diff --git a/scouthub-magazzino-be/src/app.ts b/scouthub-magazzino-be/src/app.ts index 9985256..dc37f55 100644 --- a/scouthub-magazzino-be/src/app.ts +++ b/scouthub-magazzino-be/src/app.ts @@ -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); diff --git a/scouthub-magazzino-be/src/controllers/eventi.controller.ts b/scouthub-magazzino-be/src/controllers/eventi.controller.ts deleted file mode 100644 index 7aea2fd..0000000 --- a/scouthub-magazzino-be/src/controllers/eventi.controller.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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); - } -} diff --git a/scouthub-magazzino-be/src/repositories/eventi.repository.ts b/scouthub-magazzino-be/src/repositories/eventi.repository.ts deleted file mode 100644 index f398282..0000000 --- a/scouthub-magazzino-be/src/repositories/eventi.repository.ts +++ /dev/null @@ -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 { - return prisma.evento.findFirst({ where: { id, orgId }, include: includeEvento }); - } - - create(data: CreateEventoData): Promise { - return prisma.evento.create({ data, include: includeEvento }); - } - - upsertCheck(eventoId: string, materialeId: string, data: UpsertCheckData): Promise { - 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(); diff --git a/scouthub-magazzino-be/src/repositories/liste.repository.ts b/scouthub-magazzino-be/src/repositories/liste.repository.ts index 714b830..bd86cf9 100644 --- a/scouthub-magazzino-be/src/repositories/liste.repository.ts +++ b/scouthub-magazzino-be/src/repositories/liste.repository.ts @@ -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 { - return prisma.lista.findFirst({ where: { id, orgId }, include: includeVoci }); - } - create(data: CreateListaData, db: Db = prisma): Promise { return db.lista.create({ data: { diff --git a/scouthub-magazzino-be/src/repositories/magazzino.repository.ts b/scouthub-magazzino-be/src/repositories/magazzino.repository.ts index e6c482b..74e5581 100644 --- a/scouthub-magazzino-be/src/repositories/magazzino.repository.ts +++ b/scouthub-magazzino-be/src/repositories/magazzino.repository.ts @@ -26,11 +26,6 @@ export interface UpdateMagazzinoVoceData { gruppoId?: string | null; } -export interface QuantitaPosseduta { - materialeId: string; - quantitaPosseduta: number; -} - export class MagazzinoRepository { findAllByOrg(orgId: string): Promise { return prisma.magazzinoVoce.findMany({ @@ -57,15 +52,6 @@ export class MagazzinoRepository { async delete(id: string): Promise { 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 { - return prisma.magazzinoVoce.findMany({ - where: { orgId, materialeId: { in: materialeIds } }, - select: { materialeId: true, quantitaPosseduta: true }, - }); - } } export const magazzinoRepository = new MagazzinoRepository(); diff --git a/scouthub-magazzino-be/src/routes/eventi.routes.ts b/scouthub-magazzino-be/src/routes/eventi.routes.ts deleted file mode 100644 index 1da3550..0000000 --- a/scouthub-magazzino-be/src/routes/eventi.routes.ts +++ /dev/null @@ -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); diff --git a/scouthub-magazzino-be/src/services/eventi.service.ts b/scouthub-magazzino-be/src/services/eventi.service.ts deleted file mode 100644 index c9d6157..0000000 --- a/scouthub-magazzino-be/src/services/eventi.service.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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); -} diff --git a/scouthub-magazzino-be/tests/integration/eventi.endpoint.test.ts b/scouthub-magazzino-be/tests/integration/eventi.endpoint.test.ts deleted file mode 100644 index 9b9ece2..0000000 --- a/scouthub-magazzino-be/tests/integration/eventi.endpoint.test.ts +++ /dev/null @@ -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; -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> = {}) { - 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(); - }); -}); diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.css b/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.css deleted file mode 100644 index ca0d84b..0000000 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.css +++ /dev/null @@ -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); -} diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.html b/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.html deleted file mode 100644 index 72716cc..0000000 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.html +++ /dev/null @@ -1,50 +0,0 @@ -
-
-
-

Catalogo materiali

-

Materiali condivisi tra tutte le organizzazioni. Consultabile senza login.

-
- Liste modello per evento -
- - @if (loading()) { -

Caricamento catalogo…

- } @else if (loadError(); as message) { - - } @else { -
- - @for (categoria of categorie(); track categoria) { - - } -
- - @if (materialiFiltrati().length === 0) { -

Nessun materiale trovato.

- } @else { -
- @for (materiale of materialiFiltrati(); track materiale.id) { -
-
{{ materiale.categoria }}
-
{{ materiale.nome }}
-

Unità di misura: {{ materiale.unitaMisura }}

-
- } -
- } - } -
diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.spec.ts b/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.spec.ts deleted file mode 100644 index 993a8ce..0000000 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.spec.ts +++ /dev/null @@ -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; - let materialiApi: { getMateriali: ReturnType }; - - 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 { - 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.'); - }); -}); diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.ts b/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.ts deleted file mode 100644 index 7285b31..0000000 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo-materiali/catalogo-materiali.ts +++ /dev/null @@ -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(null); - readonly materiali = signal([]); - readonly categoriaSelezionata = signal(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 { - await this.caricaMateriali(); - } - - selezionaCategoria(categoria: string | null): void { - this.categoriaSelezionata.set(categoria); - } - - private async caricaMateriali(): Promise { - 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); - } - } -} diff --git a/scouthub-magazzino-fe/src/app/catalogo/catalogo.routes.ts b/scouthub-magazzino-fe/src/app/catalogo/catalogo.routes.ts index 01c3e3f..e29fe48 100644 --- a/scouthub-magazzino-fe/src/app/catalogo/catalogo.routes.ts +++ b/scouthub-magazzino-fe/src/app/catalogo/catalogo.routes.ts @@ -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', diff --git a/scouthub-magazzino-fe/src/app/liste/liste-api.service.ts b/scouthub-magazzino-fe/src/app/liste/liste-api.service.ts index 1b9da42..2e0f281 100644 --- a/scouthub-magazzino-fe/src/app/liste/liste-api.service.ts +++ b/scouthub-magazzino-fe/src/app/liste/liste-api.service.ts @@ -105,4 +105,8 @@ export class ListeApiService { aggiornaLista(id: string, input: AggiornaListaInput): Observable { return this.http.put(`${environment.magazzinoApiBaseUrl}/liste/${id}`, input); } + + eliminaLista(id: string): Observable { + return this.http.delete(`${environment.magazzinoApiBaseUrl}/liste/${id}`); + } } diff --git a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.css b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.css index 24b1f4e..8407b34 100644 --- a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.css +++ b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.css @@ -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; diff --git a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.html b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.html index de8f31a..6b2754a 100644 --- a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.html +++ b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.html @@ -37,6 +37,9 @@ ❌ proposta rifiutata } + @if (eliminaErroreId() === lista.id) { +
{{ eliminaErroreMsg() }}
+ } @if (puoCambiareStato(lista)) {
@@ -58,7 +61,10 @@ 🔒 solo il creatore può cambiare lo stato
} -
Modifica
+
+
Modifica
+
Elimina
+
} diff --git a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.spec.ts b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.spec.ts index ee8a082..b28048e 100644 --- a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.spec.ts +++ b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.spec.ts @@ -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 }).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); + }); + }); }); diff --git a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.ts b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.ts index 55d284c..c2930a8 100644 --- a/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.ts +++ b/scouthub-magazzino-fe/src/app/liste/liste-list/liste-list.ts @@ -43,6 +43,9 @@ export class ListeList implements OnInit { readonly liste = signal([]); readonly stati = STATI_LISTA; + readonly eliminaErroreId = signal(null); + readonly eliminaErroreMsg = signal(null); + async ngOnInit(): Promise { 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 { + 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."; + } } diff --git a/scouthub-magazzino-fe/src/app/magazzino/magazzino-api.service.ts b/scouthub-magazzino-fe/src/app/magazzino/magazzino-api.service.ts index 2ab4938..798257b 100644 --- a/scouthub-magazzino-fe/src/app/magazzino/magazzino-api.service.ts +++ b/scouthub-magazzino-fe/src/app/magazzino/magazzino-api.service.ts @@ -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 { return this.http.put(`${environment.magazzinoApiBaseUrl}/magazzino/${id}`, input); } + + eliminaVoce(id: string): Observable { + return this.http.delete(`${environment.magazzinoApiBaseUrl}/magazzino/${id}`); + } } diff --git a/scouthub-magazzino-fe/src/app/magazzino/magazzino.css b/scouthub-magazzino-fe/src/app/magazzino/magazzino.css index 35cc90a..7dde6c0 100644 --- a/scouthub-magazzino-fe/src/app/magazzino/magazzino.css +++ b/scouthub-magazzino-fe/src/app/magazzino/magazzino.css @@ -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); +} diff --git a/scouthub-magazzino-fe/src/app/magazzino/magazzino.html b/scouthub-magazzino-fe/src/app/magazzino/magazzino.html index faccc46..7592348 100644 --- a/scouthub-magazzino-fe/src/app/magazzino/magazzino.html +++ b/scouthub-magazzino-fe/src/app/magazzino/magazzino.html @@ -6,18 +6,16 @@ } -
- - +
@if (loading()) { @@ -93,6 +91,16 @@ + +
+ + +
@if (salvataggioErrore(); as message) { @@ -108,7 +116,7 @@ } - @if (voci().length === 0) { + @if (vista() !== 'gruppo' && voci().length === 0) {

Nessuna voce in magazzino.

} @else {
@@ -117,8 +125,8 @@ @for (lettera of lettereDisponibili(); track lettera) { + + @if (voceEliminaErroreId() === voce.id) { + + {{ voceEliminaErroreMsg() }} + + } } } - } @else { + } @else if (vista() === 'posizione') {
@for (gruppo of gruppiPerPosizione(); track gruppo.posizione) {
@@ -176,12 +190,186 @@ {{ statoLabel(voce.stato) }} + + @if (voceEliminaErroreId() === voce.id) { +

{{ voceEliminaErroreMsg() }}

+ } }
}
+ } @else { + @if (!gruppoFormAperto()) { + + } + + @if (gruppoFormAperto()) { +
+

{{ gruppoEditId() ? 'Modifica gruppo' : 'Nuovo gruppo' }}

+ +
+
+ + +
+ +
+ + +
+
+ + @if (gruppoSalvataggioErrore(); as message) { + + } + +
+ + +
+
+ } + + @if (gruppi().length === 0) { +

Nessun gruppo creato.

+ } @else { +
+ @for (radice of gruppi(); track radice.id) { +
+
+
{{ radice.nome }}
+
+ + + +
+
+ + @if (radice.voci.length === 0 && radice.figli.length === 0) { +

Nessuna voce in questo gruppo.

+ } + + @if (radice.voci.length > 0) { +
    + @for (voce of radice.voci; track voce.id) { +
  • +
    + {{ voce.materialeNome }} + × {{ voce.quantitaPosseduta }} +
    + + {{ statoLabel(voce.stato) }} + + + @if (voceEliminaErroreId() === voce.id) { +

    + {{ voceEliminaErroreMsg() }} +

    + } +
  • + } +
+ } + + @if (gruppoEliminaErroreId() === radice.id) { + + } + + @if (radice.figli.length > 0) { +
+ @for (figlio of radice.figli; track figlio.id) { +
+
+
{{ figlio.nome }}
+
+ + +
+
+ + @if (figlio.voci.length === 0) { +

Nessuna voce in questo gruppo.

+ } @else { +
    + @for (voce of figlio.voci; track voce.id) { +
  • +
    + {{ voce.materialeNome }} + × {{ voce.quantitaPosseduta }} +
    + + {{ statoLabel(voce.stato) }} + + + @if (voceEliminaErroreId() === voce.id) { +

    + {{ voceEliminaErroreMsg() }} +

    + } +
  • + } +
+ } + + @if (gruppoEliminaErroreId() === figlio.id) { + + } +
+ } +
+ } +
+ } +
+ } }
} diff --git a/scouthub-magazzino-fe/src/app/magazzino/magazzino.spec.ts b/scouthub-magazzino-fe/src/app/magazzino/magazzino.spec.ts index 38f7a2d..0a4fba5 100644 --- a/scouthub-magazzino-fe/src/app/magazzino/magazzino.spec.ts +++ b/scouthub-magazzino-fe/src/app/magazzino/magazzino.spec.ts @@ -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; aggiungiVoce: ReturnType; aggiornaVoce: ReturnType; + eliminaVoce: ReturnType; }; let materialiApi: { getMateriali: ReturnType }; + let gruppiApi: { + getGruppi: ReturnType; + creaGruppo: ReturnType; + aggiornaGruppo: ReturnType; + eliminaGruppo: ReturnType; + }; 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); + }); + }); }); diff --git a/scouthub-magazzino-fe/src/app/magazzino/magazzino.ts b/scouthub-magazzino-fe/src/app/magazzino/magazzino.ts index 03e56ea..80ecfc9 100644 --- a/scouthub-magazzino-fe/src/app/magazzino/magazzino.ts +++ b/scouthub-magazzino-fe/src/app/magazzino/magazzino.ts @@ -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(null); + readonly gruppi = signal([]); + + // 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(() => { + 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(null); + readonly gruppoNome = signal(''); + readonly gruppoParentId = signal(null); + readonly gruppoSalvataggioErrore = signal(null); + readonly gruppoEliminaErroreId = signal(null); + readonly gruppoEliminaErroreMsg = signal(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(() => + this.gruppi().filter((g) => g.id !== this.gruppoEditId()) + ); + readonly formAperto = signal(false); readonly voceInModificaId = signal(null); readonly materialeSelezionato = signal(null); @@ -124,6 +173,9 @@ export class Magazzino implements OnInit { readonly salvataggioInCorso = signal(false); readonly salvataggioErrore = signal(null); + readonly voceEliminaErroreId = signal(null); + readonly voceEliminaErroreMsg = signal(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('buono', { nonNullable: true, validators: [Validators.required] }), posizione: new FormControl('', { nonNullable: true }), - note: new FormControl('', { nonNullable: true }) + note: new FormControl('', { nonNullable: true }), + gruppoId: new FormControl(null) }); readonly risultatiRicerca = computed(() => { @@ -150,7 +203,7 @@ export class Magazzino implements OnInit { ); async ngOnInit(): Promise { - 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 { + 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 { 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 { + 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 { + 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 { + 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."; + } }