Fix magazzino
This commit is contained in:
@@ -7,7 +7,6 @@ import { tipiEventoRouter } from './routes/tipiEvento.routes';
|
||||
import { listeRouter } from './routes/liste.routes';
|
||||
import { magazzinoRouter } from './routes/magazzino.routes';
|
||||
import { gruppiMagazzinoRouter } from './routes/gruppiMagazzino.routes';
|
||||
import { eventiRouter } from './routes/eventi.routes';
|
||||
import { autocompleteRouter } from './routes/autocomplete.routes';
|
||||
import { notificheRouter } from './routes/notifiche.routes';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
@@ -27,7 +26,6 @@ app.use(tipiEventoRouter);
|
||||
app.use(listeRouter);
|
||||
app.use(magazzinoRouter);
|
||||
app.use(gruppiMagazzinoRouter);
|
||||
app.use(eventiRouter);
|
||||
app.use(autocompleteRouter);
|
||||
app.use(notificheRouter);
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AggiornaCheckInput, aggiornaCheckEvento, creaEvento, getDettaglioEvento } from '../services/eventi.service';
|
||||
import { HttpError } from '../errors';
|
||||
|
||||
interface PostEventoBody {
|
||||
nome?: unknown;
|
||||
listaId?: unknown;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
function parseData(value: unknown): Date {
|
||||
if (typeof value !== 'string') {
|
||||
throw new HttpError(400, "Il campo 'data' è obbligatorio ed è una stringa in formato data");
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new HttpError(400, "Il campo 'data' non è una data valida");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseCreateBody(body: PostEventoBody): { nome: string; listaId: string; data: Date } {
|
||||
if (typeof body.nome !== 'string' || body.nome.trim().length === 0) {
|
||||
throw new HttpError(400, "Il campo 'nome' è obbligatorio ed è una stringa non vuota");
|
||||
}
|
||||
if (typeof body.listaId !== 'string' || body.listaId.trim().length === 0) {
|
||||
throw new HttpError(400, "Il campo 'listaId' è obbligatorio ed è una stringa non vuota");
|
||||
}
|
||||
|
||||
return { nome: body.nome, listaId: body.listaId, data: parseData(body.data) };
|
||||
}
|
||||
|
||||
export async function postEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const input = parseCreateBody(req.body ?? {});
|
||||
const evento = await creaEvento(req.auth!.orgId!, input);
|
||||
res.status(201).json(evento);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEvento(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const evento = await getDettaglioEvento(req.params.id, req.auth!.orgId!);
|
||||
res.status(200).json(evento);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
interface CheckVoceBody {
|
||||
materialeId?: unknown;
|
||||
portato?: unknown;
|
||||
note?: unknown;
|
||||
}
|
||||
|
||||
interface PatchCheckBody {
|
||||
voci?: unknown;
|
||||
}
|
||||
|
||||
function parseCheckBody(body: PatchCheckBody): AggiornaCheckInput[] {
|
||||
if (!Array.isArray(body.voci) || body.voci.length === 0) {
|
||||
throw new HttpError(400, "Il campo 'voci' è obbligatorio ed è un array non vuoto");
|
||||
}
|
||||
|
||||
return body.voci.map((voce: CheckVoceBody) => {
|
||||
if (typeof voce.materialeId !== 'string' || voce.materialeId.trim().length === 0) {
|
||||
throw new HttpError(400, "Ogni voce deve avere un 'materialeId' valido");
|
||||
}
|
||||
if (voce.portato !== undefined && typeof voce.portato !== 'boolean') {
|
||||
throw new HttpError(400, "Il campo 'portato', se presente, deve essere un booleano");
|
||||
}
|
||||
if (voce.note !== undefined && voce.note !== null && typeof voce.note !== 'string') {
|
||||
throw new HttpError(400, "Il campo 'note', se presente, deve essere una stringa o null");
|
||||
}
|
||||
|
||||
return {
|
||||
materialeId: voce.materialeId,
|
||||
portato: voce.portato as boolean | undefined,
|
||||
note: voce.note as string | null | undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchEventoCheck(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const voci = parseCheckBody(req.body ?? {});
|
||||
const evento = await aggiornaCheckEvento(req.params.id, req.auth!.orgId!, voci);
|
||||
res.status(200).json(evento);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../db/prisma';
|
||||
|
||||
const includeEvento = {
|
||||
lista: { include: { voci: { include: { materiale: true } } } },
|
||||
check: true,
|
||||
} satisfies Prisma.EventoInclude;
|
||||
|
||||
export type EventoConDettagli = Prisma.EventoGetPayload<{ include: typeof includeEvento }>;
|
||||
|
||||
export interface CreateEventoData {
|
||||
orgId: string;
|
||||
nome: string;
|
||||
listaId: string;
|
||||
data: Date;
|
||||
}
|
||||
|
||||
export interface UpsertCheckData {
|
||||
portato?: boolean;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export class EventiRepository {
|
||||
// id + orgId nella stessa where: un evento di un'altra org risulta
|
||||
// semplicemente "non trovato", mai un 403 che ne rivela l'esistenza.
|
||||
findByIdAndOrg(id: string, orgId: string): Promise<EventoConDettagli | null> {
|
||||
return prisma.evento.findFirst({ where: { id, orgId }, include: includeEvento });
|
||||
}
|
||||
|
||||
create(data: CreateEventoData): Promise<EventoConDettagli> {
|
||||
return prisma.evento.create({ data, include: includeEvento });
|
||||
}
|
||||
|
||||
upsertCheck(eventoId: string, materialeId: string, data: UpsertCheckData): Promise<void> {
|
||||
return prisma.eventoCheck
|
||||
.upsert({
|
||||
where: { eventoId_materialeId: { eventoId, materialeId } },
|
||||
create: { eventoId, materialeId, portato: data.portato ?? false, note: data.note ?? null },
|
||||
update: {
|
||||
...(data.portato !== undefined ? { portato: data.portato } : {}),
|
||||
...(data.note !== undefined ? { note: data.note } : {}),
|
||||
},
|
||||
})
|
||||
.then(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export const eventiRepository = new EventiRepository();
|
||||
@@ -113,13 +113,6 @@ export class ListeRepository {
|
||||
return prisma.lista.findFirst({ where: { id }, include: includeVoci });
|
||||
}
|
||||
|
||||
// Usato solo da eventi.service.ts (un Evento, sempre org-scoped, può collegarsi solo
|
||||
// a una lista 'gruppo' della propria org: le altre liste hanno orgId null e non
|
||||
// potranno mai combaciare). Non toccare la firma: è un contratto tra i due moduli.
|
||||
findByIdAndOrg(id: string, orgId: string): Promise<ListaConVoci | null> {
|
||||
return prisma.lista.findFirst({ where: { id, orgId }, include: includeVoci });
|
||||
}
|
||||
|
||||
create(data: CreateListaData, db: Db = prisma): Promise<ListaConVoci> {
|
||||
return db.lista.create({
|
||||
data: {
|
||||
|
||||
@@ -26,11 +26,6 @@ export interface UpdateMagazzinoVoceData {
|
||||
gruppoId?: string | null;
|
||||
}
|
||||
|
||||
export interface QuantitaPosseduta {
|
||||
materialeId: string;
|
||||
quantitaPosseduta: number;
|
||||
}
|
||||
|
||||
export class MagazzinoRepository {
|
||||
findAllByOrg(orgId: string): Promise<MagazzinoVoceConMateriale[]> {
|
||||
return prisma.magazzinoVoce.findMany({
|
||||
@@ -57,15 +52,6 @@ export class MagazzinoRepository {
|
||||
async delete(id: string): Promise<void> {
|
||||
await prisma.magazzinoVoce.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// Usato per il join evento<->magazzino: quantità possedute dall'org per un
|
||||
// sottoinsieme di materiali (quelli della lista collegata all'evento).
|
||||
findQuantitaByOrgEMateriali(orgId: string, materialeIds: string[]): Promise<QuantitaPosseduta[]> {
|
||||
return prisma.magazzinoVoce.findMany({
|
||||
where: { orgId, materialeId: { in: materialeIds } },
|
||||
select: { materialeId: true, quantitaPosseduta: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const magazzinoRepository = new MagazzinoRepository();
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { verifyToken } from '../auth/verify-token.middleware';
|
||||
import { requireOrgId } from '../auth/require-org-id.middleware';
|
||||
import { getEvento, patchEventoCheck, postEvento } from '../controllers/eventi.controller';
|
||||
|
||||
export const eventiRouter = Router();
|
||||
|
||||
eventiRouter.post('/eventi', verifyToken, requireOrgId, postEvento);
|
||||
eventiRouter.get('/eventi/:id', verifyToken, requireOrgId, getEvento);
|
||||
eventiRouter.patch('/eventi/:id/check', verifyToken, requireOrgId, patchEventoCheck);
|
||||
@@ -1,109 +0,0 @@
|
||||
import { EventoConDettagli, eventiRepository } from '../repositories/eventi.repository';
|
||||
import { listeRepository } from '../repositories/liste.repository';
|
||||
import { magazzinoRepository } from '../repositories/magazzino.repository';
|
||||
import { HttpError } from '../errors';
|
||||
|
||||
export interface EventoVoceView {
|
||||
materialeId: string;
|
||||
nome: string;
|
||||
unitaMisura: string;
|
||||
quantitaRichiesta: number;
|
||||
quantitaPosseduta: number;
|
||||
portato: boolean;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface EventoDettaglioView {
|
||||
id: string;
|
||||
orgId: string;
|
||||
nome: string;
|
||||
listaId: string;
|
||||
data: Date;
|
||||
voci: EventoVoceView[];
|
||||
}
|
||||
|
||||
// Join fra le voci della lista collegata all'evento e il magazzino dell'org:
|
||||
// per ogni materiale della lista, quanto ne possiede l'org (0 se non tracciato)
|
||||
// e lo stato di check (di default "non portato", nessuna nota) finché non
|
||||
// viene aggiornato via PATCH /eventi/:id/check.
|
||||
async function buildDettaglioView(evento: EventoConDettagli): Promise<EventoDettaglioView> {
|
||||
const materialeIds = evento.lista.voci.map((v) => v.materialeId);
|
||||
const quantitaPossedute =
|
||||
materialeIds.length > 0 ? await magazzinoRepository.findQuantitaByOrgEMateriali(evento.orgId, materialeIds) : [];
|
||||
const magazzinoByMateriale = new Map(quantitaPossedute.map((m) => [m.materialeId, m.quantitaPosseduta]));
|
||||
const checkByMateriale = new Map(evento.check.map((c) => [c.materialeId, c]));
|
||||
|
||||
return {
|
||||
id: evento.id,
|
||||
orgId: evento.orgId,
|
||||
nome: evento.nome,
|
||||
listaId: evento.listaId,
|
||||
data: evento.data,
|
||||
voci: evento.lista.voci.map((v) => {
|
||||
const check = checkByMateriale.get(v.materialeId);
|
||||
return {
|
||||
materialeId: v.materialeId,
|
||||
nome: v.materiale.nome,
|
||||
unitaMisura: v.materiale.unitaMisura,
|
||||
quantitaRichiesta: v.quantita,
|
||||
quantitaPosseduta: magazzinoByMateriale.get(v.materialeId) ?? 0,
|
||||
portato: check?.portato ?? false,
|
||||
note: check?.note ?? null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreaEventoInput {
|
||||
nome: string;
|
||||
listaId: string;
|
||||
data: Date;
|
||||
}
|
||||
|
||||
export async function creaEvento(orgId: string, input: CreaEventoInput): Promise<EventoDettaglioView> {
|
||||
const lista = await listeRepository.findByIdAndOrg(input.listaId, orgId);
|
||||
if (!lista) {
|
||||
throw new HttpError(400, 'La lista indicata non esiste o non appartiene alla tua organizzazione');
|
||||
}
|
||||
|
||||
const evento = await eventiRepository.create({ orgId, nome: input.nome, listaId: input.listaId, data: input.data });
|
||||
return buildDettaglioView(evento);
|
||||
}
|
||||
|
||||
export async function getDettaglioEvento(id: string, orgId: string): Promise<EventoDettaglioView> {
|
||||
const evento = await eventiRepository.findByIdAndOrg(id, orgId);
|
||||
if (!evento) {
|
||||
throw new HttpError(404, 'Evento non trovato');
|
||||
}
|
||||
return buildDettaglioView(evento);
|
||||
}
|
||||
|
||||
export interface AggiornaCheckInput {
|
||||
materialeId: string;
|
||||
portato?: boolean;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export async function aggiornaCheckEvento(
|
||||
id: string,
|
||||
orgId: string,
|
||||
voci: AggiornaCheckInput[],
|
||||
): Promise<EventoDettaglioView> {
|
||||
const evento = await eventiRepository.findByIdAndOrg(id, orgId);
|
||||
if (!evento) {
|
||||
throw new HttpError(404, 'Evento non trovato');
|
||||
}
|
||||
|
||||
const materialiDellaLista = new Set(evento.lista.voci.map((v) => v.materialeId));
|
||||
for (const voce of voci) {
|
||||
if (!materialiDellaLista.has(voce.materialeId)) {
|
||||
throw new HttpError(400, `Il materiale ${voce.materialeId} non fa parte della lista collegata a questo evento`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const voce of voci) {
|
||||
await eventiRepository.upsertCheck(id, voce.materialeId, { portato: voce.portato, note: voce.note });
|
||||
}
|
||||
|
||||
return getDettaglioEvento(id, orgId);
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import { generateKeyPairSync } from 'crypto';
|
||||
import request from 'supertest';
|
||||
import nock from 'nock';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
process.env.KEYCLOAK_BASE_URL = 'http://keycloak.test';
|
||||
process.env.KEYCLOAK_REALM = 'scouthub';
|
||||
process.env.KEYCLOAK_MAGAZZINO_CLIENT_ID = 'test-client';
|
||||
process.env.KEYCLOAK_MAGAZZINO_CLIENT_SECRET = 'test-secret';
|
||||
process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/scouthub_magazzino_test';
|
||||
|
||||
const eventoFindFirst = jest.fn();
|
||||
const eventoCreate = jest.fn();
|
||||
const eventoCheckUpsert = jest.fn();
|
||||
const listaFindFirst = jest.fn();
|
||||
const magazzinoVoceFindMany = jest.fn();
|
||||
|
||||
jest.mock('../../src/db/prisma', () => ({
|
||||
prisma: {
|
||||
evento: {
|
||||
findFirst: (...args: unknown[]) => eventoFindFirst(...args),
|
||||
create: (...args: unknown[]) => eventoCreate(...args),
|
||||
},
|
||||
eventoCheck: {
|
||||
upsert: (...args: unknown[]) => eventoCheckUpsert(...args),
|
||||
},
|
||||
lista: {
|
||||
findFirst: (...args: unknown[]) => listaFindFirst(...args),
|
||||
},
|
||||
magazzinoVoce: {
|
||||
findMany: (...args: unknown[]) => magazzinoVoceFindMany(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { app } from '../../src/app';
|
||||
|
||||
const KEYCLOAK_HOST = 'http://keycloak.test';
|
||||
const CERTS_PATH = '/realms/scouthub/protocol/openid-connect/certs';
|
||||
const KID = 'test-kid';
|
||||
|
||||
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const jwk = publicKey.export({ format: 'jwk' }) as Record<string, unknown>;
|
||||
const privateKeyPem = privateKey.export({ type: 'pkcs1', format: 'pem' }) as string;
|
||||
|
||||
function signToken(payload: object): string {
|
||||
return jwt.sign(payload, privateKeyPem, { algorithm: 'RS256', keyid: KID, expiresIn: '5m' });
|
||||
}
|
||||
|
||||
function tokenOrg(orgId: string): string {
|
||||
return signToken({
|
||||
sub: 'user-1',
|
||||
realm_access: { roles: ['censito'] },
|
||||
organization: { gruppo: { id: orgId, roles: [] } },
|
||||
});
|
||||
}
|
||||
|
||||
function materiale(id: string, nome: string, unitaMisura: string) {
|
||||
return { id, nome, categoria: 'x', unitaMisura, stato: 'approvato', propostoDaOrgId: 'org-seed', creatoIl: new Date() };
|
||||
}
|
||||
|
||||
// Evento con lista a due voci (Tenda x2, Torcia x4): un materiale è tracciato
|
||||
// in magazzino, l'altro no (deve risultare quantitaPosseduta: 0 di default).
|
||||
function eventoConDettagli(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
id: 'ev-1',
|
||||
orgId: 'org-a',
|
||||
nome: 'Campo estivo 2026',
|
||||
listaId: 'l-1',
|
||||
data: new Date('2026-08-01'),
|
||||
lista: {
|
||||
id: 'l-1',
|
||||
nome: 'Kit campo estivo',
|
||||
orgId: 'org-a',
|
||||
creataIl: new Date('2026-01-01'),
|
||||
voci: [
|
||||
{ listaId: 'l-1', materialeId: 'm-1', quantita: 2, materiale: materiale('m-1', 'Tenda', 'pz') },
|
||||
{ listaId: 'l-1', materialeId: 'm-2', quantita: 4, materiale: materiale('m-2', 'Torcia', 'pz') },
|
||||
],
|
||||
},
|
||||
check: [{ eventoId: 'ev-1', materialeId: 'm-1', portato: true, note: 'controllata' }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
nock(KEYCLOAK_HOST).persist().get(CERTS_PATH).reply(200, {
|
||||
keys: [{ ...jwk, kid: KID, alg: 'RS256', use: 'sig' }],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('POST /eventi', () => {
|
||||
test("crea l'evento per l'org corrente se la lista appartiene alla stessa org", async () => {
|
||||
listaFindFirst.mockResolvedValueOnce({ id: 'l-1', nome: 'Kit', orgId: 'org-a', creataIl: new Date(), voci: [] });
|
||||
eventoCreate.mockResolvedValueOnce(eventoConDettagli({ check: [] }));
|
||||
magazzinoVoceFindMany.mockResolvedValueOnce([]);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/eventi')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Campo estivo 2026', listaId: 'l-1', data: '2026-08-01' });
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(listaFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'l-1', orgId: 'org-a' } }),
|
||||
);
|
||||
expect(eventoCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ orgId: 'org-a', listaId: 'l-1' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('risponde 400 se la lista non esiste o appartiene a un\'altra org', async () => {
|
||||
listaFindFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/eventi')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ nome: 'Campo estivo 2026', listaId: 'l-di-unaltra-org', data: '2026-08-01' });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(eventoCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).post('/eventi').send({ nome: 'x', listaId: 'l-1', data: '2026-08-01' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(eventoCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /eventi/:id — join lista <-> magazzino', () => {
|
||||
test("un'org non può leggere un evento di un'altra org", async () => {
|
||||
eventoFindFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app).get('/eventi/ev-1').set('Authorization', `Bearer ${tokenOrg('org-b')}`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(eventoFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { id: 'ev-1', orgId: 'org-b' } }),
|
||||
);
|
||||
expect(magazzinoVoceFindMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('combina, per ogni voce della lista, quantità posseduta in magazzino e stato di check', async () => {
|
||||
eventoFindFirst.mockResolvedValueOnce(eventoConDettagli());
|
||||
// Solo m-1 è tracciato in magazzino (5 posseduti); m-2 non ha alcuna riga.
|
||||
magazzinoVoceFindMany.mockResolvedValueOnce([{ materialeId: 'm-1', quantitaPosseduta: 5 }]);
|
||||
|
||||
const response = await request(app).get('/eventi/ev-1').set('Authorization', `Bearer ${tokenOrg('org-a')}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(magazzinoVoceFindMany).toHaveBeenCalledWith({
|
||||
where: { orgId: 'org-a', materialeId: { in: ['m-1', 'm-2'] } },
|
||||
select: { materialeId: true, quantitaPosseduta: true },
|
||||
});
|
||||
expect(response.body).toEqual({
|
||||
id: 'ev-1',
|
||||
orgId: 'org-a',
|
||||
nome: 'Campo estivo 2026',
|
||||
listaId: 'l-1',
|
||||
data: '2026-08-01T00:00:00.000Z',
|
||||
voci: [
|
||||
{
|
||||
materialeId: 'm-1',
|
||||
nome: 'Tenda',
|
||||
unitaMisura: 'pz',
|
||||
quantitaRichiesta: 2,
|
||||
quantitaPosseduta: 5,
|
||||
portato: true,
|
||||
note: 'controllata',
|
||||
},
|
||||
{
|
||||
materialeId: 'm-2',
|
||||
nome: 'Torcia',
|
||||
unitaMisura: 'pz',
|
||||
quantitaRichiesta: 4,
|
||||
quantitaPosseduta: 0,
|
||||
portato: false,
|
||||
note: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).get('/eventi/ev-1');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(eventoFindFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /eventi/:id/check', () => {
|
||||
test('aggiorna portato/note per una voce e restituisce il dettaglio aggiornato', async () => {
|
||||
eventoFindFirst
|
||||
.mockResolvedValueOnce(eventoConDettagli({ check: [] })) // ownership check dentro aggiornaCheckEvento
|
||||
.mockResolvedValueOnce(eventoConDettagli()); // rilettura per la response
|
||||
eventoCheckUpsert.mockResolvedValueOnce({ eventoId: 'ev-1', materialeId: 'm-1', portato: true, note: 'controllata' });
|
||||
magazzinoVoceFindMany.mockResolvedValue([{ materialeId: 'm-1', quantitaPosseduta: 5 }]);
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/eventi/ev-1/check')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ voci: [{ materialeId: 'm-1', portato: true, note: 'controllata' }] });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(eventoCheckUpsert).toHaveBeenCalledWith({
|
||||
where: { eventoId_materialeId: { eventoId: 'ev-1', materialeId: 'm-1' } },
|
||||
create: { eventoId: 'ev-1', materialeId: 'm-1', portato: true, note: 'controllata' },
|
||||
update: { portato: true, note: 'controllata' },
|
||||
});
|
||||
expect(response.body.voci[0]).toMatchObject({ materialeId: 'm-1', portato: true, note: 'controllata' });
|
||||
});
|
||||
|
||||
test('rifiuta un materialeId che non appartiene alla lista collegata (400)', async () => {
|
||||
eventoFindFirst.mockResolvedValueOnce(eventoConDettagli());
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/eventi/ev-1/check')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-a')}`)
|
||||
.send({ voci: [{ materialeId: 'm-estraneo', portato: true }] });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(eventoCheckUpsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("un'org non può aggiornare il check di un evento di un'altra org", async () => {
|
||||
eventoFindFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await request(app)
|
||||
.patch('/eventi/ev-1/check')
|
||||
.set('Authorization', `Bearer ${tokenOrg('org-b')}`)
|
||||
.send({ voci: [{ materialeId: 'm-1', portato: true }] });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(eventoCheckUpsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('risponde 401 senza token', async () => {
|
||||
const response = await request(app).patch('/eventi/ev-1/check').send({ voci: [{ materialeId: 'm-1', portato: true }] });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(eventoCheckUpsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
.catalogo-materiali__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.catalogo-materiali__filtri {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-5);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.catalogo-materiali__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.catalogo-materiali__error {
|
||||
color: var(--color-accent-800);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<section class="catalogo-materiali om-page">
|
||||
<div class="catalogo-materiali__header">
|
||||
<div>
|
||||
<h1 class="om-section-title">Catalogo materiali</h1>
|
||||
<p class="om-section-sub">Materiali condivisi tra tutte le organizzazioni. Consultabile senza login.</p>
|
||||
</div>
|
||||
<a class="btn btn-ghost" routerLink="/">Liste modello per evento</a>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<p class="om-empty">Caricamento catalogo…</p>
|
||||
} @else if (loadError(); as message) {
|
||||
<p class="catalogo-materiali__error" role="alert">{{ message }}</p>
|
||||
} @else {
|
||||
<div class="seg catalogo-materiali__filtri" role="group" aria-label="Filtra per categoria">
|
||||
<button
|
||||
type="button"
|
||||
class="seg-opt"
|
||||
[class.seg-opt--attiva]="categoriaSelezionata() === null"
|
||||
(click)="selezionaCategoria(null)"
|
||||
>
|
||||
Tutte
|
||||
</button>
|
||||
@for (categoria of categorie(); track categoria) {
|
||||
<button
|
||||
type="button"
|
||||
class="seg-opt"
|
||||
[class.seg-opt--attiva]="categoriaSelezionata() === categoria"
|
||||
(click)="selezionaCategoria(categoria)"
|
||||
>
|
||||
{{ categoria }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (materialiFiltrati().length === 0) {
|
||||
<p class="om-empty">Nessun materiale trovato.</p>
|
||||
} @else {
|
||||
<div class="catalogo-materiali__grid">
|
||||
@for (materiale of materialiFiltrati(); track materiale.id) {
|
||||
<div class="card materiale-card">
|
||||
<div class="card-kicker">{{ materiale.categoria }}</div>
|
||||
<div class="card-title">{{ materiale.nome }}</div>
|
||||
<p class="card-body">Unità di misura: {{ materiale.unitaMisura }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -1,79 +0,0 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
|
||||
import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service';
|
||||
import { CatalogoMateriali } from './catalogo-materiali';
|
||||
|
||||
describe('CatalogoMateriali', () => {
|
||||
let component: CatalogoMateriali;
|
||||
let fixture: ComponentFixture<CatalogoMateriali>;
|
||||
let materialiApi: { getMateriali: ReturnType<typeof vi.fn> };
|
||||
|
||||
const materiali: MaterialePubblico[] = [
|
||||
{ id: 'mat-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' },
|
||||
{ id: 'mat-2', nome: 'Telo cerato', categoria: 'Campeggio', unitaMisura: 'pz' },
|
||||
{ id: 'mat-3', nome: 'Moschettone', categoria: 'Attrezzatura', unitaMisura: 'pz' }
|
||||
];
|
||||
|
||||
async function setup(): Promise<void> {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CatalogoMateriali],
|
||||
providers: [provideRouter([]), { provide: MaterialiApiService, useValue: materialiApi }]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CatalogoMateriali);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
it('mostra tutti i materiali del catalogo dopo il caricamento', async () => {
|
||||
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
|
||||
|
||||
await setup();
|
||||
|
||||
expect(component.loading()).toBe(false);
|
||||
expect(component.materiali()).toEqual(materiali);
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const cards = compiled.querySelectorAll('.materiale-card');
|
||||
expect(cards.length).toBe(3);
|
||||
});
|
||||
|
||||
it('filtra i materiali per categoria quando si seleziona un filtro', async () => {
|
||||
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
|
||||
|
||||
await setup();
|
||||
|
||||
component.selezionaCategoria('Attrezzatura');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.materialiFiltrati()).toEqual([materiali[0], materiali[2]]);
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const cards = compiled.querySelectorAll('.materiale-card');
|
||||
expect(cards.length).toBe(2);
|
||||
});
|
||||
|
||||
it('torna a mostrare tutti i materiali selezionando di nuovo "Tutte"', async () => {
|
||||
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
|
||||
|
||||
await setup();
|
||||
|
||||
component.selezionaCategoria('Campeggio');
|
||||
component.selezionaCategoria(null);
|
||||
|
||||
expect(component.materialiFiltrati()).toEqual(materiali);
|
||||
});
|
||||
|
||||
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
|
||||
materialiApi = { getMateriali: vi.fn().mockReturnValue(throwError(() => new Error('network error'))) };
|
||||
|
||||
await setup();
|
||||
|
||||
expect(component.loading()).toBe(false);
|
||||
expect(component.loadError()).toBe('Impossibile caricare il catalogo dei materiali. Riprova più tardi.');
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-catalogo-materiali',
|
||||
imports: [MatButtonModule, MatCardModule, RouterLink],
|
||||
templateUrl: './catalogo-materiali.html',
|
||||
styleUrl: './catalogo-materiali.css'
|
||||
})
|
||||
export class CatalogoMateriali implements OnInit {
|
||||
private readonly materialiApi = inject(MaterialiApiService);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
readonly materiali = signal<MaterialePubblico[]>([]);
|
||||
readonly categoriaSelezionata = signal<string | null>(null);
|
||||
|
||||
readonly categorie = computed(() => {
|
||||
const insieme = new Set(this.materiali().map((materiale) => materiale.categoria));
|
||||
return Array.from(insieme).sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
readonly materialiFiltrati = computed(() => {
|
||||
const categoria = this.categoriaSelezionata();
|
||||
const materiali = this.materiali();
|
||||
return categoria ? materiali.filter((materiale) => materiale.categoria === categoria) : materiali;
|
||||
});
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.caricaMateriali();
|
||||
}
|
||||
|
||||
selezionaCategoria(categoria: string | null): void {
|
||||
this.categoriaSelezionata.set(categoria);
|
||||
}
|
||||
|
||||
private async caricaMateriali(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.loadError.set(null);
|
||||
|
||||
try {
|
||||
const materiali = await firstValueFrom(this.materialiApi.getMateriali());
|
||||
this.materiali.set(materiali);
|
||||
} catch {
|
||||
this.loadError.set('Impossibile caricare il catalogo dei materiali. Riprova più tardi.');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,6 @@ export const CATALOGO_ROUTES: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./catalogo-liste-modello/catalogo-liste-modello').then((m) => m.CatalogoListeModello)
|
||||
},
|
||||
{
|
||||
// Pubblica: il catalogo del materiale è consultabile anche da chi non è autenticato.
|
||||
path: 'catalogo-materiali',
|
||||
loadComponent: () => import('./catalogo-materiali/catalogo-materiali').then((m) => m.CatalogoMateriali)
|
||||
},
|
||||
{
|
||||
// Pubblica: la ricerca delle liste modello è consultabile anche da chi non è autenticato.
|
||||
path: 'cerca-lista',
|
||||
|
||||
@@ -105,4 +105,8 @@ export class ListeApiService {
|
||||
aggiornaLista(id: string, input: AggiornaListaInput): Observable<Lista> {
|
||||
return this.http.put<Lista>(`${environment.magazzinoApiBaseUrl}/liste/${id}`, input);
|
||||
}
|
||||
|
||||
eliminaLista(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${environment.magazzinoApiBaseUrl}/liste/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,11 @@
|
||||
border: 1px solid var(--color-divider);
|
||||
}
|
||||
|
||||
.riga-azioni {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.riga-modifica {
|
||||
cursor: pointer;
|
||||
padding: 9px 14px;
|
||||
@@ -107,6 +112,22 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.riga-elimina {
|
||||
cursor: pointer;
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-accent-800);
|
||||
color: var(--color-accent-800);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.errore-inline {
|
||||
color: var(--color-accent-800);
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
❌ proposta rifiutata
|
||||
</div>
|
||||
}
|
||||
@if (eliminaErroreId() === lista.id) {
|
||||
<div class="errore-inline">{{ eliminaErroreMsg() }}</div>
|
||||
}
|
||||
</div>
|
||||
@if (puoCambiareStato(lista)) {
|
||||
<div class="stato-options">
|
||||
@@ -58,7 +61,10 @@
|
||||
🔒 solo il creatore può cambiare lo stato
|
||||
</div>
|
||||
}
|
||||
<div class="riga-modifica" (click)="modifica(lista)">Modifica</div>
|
||||
<div class="riga-azioni">
|
||||
<div class="riga-modifica" (click)="modifica(lista)">Modifica</div>
|
||||
<div class="riga-elimina" (click)="elimina(lista)">Elimina</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -176,4 +176,37 @@ describe('ListeList', () => {
|
||||
|
||||
expect(component.loadError()).toBe('Impossibile caricare le liste. Riprova più tardi.');
|
||||
});
|
||||
|
||||
describe('eliminazione di una lista', () => {
|
||||
it('rimuove la lista dall\'elenco al successo', async () => {
|
||||
listeApi = {
|
||||
getListe: vi.fn().mockReturnValue(of(liste)),
|
||||
eliminaLista: vi.fn().mockReturnValue(of(undefined))
|
||||
} as unknown as typeof listeApi;
|
||||
|
||||
await setup();
|
||||
await component.elimina(liste[0]);
|
||||
|
||||
expect((listeApi as unknown as { eliminaLista: ReturnType<typeof vi.fn> }).eliminaLista).toHaveBeenCalledWith(
|
||||
'lista-1'
|
||||
);
|
||||
expect(component.liste().some((l) => l.id === 'lista-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('mostra un errore inline sulla riga se l\'eliminazione fallisce', async () => {
|
||||
listeApi = {
|
||||
getListe: vi.fn().mockReturnValue(of(liste)),
|
||||
eliminaLista: vi
|
||||
.fn()
|
||||
.mockReturnValue(throwError(() => ({ error: { message: 'Lista usata come sotto-lista.' } })))
|
||||
} as unknown as typeof listeApi;
|
||||
|
||||
await setup();
|
||||
await component.elimina(liste[0]);
|
||||
|
||||
expect(component.eliminaErroreId()).toBe('lista-1');
|
||||
expect(component.eliminaErroreMsg()).toBe('Lista usata come sotto-lista.');
|
||||
expect(component.liste().some((l) => l.id === 'lista-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,9 @@ export class ListeList implements OnInit {
|
||||
readonly liste = signal<Lista[]>([]);
|
||||
readonly stati = STATI_LISTA;
|
||||
|
||||
readonly eliminaErroreId = signal<string | null>(null);
|
||||
readonly eliminaErroreMsg = signal<string | null>(null);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.caricaListe();
|
||||
}
|
||||
@@ -122,4 +125,31 @@ export class ListeList implements OnInit {
|
||||
error: () => this.loadError.set('Impossibile cambiare stato alla lista. Riprova più tardi.')
|
||||
});
|
||||
}
|
||||
|
||||
async elimina(lista: Lista): Promise<void> {
|
||||
this.eliminaErroreId.set(null);
|
||||
this.eliminaErroreMsg.set(null);
|
||||
try {
|
||||
await firstValueFrom(this.listeApi.eliminaLista(lista.id));
|
||||
this.liste.update((liste) => liste.filter((l) => l.id !== lista.id));
|
||||
} catch (err) {
|
||||
this.eliminaErroreId.set(lista.id);
|
||||
this.eliminaErroreMsg.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
private estraiMessaggioErrore(err: unknown): string {
|
||||
if (err && typeof err === 'object' && 'error' in err) {
|
||||
const body = (err as { error?: unknown }).error;
|
||||
if (
|
||||
body &&
|
||||
typeof body === 'object' &&
|
||||
'message' in body &&
|
||||
typeof (body as { message?: unknown }).message === 'string'
|
||||
) {
|
||||
return (body as { message: string }).message;
|
||||
}
|
||||
}
|
||||
return "Impossibile completare l'operazione.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface MagazzinoVoce {
|
||||
stato: StatoMagazzinoVoce;
|
||||
posizione: string | null;
|
||||
note: string | null;
|
||||
gruppoId: string | null;
|
||||
}
|
||||
|
||||
export interface AggiungiMagazzinoVoceInput {
|
||||
@@ -24,6 +25,7 @@ export interface AggiungiMagazzinoVoceInput {
|
||||
stato: StatoMagazzinoVoce;
|
||||
posizione?: string;
|
||||
note?: string;
|
||||
gruppoId?: string | null;
|
||||
}
|
||||
|
||||
export interface AggiornaMagazzinoVoceInput {
|
||||
@@ -32,6 +34,7 @@ export interface AggiornaMagazzinoVoceInput {
|
||||
stato?: StatoMagazzinoVoce;
|
||||
posizione?: string | null;
|
||||
note?: string | null;
|
||||
gruppoId?: string | null;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -49,4 +52,8 @@ export class MagazzinoApiService {
|
||||
aggiornaVoce(id: string, input: AggiornaMagazzinoVoceInput): Observable<MagazzinoVoce> {
|
||||
return this.http.put<MagazzinoVoce>(`${environment.magazzinoApiBaseUrl}/magazzino/${id}`, input);
|
||||
}
|
||||
|
||||
eliminaVoce(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${environment.magazzinoApiBaseUrl}/magazzino/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,15 +19,34 @@
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.magazzino__tabs {
|
||||
/* Stile bottoni tab replicato 1:1 da tassonomie.css (.tabs/.tab/.tab--attivo),
|
||||
per coerenza visiva tra le pagine con viste alternative a tab. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-4);
|
||||
gap: 6px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.magazzino__tabs .seg-opt {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
.tab {
|
||||
cursor: pointer;
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-divider);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tab--attivo {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.magazzino__tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.magazzino__error {
|
||||
@@ -37,30 +56,13 @@
|
||||
.magazzino__alfabeto {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.magazzino__lettera {
|
||||
min-width: 28px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.magazzino__lettera:hover {
|
||||
background: color-mix(in srgb, var(--color-text) 7%, transparent);
|
||||
}
|
||||
|
||||
.magazzino__lettera--attiva {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.magazzino__contenuto--con-form {
|
||||
@@ -85,6 +87,7 @@
|
||||
|
||||
.magazzino__posizione-voce {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
@@ -93,6 +96,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.magazzino__posizione-voce-errore {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.magazzino__posizione-lista li:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
@@ -120,7 +128,7 @@
|
||||
|
||||
.magazzino__form {
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.magazzino__materiale {
|
||||
@@ -179,6 +187,12 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.magazzino__tabella-azioni {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.magazzino-badge--buono {
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-800);
|
||||
@@ -193,3 +207,38 @@
|
||||
background: var(--color-neutral-200);
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.magazzino__gruppo-nuovo {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.magazzino__gruppo-form {
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.magazzino__gruppo-card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.magazzino__gruppo-azioni {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.magazzino__gruppo-figli {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.magazzino__gruppo-figlio {
|
||||
padding-left: var(--space-3);
|
||||
border-left: 2px solid var(--color-divider);
|
||||
}
|
||||
|
||||
@@ -6,18 +6,16 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="seg magazzino__tabs" role="group" aria-label="Visualizzazione magazzino">
|
||||
<button type="button" class="seg-opt" [class.seg-opt--attiva]="vista() === 'lista'" (click)="vista.set('lista')">
|
||||
<div class="tabs magazzino__tabs" role="group" aria-label="Visualizzazione magazzino">
|
||||
<button type="button" class="tab" [class.tab--attivo]="vista() === 'lista'" (click)="vista.set('lista')">
|
||||
Per lista
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="seg-opt"
|
||||
[class.seg-opt--attiva]="vista() === 'posizione'"
|
||||
(click)="vista.set('posizione')"
|
||||
>
|
||||
<button type="button" class="tab" [class.tab--attivo]="vista() === 'posizione'" (click)="vista.set('posizione')">
|
||||
Per posizione
|
||||
</button>
|
||||
<button type="button" class="tab" [class.tab--attivo]="vista() === 'gruppo'" (click)="vista.set('gruppo')">
|
||||
Per gruppo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
@@ -93,6 +91,16 @@
|
||||
<label for="mg-note">Note</label>
|
||||
<input class="input" id="mg-note" formControlName="note" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="mg-gruppo">Gruppo</label>
|
||||
<select class="input" id="mg-gruppo" formControlName="gruppoId">
|
||||
<option [ngValue]="null">Nessun gruppo</option>
|
||||
@for (opzione of gruppiSelezionabili(); track opzione.id) {
|
||||
<option [ngValue]="opzione.id">{{ opzione.indentato ? '— ' : '' }}{{ opzione.nome }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (salvataggioErrore(); as message) {
|
||||
@@ -108,7 +116,7 @@
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (voci().length === 0) {
|
||||
@if (vista() !== 'gruppo' && voci().length === 0) {
|
||||
<p class="om-empty">Nessuna voce in magazzino.</p>
|
||||
} @else {
|
||||
<div class="magazzino__contenuto" [class.magazzino__contenuto--con-form]="formAperto()">
|
||||
@@ -117,8 +125,8 @@
|
||||
@for (lettera of lettereDisponibili(); track lettera) {
|
||||
<button
|
||||
type="button"
|
||||
class="magazzino__lettera"
|
||||
[class.magazzino__lettera--attiva]="letteraSelezionata() === lettera"
|
||||
class="tab magazzino__lettera"
|
||||
[class.tab--attivo]="letteraSelezionata() === lettera"
|
||||
(click)="selezionaLettera(lettera)"
|
||||
>
|
||||
{{ lettera }}
|
||||
@@ -152,15 +160,21 @@
|
||||
</td>
|
||||
<td>{{ voce.posizione ?? '—' }}</td>
|
||||
<td>{{ voce.note ?? '—' }}</td>
|
||||
<td>
|
||||
<td class="magazzino__tabella-azioni">
|
||||
<button type="button" class="btn btn-ghost" (click)="apriModificaVoce(voce)">Modifica</button>
|
||||
<button type="button" class="btn btn-ghost" (click)="eliminaVoce(voce)">Elimina</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (voceEliminaErroreId() === voce.id) {
|
||||
<tr>
|
||||
<td colspan="6" class="magazzino__error">{{ voceEliminaErroreMsg() }}</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
} @else {
|
||||
} @else if (vista() === 'posizione') {
|
||||
<div class="om-grid">
|
||||
@for (gruppo of gruppiPerPosizione(); track gruppo.posizione) {
|
||||
<div class="card magazzino__posizione-card">
|
||||
@@ -176,12 +190,186 @@
|
||||
<span [class]="'tag magazzino-badge magazzino-badge--' + voce.stato">
|
||||
{{ statoLabel(voce.stato) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
(click)="$event.stopPropagation(); eliminaVoce(voce)"
|
||||
>
|
||||
Elimina
|
||||
</button>
|
||||
@if (voceEliminaErroreId() === voce.id) {
|
||||
<p class="magazzino__error magazzino__posizione-voce-errore">{{ voceEliminaErroreMsg() }}</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@if (!gruppoFormAperto()) {
|
||||
<button type="button" class="btn btn-primary magazzino__gruppo-nuovo" (click)="apriNuovoGruppo()">
|
||||
+ Nuovo gruppo
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (gruppoFormAperto()) {
|
||||
<div class="magazzino__gruppo-form card elev-sm">
|
||||
<h2>{{ gruppoEditId() ? 'Modifica gruppo' : 'Nuovo gruppo' }}</h2>
|
||||
|
||||
<div class="magazzino__campi">
|
||||
<div class="field">
|
||||
<label for="mg-gruppo-nome">Nome</label>
|
||||
<input
|
||||
class="input"
|
||||
id="mg-gruppo-nome"
|
||||
[ngModel]="gruppoNome()"
|
||||
(ngModelChange)="gruppoNome.set($event)"
|
||||
name="gruppoNome"
|
||||
placeholder="Es. Sede, Ripostiglio A"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="mg-gruppo-padre">Gruppo padre</label>
|
||||
<select
|
||||
class="input"
|
||||
id="mg-gruppo-padre"
|
||||
[ngModel]="gruppoParentId()"
|
||||
(ngModelChange)="gruppoParentId.set($event)"
|
||||
name="gruppoParentId"
|
||||
>
|
||||
<option [ngValue]="null">Nessuno (gruppo di primo livello)</option>
|
||||
@for (radice of gruppiRadiceDisponibili(); track radice.id) {
|
||||
<option [ngValue]="radice.id">{{ radice.nome }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (gruppoSalvataggioErrore(); as message) {
|
||||
<p class="magazzino__error" role="alert">{{ message }}</p>
|
||||
}
|
||||
|
||||
<div class="magazzino__form-azioni">
|
||||
<button type="button" class="btn btn-primary" (click)="salvaGruppo()">
|
||||
{{ gruppoEditId() ? 'Salva modifiche' : '+ Aggiungi gruppo' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" (click)="annullaGruppo()">Annulla</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (gruppi().length === 0) {
|
||||
<p class="om-empty">Nessun gruppo creato.</p>
|
||||
} @else {
|
||||
<div class="om-grid">
|
||||
@for (radice of gruppi(); track radice.id) {
|
||||
<div class="card magazzino__posizione-card">
|
||||
<div class="magazzino__gruppo-card-header">
|
||||
<div class="card-title">{{ radice.nome }}</div>
|
||||
<div class="magazzino__gruppo-azioni">
|
||||
<button type="button" class="btn btn-ghost" (click)="apriNuovoGruppo(radice.id)">
|
||||
+ Sottogruppo
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" (click)="modificaGruppo(radice)">Modifica</button>
|
||||
<button type="button" class="btn btn-ghost" (click)="eliminaGruppo(radice)">Elimina</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (radice.voci.length === 0 && radice.figli.length === 0) {
|
||||
<p class="om-empty">Nessuna voce in questo gruppo.</p>
|
||||
}
|
||||
|
||||
@if (radice.voci.length > 0) {
|
||||
<ul class="magazzino__posizione-lista">
|
||||
@for (voce of radice.voci; track voce.id) {
|
||||
<li class="magazzino__posizione-voce" (click)="apriModificaVoce(voce)">
|
||||
<div class="magazzino__posizione-voce-info">
|
||||
<span class="magazzino__posizione-voce-nome">{{ voce.materialeNome }}</span>
|
||||
<span class="magazzino__posizione-voce-qta">× {{ voce.quantitaPosseduta }}</span>
|
||||
</div>
|
||||
<span [class]="'tag magazzino-badge magazzino-badge--' + voce.stato">
|
||||
{{ statoLabel(voce.stato) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
(click)="$event.stopPropagation(); eliminaVoce(voce)"
|
||||
>
|
||||
Elimina
|
||||
</button>
|
||||
@if (voceEliminaErroreId() === voce.id) {
|
||||
<p class="magazzino__error magazzino__posizione-voce-errore">
|
||||
{{ voceEliminaErroreMsg() }}
|
||||
</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (gruppoEliminaErroreId() === radice.id) {
|
||||
<p class="magazzino__error" role="alert">{{ gruppoEliminaErroreMsg() }}</p>
|
||||
}
|
||||
|
||||
@if (radice.figli.length > 0) {
|
||||
<div class="magazzino__gruppo-figli">
|
||||
@for (figlio of radice.figli; track figlio.id) {
|
||||
<div class="magazzino__gruppo-figlio">
|
||||
<div class="magazzino__gruppo-card-header">
|
||||
<div class="card-title">{{ figlio.nome }}</div>
|
||||
<div class="magazzino__gruppo-azioni">
|
||||
<button type="button" class="btn btn-ghost" (click)="modificaGruppo(figlio)">
|
||||
Modifica
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" (click)="eliminaGruppo(figlio)">
|
||||
Elimina
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (figlio.voci.length === 0) {
|
||||
<p class="om-empty">Nessuna voce in questo gruppo.</p>
|
||||
} @else {
|
||||
<ul class="magazzino__posizione-lista">
|
||||
@for (voce of figlio.voci; track voce.id) {
|
||||
<li class="magazzino__posizione-voce" (click)="apriModificaVoce(voce)">
|
||||
<div class="magazzino__posizione-voce-info">
|
||||
<span class="magazzino__posizione-voce-nome">{{ voce.materialeNome }}</span>
|
||||
<span class="magazzino__posizione-voce-qta">× {{ voce.quantitaPosseduta }}</span>
|
||||
</div>
|
||||
<span [class]="'tag magazzino-badge magazzino-badge--' + voce.stato">
|
||||
{{ statoLabel(voce.stato) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
(click)="$event.stopPropagation(); eliminaVoce(voce)"
|
||||
>
|
||||
Elimina
|
||||
</button>
|
||||
@if (voceEliminaErroreId() === voce.id) {
|
||||
<p class="magazzino__error magazzino__posizione-voce-errore">
|
||||
{{ voceEliminaErroreMsg() }}
|
||||
</p>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (gruppoEliminaErroreId() === figlio.id) {
|
||||
<p class="magazzino__error" role="alert">{{ gruppoEliminaErroreMsg() }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { of, throwError } from 'rxjs';
|
||||
|
||||
import { MaterialePubblico, MaterialiApiService } from '../catalogo/materiali-api.service';
|
||||
import { MagazzinoApiService, MagazzinoVoce } from './magazzino-api.service';
|
||||
import { GruppiMagazzinoApiService, GruppoMagazzino } from './gruppi-magazzino-api.service';
|
||||
import { Magazzino } from './magazzino';
|
||||
|
||||
describe('Magazzino', () => {
|
||||
@@ -13,8 +14,15 @@ describe('Magazzino', () => {
|
||||
getMagazzino: ReturnType<typeof vi.fn>;
|
||||
aggiungiVoce: ReturnType<typeof vi.fn>;
|
||||
aggiornaVoce: ReturnType<typeof vi.fn>;
|
||||
eliminaVoce: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let materialiApi: { getMateriali: ReturnType<typeof vi.fn> };
|
||||
let gruppiApi: {
|
||||
getGruppi: ReturnType<typeof vi.fn>;
|
||||
creaGruppo: ReturnType<typeof vi.fn>;
|
||||
aggiornaGruppo: ReturnType<typeof vi.fn>;
|
||||
eliminaGruppo: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const vociIniziali: MagazzinoVoce[] = [
|
||||
{
|
||||
@@ -26,7 +34,8 @@ describe('Magazzino', () => {
|
||||
quantitaPosseduta: 10,
|
||||
stato: 'buono',
|
||||
posizione: 'Sede',
|
||||
note: null
|
||||
note: null,
|
||||
gruppoId: null
|
||||
},
|
||||
{
|
||||
id: 'voce-2',
|
||||
@@ -37,7 +46,30 @@ describe('Magazzino', () => {
|
||||
quantitaPosseduta: 2,
|
||||
stato: 'mancante',
|
||||
posizione: null,
|
||||
note: 'Da riordinare'
|
||||
note: 'Da riordinare',
|
||||
gruppoId: 'gruppo-figlio-1'
|
||||
}
|
||||
];
|
||||
|
||||
const gruppiIniziali: GruppoMagazzino[] = [
|
||||
{
|
||||
id: 'gruppo-radice-1',
|
||||
orgId: 'org-1',
|
||||
nome: 'Sede',
|
||||
parentId: null,
|
||||
creatoIl: '2026-07-31T00:00:00.000Z',
|
||||
voci: [],
|
||||
figli: [
|
||||
{
|
||||
id: 'gruppo-figlio-1',
|
||||
orgId: 'org-1',
|
||||
nome: 'Ripostiglio A',
|
||||
parentId: 'gruppo-radice-1',
|
||||
creatoIl: '2026-07-31T00:00:00.000Z',
|
||||
voci: [vociIniziali[1]],
|
||||
figli: []
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -52,7 +84,8 @@ describe('Magazzino', () => {
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: MagazzinoApiService, useValue: magazzinoApi },
|
||||
{ provide: MaterialiApiService, useValue: materialiApi }
|
||||
{ provide: MaterialiApiService, useValue: materialiApi },
|
||||
{ provide: GruppiMagazzinoApiService, useValue: gruppiApi }
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
@@ -67,9 +100,16 @@ describe('Magazzino', () => {
|
||||
magazzinoApi = {
|
||||
getMagazzino: vi.fn().mockReturnValue(of(vociIniziali)),
|
||||
aggiungiVoce: vi.fn(),
|
||||
aggiornaVoce: vi.fn()
|
||||
aggiornaVoce: vi.fn(),
|
||||
eliminaVoce: vi.fn()
|
||||
};
|
||||
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materialiCatalogo)) };
|
||||
gruppiApi = {
|
||||
getGruppi: vi.fn().mockReturnValue(of(gruppiIniziali)),
|
||||
creaGruppo: vi.fn(),
|
||||
aggiornaGruppo: vi.fn(),
|
||||
eliminaGruppo: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
it('mostra la tabella con materiale, quantità, stato, posizione e note', async () => {
|
||||
@@ -172,7 +212,13 @@ describe('Magazzino', () => {
|
||||
|
||||
it('chiama aggiungiVoce con i dati del form e chiude il form al successo', async () => {
|
||||
component.selezionaMateriale(materialiCatalogo[1]);
|
||||
component.form.setValue({ quantitaPosseduta: 5, stato: 'da_riparare', posizione: 'Garage', note: '' });
|
||||
component.form.setValue({
|
||||
quantitaPosseduta: 5,
|
||||
stato: 'da_riparare',
|
||||
posizione: 'Garage',
|
||||
note: '',
|
||||
gruppoId: null
|
||||
});
|
||||
|
||||
const nuovaVoce: MagazzinoVoce = {
|
||||
id: 'voce-3',
|
||||
@@ -183,7 +229,8 @@ describe('Magazzino', () => {
|
||||
quantitaPosseduta: 5,
|
||||
stato: 'da_riparare',
|
||||
posizione: 'Garage',
|
||||
note: null
|
||||
note: null,
|
||||
gruppoId: null
|
||||
};
|
||||
magazzinoApi.aggiungiVoce.mockReturnValue(of(nuovaVoce));
|
||||
|
||||
@@ -194,7 +241,8 @@ describe('Magazzino', () => {
|
||||
quantitaPosseduta: 5,
|
||||
stato: 'da_riparare',
|
||||
posizione: 'Garage',
|
||||
note: undefined
|
||||
note: undefined,
|
||||
gruppoId: null
|
||||
});
|
||||
expect(component.voci()[0]).toEqual(nuovaVoce);
|
||||
expect(component.formAperto()).toBe(false);
|
||||
@@ -224,6 +272,7 @@ describe('Magazzino', () => {
|
||||
expect(component.form.value.quantitaPosseduta).toBe(2);
|
||||
expect(component.form.value.stato).toBe('mancante');
|
||||
expect(component.form.value.note).toBe('Da riordinare');
|
||||
expect(component.form.value.gruppoId).toBe('gruppo-figlio-1');
|
||||
});
|
||||
|
||||
it('chiama aggiornaVoce con l\'id corretto e azzera i campi vuoti a null', async () => {
|
||||
@@ -240,9 +289,223 @@ describe('Magazzino', () => {
|
||||
quantitaPosseduta: 4,
|
||||
stato: 'buono',
|
||||
posizione: null,
|
||||
note: 'Da riordinare'
|
||||
note: 'Da riordinare',
|
||||
gruppoId: 'gruppo-figlio-1'
|
||||
});
|
||||
expect(component.voci().find((v) => v.id === 'voce-2')).toEqual(voceAggiornata);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eliminazione di una voce', () => {
|
||||
beforeEach(async () => {
|
||||
await setup();
|
||||
});
|
||||
|
||||
it('rimuove la voce dall\'elenco al successo', async () => {
|
||||
magazzinoApi.eliminaVoce.mockReturnValue(of(undefined));
|
||||
|
||||
await component.eliminaVoce(vociIniziali[0]);
|
||||
|
||||
expect(magazzinoApi.eliminaVoce).toHaveBeenCalledWith('voce-1');
|
||||
expect(component.voci().some((v) => v.id === 'voce-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('rifà il fetch dei gruppi se la voce eliminata apparteneva a un gruppo', async () => {
|
||||
magazzinoApi.eliminaVoce.mockReturnValue(of(undefined));
|
||||
|
||||
await component.eliminaVoce(vociIniziali[1]);
|
||||
|
||||
expect(gruppiApi.getGruppi).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('non rifà il fetch dei gruppi se la voce eliminata non apparteneva a nessun gruppo', async () => {
|
||||
magazzinoApi.eliminaVoce.mockReturnValue(of(undefined));
|
||||
|
||||
await component.eliminaVoce(vociIniziali[0]);
|
||||
|
||||
expect(gruppiApi.getGruppi).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('mostra un errore inline se l\'eliminazione fallisce', async () => {
|
||||
magazzinoApi.eliminaVoce.mockReturnValue(
|
||||
throwError(() => ({ error: { message: 'Voce referenziata da un evento.' } }))
|
||||
);
|
||||
|
||||
await component.eliminaVoce(vociIniziali[0]);
|
||||
|
||||
expect(component.voceEliminaErroreId()).toBe('voce-1');
|
||||
expect(component.voceEliminaErroreMsg()).toBe('Voce referenziata da un evento.');
|
||||
expect(component.voci().some((v) => v.id === 'voce-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vista per gruppo', () => {
|
||||
beforeEach(async () => {
|
||||
await setup();
|
||||
});
|
||||
|
||||
it('mostra i gruppi radice con i figli annidati quando si passa alla vista "Per gruppo"', () => {
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const tabGruppo = Array.from(compiled.querySelectorAll('.tab')).find((el) =>
|
||||
el.textContent?.includes('Per gruppo')
|
||||
) as HTMLButtonElement;
|
||||
tabGruppo.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.vista()).toBe('gruppo');
|
||||
expect(compiled.textContent).toContain('Sede');
|
||||
const figlio = compiled.querySelector('.magazzino__gruppo-figlio');
|
||||
expect(figlio?.textContent).toContain('Ripostiglio A');
|
||||
expect(figlio?.textContent).toContain('Telo cerato');
|
||||
});
|
||||
|
||||
it('tiene nascosta la card gruppo finché non si clicca "+ Nuovo gruppo", e "Annulla" la richiude', () => {
|
||||
component.vista.set('gruppo');
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(component.gruppoFormAperto()).toBe(false);
|
||||
expect(compiled.querySelector('.magazzino__gruppo-form')).toBeNull();
|
||||
|
||||
const apriBtn = compiled.querySelector('.magazzino__gruppo-nuovo') as HTMLButtonElement;
|
||||
expect(apriBtn).toBeTruthy();
|
||||
apriBtn.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.gruppoFormAperto()).toBe(true);
|
||||
expect(compiled.querySelector('.magazzino__gruppo-form')).toBeTruthy();
|
||||
expect(compiled.querySelector('.magazzino__gruppo-nuovo')).toBeNull();
|
||||
|
||||
const annullaBtn = Array.from(compiled.querySelectorAll('.magazzino__gruppo-form .btn-ghost')).find((el) =>
|
||||
el.textContent?.includes('Annulla')
|
||||
) as HTMLButtonElement;
|
||||
annullaBtn.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.gruppoFormAperto()).toBe(false);
|
||||
expect(compiled.querySelector('.magazzino__gruppo-form')).toBeNull();
|
||||
});
|
||||
|
||||
it('modificaGruppo apre la card precompilata anche senza passare da "+ Nuovo gruppo"', () => {
|
||||
component.modificaGruppo(gruppiIniziali[0]);
|
||||
|
||||
expect(component.gruppoFormAperto()).toBe(true);
|
||||
expect(component.gruppoEditId()).toBe('gruppo-radice-1');
|
||||
expect(component.gruppoNome()).toBe('Sede');
|
||||
});
|
||||
|
||||
it('espone le opzioni indentate del select "Gruppo" nel form voce', () => {
|
||||
component.apriNuovaVoce();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.gruppiSelezionabili()).toEqual([
|
||||
{ id: 'gruppo-radice-1', nome: 'Sede', indentato: false },
|
||||
{ id: 'gruppo-figlio-1', nome: 'Ripostiglio A', indentato: true }
|
||||
]);
|
||||
});
|
||||
|
||||
it('crea un gruppo radice e lo aggiunge localmente all\'albero', async () => {
|
||||
component.vista.set('gruppo');
|
||||
component.gruppoNome.set('Magazzino esterno');
|
||||
|
||||
const nuovoGruppo: GruppoMagazzino = {
|
||||
id: 'gruppo-radice-2',
|
||||
orgId: 'org-1',
|
||||
nome: 'Magazzino esterno',
|
||||
parentId: null,
|
||||
creatoIl: '2026-08-01T00:00:00.000Z',
|
||||
voci: [],
|
||||
figli: []
|
||||
};
|
||||
gruppiApi.creaGruppo.mockReturnValue(of(nuovoGruppo));
|
||||
|
||||
await component.salvaGruppo();
|
||||
|
||||
expect(gruppiApi.creaGruppo).toHaveBeenCalledWith({ nome: 'Magazzino esterno', parentId: null });
|
||||
expect(component.gruppi().some((g) => g.id === 'gruppo-radice-2')).toBe(true);
|
||||
expect(component.gruppoEditId()).toBeNull();
|
||||
});
|
||||
|
||||
it('crea un sottogruppo con il padre precompilato da apriNuovoGruppo', async () => {
|
||||
component.apriNuovoGruppo('gruppo-radice-1');
|
||||
component.gruppoNome.set('Ripostiglio B');
|
||||
|
||||
const nuovoFiglio: GruppoMagazzino = {
|
||||
id: 'gruppo-figlio-2',
|
||||
orgId: 'org-1',
|
||||
nome: 'Ripostiglio B',
|
||||
parentId: 'gruppo-radice-1',
|
||||
creatoIl: '2026-08-01T00:00:00.000Z',
|
||||
voci: [],
|
||||
figli: []
|
||||
};
|
||||
gruppiApi.creaGruppo.mockReturnValue(of(nuovoFiglio));
|
||||
|
||||
await component.salvaGruppo();
|
||||
|
||||
expect(gruppiApi.creaGruppo).toHaveBeenCalledWith({ nome: 'Ripostiglio B', parentId: 'gruppo-radice-1' });
|
||||
const radice = component.gruppi().find((g) => g.id === 'gruppo-radice-1');
|
||||
expect(radice?.figli.some((f) => f.id === 'gruppo-figlio-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('modifica il nome di un gruppo senza cambiare padre (patch locale, nessun refetch)', async () => {
|
||||
component.modificaGruppo(gruppiIniziali[0]);
|
||||
component.gruppoNome.set('Sede centrale');
|
||||
|
||||
const aggiornato: GruppoMagazzino = { ...gruppiIniziali[0], nome: 'Sede centrale', figli: [] };
|
||||
gruppiApi.aggiornaGruppo.mockReturnValue(of(aggiornato));
|
||||
|
||||
await component.salvaGruppo();
|
||||
|
||||
expect(gruppiApi.aggiornaGruppo).toHaveBeenCalledWith('gruppo-radice-1', {
|
||||
nome: 'Sede centrale',
|
||||
parentId: null
|
||||
});
|
||||
expect(gruppiApi.getGruppi).toHaveBeenCalledTimes(1);
|
||||
expect(component.gruppi().find((g) => g.id === 'gruppo-radice-1')?.nome).toBe('Sede centrale');
|
||||
expect(component.gruppi().find((g) => g.id === 'gruppo-radice-1')?.figli.length).toBe(1);
|
||||
});
|
||||
|
||||
it('elimina un gruppo radice e promuove il figlio (rifà il fetch, nessuna voce del figlio orfanata)', async () => {
|
||||
// setup() nel beforeEach ha già consumato una chiamata a getGruppi per il
|
||||
// caricamento iniziale: qui mockiamo solo la chiamata successiva, quella
|
||||
// del refetch interno a eliminaGruppo. La radice eliminata non ha voci
|
||||
// dirette (sono nel figlio), quindi la voce del figlio non va orfanata.
|
||||
gruppiApi.eliminaGruppo.mockReturnValue(of(undefined));
|
||||
const alberoDopoEliminazione: GruppoMagazzino[] = [
|
||||
{ ...gruppiIniziali[0].figli[0], parentId: null }
|
||||
];
|
||||
gruppiApi.getGruppi.mockReturnValueOnce(of(alberoDopoEliminazione));
|
||||
|
||||
await component.eliminaGruppo(gruppiIniziali[0]);
|
||||
|
||||
expect(gruppiApi.eliminaGruppo).toHaveBeenCalledWith('gruppo-radice-1');
|
||||
expect(gruppiApi.getGruppi).toHaveBeenCalledTimes(2);
|
||||
expect(component.gruppi()).toEqual(alberoDopoEliminazione);
|
||||
expect(component.voci().find((v) => v.id === 'voce-2')?.gruppoId).toBe('gruppo-figlio-1');
|
||||
});
|
||||
|
||||
it('elimina un gruppo figlio e orfana la sua voce diretta', async () => {
|
||||
gruppiApi.eliminaGruppo.mockReturnValue(of(undefined));
|
||||
const alberoDopoEliminazione: GruppoMagazzino[] = [{ ...gruppiIniziali[0], figli: [] }];
|
||||
gruppiApi.getGruppi.mockReturnValueOnce(of(alberoDopoEliminazione));
|
||||
|
||||
await component.eliminaGruppo(gruppiIniziali[0].figli[0]);
|
||||
|
||||
expect(gruppiApi.eliminaGruppo).toHaveBeenCalledWith('gruppo-figlio-1');
|
||||
expect(component.voci().find((v) => v.id === 'voce-2')?.gruppoId).toBeNull();
|
||||
});
|
||||
|
||||
it('mostra un errore inline se l\'eliminazione del gruppo fallisce', async () => {
|
||||
gruppiApi.eliminaGruppo.mockReturnValue(
|
||||
throwError(() => ({ error: { message: 'Il gruppo contiene ancora dei sottogruppi.' } }))
|
||||
);
|
||||
|
||||
await component.eliminaGruppo(gruppiIniziali[0]);
|
||||
|
||||
expect(component.gruppoEliminaErroreId()).toBe('gruppo-radice-1');
|
||||
expect(component.gruppoEliminaErroreMsg()).toBe('Il gruppo contiene ancora dei sottogruppi.');
|
||||
expect(gruppiApi.getGruppi).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
@@ -16,6 +16,12 @@ import {
|
||||
MagazzinoVoce,
|
||||
StatoMagazzinoVoce
|
||||
} from './magazzino-api.service';
|
||||
import {
|
||||
AggiornaGruppoMagazzinoInput,
|
||||
CreaGruppoMagazzinoInput,
|
||||
GruppiMagazzinoApiService,
|
||||
GruppoMagazzino
|
||||
} from './gruppi-magazzino-api.service';
|
||||
|
||||
interface MaterialeRiferimento {
|
||||
id: string;
|
||||
@@ -35,17 +41,24 @@ const STATI: OpzioneStato[] = [
|
||||
|
||||
const SENZA_POSIZIONE = 'Senza posizione';
|
||||
|
||||
type Vista = 'lista' | 'posizione';
|
||||
type Vista = 'lista' | 'posizione' | 'gruppo';
|
||||
|
||||
interface GruppoPosizione {
|
||||
posizione: string;
|
||||
voci: MagazzinoVoce[];
|
||||
}
|
||||
|
||||
interface GruppoSelezionabile {
|
||||
id: string;
|
||||
nome: string;
|
||||
indentato: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-magazzino',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
@@ -58,6 +71,7 @@ interface GruppoPosizione {
|
||||
export class Magazzino implements OnInit {
|
||||
private readonly magazzinoApi = inject(MagazzinoApiService);
|
||||
private readonly materialiApi = inject(MaterialiApiService);
|
||||
private readonly gruppiApi = inject(GruppiMagazzinoApiService);
|
||||
|
||||
readonly displayedColumns = ['materiale', 'quantita', 'stato', 'posizione', 'note', 'azioni'];
|
||||
readonly stati = STATI;
|
||||
@@ -116,6 +130,41 @@ export class Magazzino implements OnInit {
|
||||
return chiavi.map((posizione) => ({ posizione, voci: gruppi.get(posizione)! }));
|
||||
});
|
||||
|
||||
// Vista "per gruppo": l'albero (radici con figli e voci annidati) arriva già
|
||||
// pronto dal backend — a differenza di "posizione" non è derivato da un
|
||||
// computed sulle voci, perché qui la relazione è esplicita lato server.
|
||||
readonly gruppiLoading = signal(true);
|
||||
readonly gruppiLoadError = signal<string | null>(null);
|
||||
readonly gruppi = signal<GruppoMagazzino[]>([]);
|
||||
|
||||
// Radici + figli appiattiti (con indicazione del rientro), usati sia dal
|
||||
// select "Gruppo" nel form voce sia dal select "Gruppo padre" nel form gruppo.
|
||||
readonly gruppiSelezionabili = computed<GruppoSelezionabile[]>(() => {
|
||||
const risultato: GruppoSelezionabile[] = [];
|
||||
for (const radice of this.gruppi()) {
|
||||
risultato.push({ id: radice.id, nome: radice.nome, indentato: false });
|
||||
for (const figlio of radice.figli) {
|
||||
risultato.push({ id: figlio.id, nome: figlio.nome, indentato: true });
|
||||
}
|
||||
}
|
||||
return risultato;
|
||||
});
|
||||
|
||||
readonly gruppoFormAperto = signal(false);
|
||||
readonly gruppoEditId = signal<string | null>(null);
|
||||
readonly gruppoNome = signal('');
|
||||
readonly gruppoParentId = signal<string | null>(null);
|
||||
readonly gruppoSalvataggioErrore = signal<string | null>(null);
|
||||
readonly gruppoEliminaErroreId = signal<string | null>(null);
|
||||
readonly gruppoEliminaErroreMsg = signal<string | null>(null);
|
||||
|
||||
// Solo le radici, per il select "Gruppo padre": un gruppo può essere figlio
|
||||
// solo di un gruppo di primo livello (vincolo lato backend), ed escludiamo il
|
||||
// gruppo stesso quando si sta modificando.
|
||||
readonly gruppiRadiceDisponibili = computed<GruppoMagazzino[]>(() =>
|
||||
this.gruppi().filter((g) => g.id !== this.gruppoEditId())
|
||||
);
|
||||
|
||||
readonly formAperto = signal(false);
|
||||
readonly voceInModificaId = signal<string | null>(null);
|
||||
readonly materialeSelezionato = signal<MaterialeRiferimento | null>(null);
|
||||
@@ -124,6 +173,9 @@ export class Magazzino implements OnInit {
|
||||
readonly salvataggioInCorso = signal(false);
|
||||
readonly salvataggioErrore = signal<string | null>(null);
|
||||
|
||||
readonly voceEliminaErroreId = signal<string | null>(null);
|
||||
readonly voceEliminaErroreMsg = signal<string | null>(null);
|
||||
|
||||
readonly ricerca = new FormControl('', { nonNullable: true });
|
||||
private readonly ricercaValue = toSignal(this.ricerca.valueChanges, { initialValue: '' });
|
||||
|
||||
@@ -134,7 +186,8 @@ export class Magazzino implements OnInit {
|
||||
}),
|
||||
stato: new FormControl<StatoMagazzinoVoce>('buono', { nonNullable: true, validators: [Validators.required] }),
|
||||
posizione: new FormControl('', { nonNullable: true }),
|
||||
note: new FormControl('', { nonNullable: true })
|
||||
note: new FormControl('', { nonNullable: true }),
|
||||
gruppoId: new FormControl<string | null>(null)
|
||||
});
|
||||
|
||||
readonly risultatiRicerca = computed<MaterialePubblico[]>(() => {
|
||||
@@ -150,7 +203,7 @@ export class Magazzino implements OnInit {
|
||||
);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
await this.carica();
|
||||
await Promise.all([this.carica(), this.caricaGruppi()]);
|
||||
}
|
||||
|
||||
apriNuovaVoce(): void {
|
||||
@@ -159,7 +212,7 @@ export class Magazzino implements OnInit {
|
||||
this.materialeErrore.set(null);
|
||||
this.salvataggioErrore.set(null);
|
||||
this.ricerca.setValue('');
|
||||
this.form.reset({ quantitaPosseduta: 0, stato: 'buono', posizione: '', note: '' });
|
||||
this.form.reset({ quantitaPosseduta: 0, stato: 'buono', posizione: '', note: '', gruppoId: null });
|
||||
this.formAperto.set(true);
|
||||
}
|
||||
|
||||
@@ -173,7 +226,8 @@ export class Magazzino implements OnInit {
|
||||
quantitaPosseduta: voce.quantitaPosseduta,
|
||||
stato: voce.stato,
|
||||
posizione: voce.posizione ?? '',
|
||||
note: voce.note ?? ''
|
||||
note: voce.note ?? '',
|
||||
gruppoId: voce.gruppoId
|
||||
});
|
||||
this.formAperto.set(true);
|
||||
}
|
||||
@@ -228,7 +282,8 @@ export class Magazzino implements OnInit {
|
||||
quantitaPosseduta: valori.quantitaPosseduta,
|
||||
stato: valori.stato,
|
||||
posizione: posizione.length > 0 ? posizione : null,
|
||||
note: note.length > 0 ? note : null
|
||||
note: note.length > 0 ? note : null,
|
||||
gruppoId: valori.gruppoId
|
||||
} satisfies AggiornaMagazzinoVoceInput)
|
||||
)
|
||||
: await firstValueFrom(
|
||||
@@ -237,7 +292,8 @@ export class Magazzino implements OnInit {
|
||||
quantitaPosseduta: valori.quantitaPosseduta,
|
||||
stato: valori.stato,
|
||||
posizione: posizione.length > 0 ? posizione : undefined,
|
||||
note: note.length > 0 ? note : undefined
|
||||
note: note.length > 0 ? note : undefined,
|
||||
gruppoId: valori.gruppoId
|
||||
} satisfies AggiungiMagazzinoVoceInput)
|
||||
);
|
||||
|
||||
@@ -246,6 +302,11 @@ export class Magazzino implements OnInit {
|
||||
return esisteGia ? voci.map((v) => (v.id === voce.id ? voce : v)) : [voce, ...voci];
|
||||
});
|
||||
this.formAperto.set(false);
|
||||
|
||||
// La voce può essere stata assegnata/riassegnata a un gruppo: l'albero
|
||||
// "per gruppo" va aggiornato di conseguenza (operazione non frequente,
|
||||
// un refetch è più semplice e sicuro di un patch manuale dell'albero).
|
||||
void this.caricaGruppi();
|
||||
} catch {
|
||||
this.salvataggioErrore.set('Impossibile salvare la voce di magazzino. Riprova più tardi.');
|
||||
} finally {
|
||||
@@ -253,6 +314,25 @@ export class Magazzino implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
async eliminaVoce(voce: MagazzinoVoce): Promise<void> {
|
||||
this.voceEliminaErroreId.set(null);
|
||||
this.voceEliminaErroreMsg.set(null);
|
||||
|
||||
try {
|
||||
await firstValueFrom(this.magazzinoApi.eliminaVoce(voce.id));
|
||||
this.voci.update((voci) => voci.filter((v) => v.id !== voce.id));
|
||||
|
||||
// La voce eliminata poteva appartenere a un gruppo: l'albero "per
|
||||
// gruppo" va aggiornato di conseguenza.
|
||||
if (voce.gruppoId) {
|
||||
void this.caricaGruppi();
|
||||
}
|
||||
} catch (err) {
|
||||
this.voceEliminaErroreId.set(voce.id);
|
||||
this.voceEliminaErroreMsg.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
private async carica(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.loadError.set(null);
|
||||
@@ -269,4 +349,147 @@ export class Magazzino implements OnInit {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// — Gruppi —
|
||||
|
||||
private async caricaGruppi(): Promise<void> {
|
||||
this.gruppiLoading.set(true);
|
||||
this.gruppiLoadError.set(null);
|
||||
|
||||
try {
|
||||
const gruppi = await firstValueFrom(this.gruppiApi.getGruppi());
|
||||
this.gruppi.set(gruppi);
|
||||
} catch {
|
||||
this.gruppiLoadError.set('Impossibile caricare i gruppi di magazzino. Riprova più tardi.');
|
||||
} finally {
|
||||
this.gruppiLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
apriNuovoGruppo(parentId: string | null = null): void {
|
||||
this.gruppoEditId.set(null);
|
||||
this.gruppoNome.set('');
|
||||
this.gruppoParentId.set(parentId);
|
||||
this.gruppoSalvataggioErrore.set(null);
|
||||
this.gruppoFormAperto.set(true);
|
||||
}
|
||||
|
||||
modificaGruppo(gruppo: GruppoMagazzino): void {
|
||||
this.gruppoEditId.set(gruppo.id);
|
||||
this.gruppoNome.set(gruppo.nome);
|
||||
this.gruppoParentId.set(gruppo.parentId);
|
||||
this.gruppoSalvataggioErrore.set(null);
|
||||
this.gruppoFormAperto.set(true);
|
||||
}
|
||||
|
||||
annullaGruppo(): void {
|
||||
this.gruppoEditId.set(null);
|
||||
this.gruppoNome.set('');
|
||||
this.gruppoParentId.set(null);
|
||||
this.gruppoSalvataggioErrore.set(null);
|
||||
this.gruppoFormAperto.set(false);
|
||||
}
|
||||
|
||||
async salvaGruppo(): Promise<void> {
|
||||
const nome = this.gruppoNome().trim();
|
||||
if (!nome) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.gruppoSalvataggioErrore.set(null);
|
||||
const editId = this.gruppoEditId();
|
||||
const parentId = this.gruppoParentId();
|
||||
|
||||
try {
|
||||
if (editId) {
|
||||
const gruppoEsistente = this.trovaGruppo(editId);
|
||||
const cambiaParent = gruppoEsistente?.parentId !== parentId;
|
||||
|
||||
const salvato = await firstValueFrom(
|
||||
this.gruppiApi.aggiornaGruppo(editId, { nome, parentId } satisfies AggiornaGruppoMagazzinoInput)
|
||||
);
|
||||
|
||||
if (cambiaParent) {
|
||||
// Un gruppo che cambia padre "salta" da un punto all'altro
|
||||
// dell'albero: più sicuro rifare il fetch completo che tentare un
|
||||
// patch locale della struttura ad albero.
|
||||
await this.caricaGruppi();
|
||||
} else {
|
||||
this.gruppi.update((lista) => this.sostituisciNodo(lista, salvato));
|
||||
}
|
||||
} else {
|
||||
const creato = await firstValueFrom(
|
||||
this.gruppiApi.creaGruppo({ nome, parentId } satisfies CreaGruppoMagazzinoInput)
|
||||
);
|
||||
|
||||
if (creato.parentId) {
|
||||
const idPadre = creato.parentId;
|
||||
this.gruppi.update((lista) =>
|
||||
lista.map((radice) => (radice.id === idPadre ? { ...radice, figli: [...radice.figli, creato] } : radice))
|
||||
);
|
||||
} else {
|
||||
this.gruppi.update((lista) => [...lista, creato].sort((a, b) => a.nome.localeCompare(b.nome, 'it')));
|
||||
}
|
||||
}
|
||||
|
||||
this.annullaGruppo();
|
||||
} catch (err) {
|
||||
this.gruppoSalvataggioErrore.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
async eliminaGruppo(gruppo: GruppoMagazzino): Promise<void> {
|
||||
this.gruppoEliminaErroreId.set(null);
|
||||
this.gruppoEliminaErroreMsg.set(null);
|
||||
|
||||
try {
|
||||
await firstValueFrom(this.gruppiApi.eliminaGruppo(gruppo.id));
|
||||
|
||||
// L'eliminazione promuove eventuali figli a radice e "orfana" le voci
|
||||
// direttamente assegnate al gruppo: un patch locale dell'albero
|
||||
// rischierebbe di introdurre incoerenze, un refetch è più sicuro.
|
||||
await this.caricaGruppi();
|
||||
this.voci.update((voci) => voci.map((v) => (v.gruppoId === gruppo.id ? { ...v, gruppoId: null } : v)));
|
||||
} catch (err) {
|
||||
this.gruppoEliminaErroreId.set(gruppo.id);
|
||||
this.gruppoEliminaErroreMsg.set(this.estraiMessaggioErrore(err));
|
||||
}
|
||||
}
|
||||
|
||||
private trovaGruppo(id: string): GruppoMagazzino | undefined {
|
||||
for (const radice of this.gruppi()) {
|
||||
if (radice.id === id) {
|
||||
return radice;
|
||||
}
|
||||
const figlio = radice.figli.find((f) => f.id === id);
|
||||
if (figlio) {
|
||||
return figlio;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private sostituisciNodo(lista: GruppoMagazzino[], aggiornato: GruppoMagazzino): GruppoMagazzino[] {
|
||||
return lista.map((radice) => {
|
||||
if (radice.id === aggiornato.id) {
|
||||
return { ...aggiornato, figli: radice.figli };
|
||||
}
|
||||
return { ...radice, figli: radice.figli.map((f) => (f.id === aggiornato.id ? aggiornato : f)) };
|
||||
});
|
||||
}
|
||||
|
||||
private estraiMessaggioErrore(err: unknown): string {
|
||||
if (err && typeof err === 'object' && 'error' in err) {
|
||||
const body = (err as { error?: unknown }).error;
|
||||
if (
|
||||
body &&
|
||||
typeof body === 'object' &&
|
||||
'message' in body &&
|
||||
typeof (body as { message?: unknown }).message === 'string'
|
||||
) {
|
||||
return (body as { message: string }).message;
|
||||
}
|
||||
}
|
||||
return "Impossibile completare l'operazione.";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user