Fix magazzino

This commit is contained in:
Lorenzo Sanesi
2026-08-02 15:18:34 +02:00
parent 0fdf395733
commit 91f404ac96
23 changed files with 880 additions and 806 deletions
-2
View File
@@ -7,7 +7,6 @@ import { tipiEventoRouter } from './routes/tipiEvento.routes';
import { listeRouter } from './routes/liste.routes'; import { listeRouter } from './routes/liste.routes';
import { magazzinoRouter } from './routes/magazzino.routes'; import { magazzinoRouter } from './routes/magazzino.routes';
import { gruppiMagazzinoRouter } from './routes/gruppiMagazzino.routes'; import { gruppiMagazzinoRouter } from './routes/gruppiMagazzino.routes';
import { eventiRouter } from './routes/eventi.routes';
import { autocompleteRouter } from './routes/autocomplete.routes'; import { autocompleteRouter } from './routes/autocomplete.routes';
import { notificheRouter } from './routes/notifiche.routes'; import { notificheRouter } from './routes/notifiche.routes';
import { errorHandler } from './middleware/errorHandler'; import { errorHandler } from './middleware/errorHandler';
@@ -27,7 +26,6 @@ app.use(tipiEventoRouter);
app.use(listeRouter); app.use(listeRouter);
app.use(magazzinoRouter); app.use(magazzinoRouter);
app.use(gruppiMagazzinoRouter); app.use(gruppiMagazzinoRouter);
app.use(eventiRouter);
app.use(autocompleteRouter); app.use(autocompleteRouter);
app.use(notificheRouter); 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 }); 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> { create(data: CreateListaData, db: Db = prisma): Promise<ListaConVoci> {
return db.lista.create({ return db.lista.create({
data: { data: {
@@ -26,11 +26,6 @@ export interface UpdateMagazzinoVoceData {
gruppoId?: string | null; gruppoId?: string | null;
} }
export interface QuantitaPosseduta {
materialeId: string;
quantitaPosseduta: number;
}
export class MagazzinoRepository { export class MagazzinoRepository {
findAllByOrg(orgId: string): Promise<MagazzinoVoceConMateriale[]> { findAllByOrg(orgId: string): Promise<MagazzinoVoceConMateriale[]> {
return prisma.magazzinoVoce.findMany({ return prisma.magazzinoVoce.findMany({
@@ -57,15 +52,6 @@ export class MagazzinoRepository {
async delete(id: string): Promise<void> { async delete(id: string): Promise<void> {
await prisma.magazzinoVoce.delete({ where: { id } }); 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(); 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: () => loadComponent: () =>
import('./catalogo-liste-modello/catalogo-liste-modello').then((m) => m.CatalogoListeModello) 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. // Pubblica: la ricerca delle liste modello è consultabile anche da chi non è autenticato.
path: 'cerca-lista', path: 'cerca-lista',
@@ -105,4 +105,8 @@ export class ListeApiService {
aggiornaLista(id: string, input: AggiornaListaInput): Observable<Lista> { aggiornaLista(id: string, input: AggiornaListaInput): Observable<Lista> {
return this.http.put<Lista>(`${environment.magazzinoApiBaseUrl}/liste/${id}`, input); 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); border: 1px solid var(--color-divider);
} }
.riga-azioni {
display: flex;
gap: 8px;
}
.riga-modifica { .riga-modifica {
cursor: pointer; cursor: pointer;
padding: 9px 14px; padding: 9px 14px;
@@ -107,6 +112,22 @@
font-size: 14px; 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 { .empty-state {
text-align: center; text-align: center;
padding: 80px 20px; padding: 80px 20px;
@@ -37,6 +37,9 @@
❌ proposta rifiutata ❌ proposta rifiutata
</div> </div>
} }
@if (eliminaErroreId() === lista.id) {
<div class="errore-inline">{{ eliminaErroreMsg() }}</div>
}
</div> </div>
@if (puoCambiareStato(lista)) { @if (puoCambiareStato(lista)) {
<div class="stato-options"> <div class="stato-options">
@@ -58,7 +61,10 @@
🔒 solo il creatore può cambiare lo stato 🔒 solo il creatore può cambiare lo stato
</div> </div>
} }
<div class="riga-azioni">
<div class="riga-modifica" (click)="modifica(lista)">Modifica</div> <div class="riga-modifica" (click)="modifica(lista)">Modifica</div>
<div class="riga-elimina" (click)="elimina(lista)">Elimina</div>
</div>
</div> </div>
} }
</div> </div>
@@ -176,4 +176,37 @@ describe('ListeList', () => {
expect(component.loadError()).toBe('Impossibile caricare le liste. Riprova più tardi.'); 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 liste = signal<Lista[]>([]);
readonly stati = STATI_LISTA; readonly stati = STATI_LISTA;
readonly eliminaErroreId = signal<string | null>(null);
readonly eliminaErroreMsg = signal<string | null>(null);
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
await this.caricaListe(); await this.caricaListe();
} }
@@ -122,4 +125,31 @@ export class ListeList implements OnInit {
error: () => this.loadError.set('Impossibile cambiare stato alla lista. Riprova più tardi.') 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; stato: StatoMagazzinoVoce;
posizione: string | null; posizione: string | null;
note: string | null; note: string | null;
gruppoId: string | null;
} }
export interface AggiungiMagazzinoVoceInput { export interface AggiungiMagazzinoVoceInput {
@@ -24,6 +25,7 @@ export interface AggiungiMagazzinoVoceInput {
stato: StatoMagazzinoVoce; stato: StatoMagazzinoVoce;
posizione?: string; posizione?: string;
note?: string; note?: string;
gruppoId?: string | null;
} }
export interface AggiornaMagazzinoVoceInput { export interface AggiornaMagazzinoVoceInput {
@@ -32,6 +34,7 @@ export interface AggiornaMagazzinoVoceInput {
stato?: StatoMagazzinoVoce; stato?: StatoMagazzinoVoce;
posizione?: string | null; posizione?: string | null;
note?: string | null; note?: string | null;
gruppoId?: string | null;
} }
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -49,4 +52,8 @@ export class MagazzinoApiService {
aggiornaVoce(id: string, input: AggiornaMagazzinoVoceInput): Observable<MagazzinoVoce> { aggiornaVoce(id: string, input: AggiornaMagazzinoVoceInput): Observable<MagazzinoVoce> {
return this.http.put<MagazzinoVoce>(`${environment.magazzinoApiBaseUrl}/magazzino/${id}`, input); 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; 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; display: flex;
width: 100%; gap: 6px;
margin-bottom: var(--space-4); margin-bottom: 24px;
flex-wrap: wrap;
} }
.magazzino__tabs .seg-opt { .tab {
flex: 1; cursor: pointer;
justify-content: center; 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 { .magazzino__error {
@@ -37,30 +56,13 @@
.magazzino__alfabeto { .magazzino__alfabeto {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 6px;
margin-bottom: var(--space-4); margin-bottom: var(--space-4);
} }
.magazzino__lettera { .magazzino__lettera {
min-width: 28px; min-width: 28px;
padding: 4px 6px; padding: 6px 12px;
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;
} }
.magazzino__contenuto--con-form { .magazzino__contenuto--con-form {
@@ -85,6 +87,7 @@
.magazzino__posizione-voce { .magazzino__posizione-voce {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: var(--space-2); gap: var(--space-2);
@@ -93,6 +96,11 @@
cursor: pointer; cursor: pointer;
} }
.magazzino__posizione-voce-errore {
flex-basis: 100%;
margin: 0;
}
.magazzino__posizione-lista li:first-child { .magazzino__posizione-lista li:first-child {
border-top: none; border-top: none;
} }
@@ -120,7 +128,7 @@
.magazzino__form { .magazzino__form {
padding: var(--space-4); padding: var(--space-4);
margin-bottom: var(--space-5); margin-bottom: var(--space-6);
} }
.magazzino__materiale { .magazzino__materiale {
@@ -179,6 +187,12 @@
width: 100%; width: 100%;
} }
.magazzino__tabella-azioni {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.magazzino-badge--buono { .magazzino-badge--buono {
background: var(--color-accent-2-100); background: var(--color-accent-2-100);
color: var(--color-accent-2-800); color: var(--color-accent-2-800);
@@ -193,3 +207,38 @@
background: var(--color-neutral-200); background: var(--color-neutral-200);
color: var(--color-neutral-800); 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>
<div class="seg magazzino__tabs" role="group" aria-label="Visualizzazione magazzino"> <div class="tabs magazzino__tabs" role="group" aria-label="Visualizzazione magazzino">
<button type="button" class="seg-opt" [class.seg-opt--attiva]="vista() === 'lista'" (click)="vista.set('lista')"> <button type="button" class="tab" [class.tab--attivo]="vista() === 'lista'" (click)="vista.set('lista')">
Per lista Per lista
</button> </button>
<button <button type="button" class="tab" [class.tab--attivo]="vista() === 'posizione'" (click)="vista.set('posizione')">
type="button"
class="seg-opt"
[class.seg-opt--attiva]="vista() === 'posizione'"
(click)="vista.set('posizione')"
>
Per posizione Per posizione
</button> </button>
<button type="button" class="tab" [class.tab--attivo]="vista() === 'gruppo'" (click)="vista.set('gruppo')">
Per gruppo
</button>
</div> </div>
@if (loading()) { @if (loading()) {
@@ -93,6 +91,16 @@
<label for="mg-note">Note</label> <label for="mg-note">Note</label>
<input class="input" id="mg-note" formControlName="note" /> <input class="input" id="mg-note" formControlName="note" />
</div> </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> </div>
@if (salvataggioErrore(); as message) { @if (salvataggioErrore(); as message) {
@@ -108,7 +116,7 @@
</form> </form>
} }
@if (voci().length === 0) { @if (vista() !== 'gruppo' && voci().length === 0) {
<p class="om-empty">Nessuna voce in magazzino.</p> <p class="om-empty">Nessuna voce in magazzino.</p>
} @else { } @else {
<div class="magazzino__contenuto" [class.magazzino__contenuto--con-form]="formAperto()"> <div class="magazzino__contenuto" [class.magazzino__contenuto--con-form]="formAperto()">
@@ -117,8 +125,8 @@
@for (lettera of lettereDisponibili(); track lettera) { @for (lettera of lettereDisponibili(); track lettera) {
<button <button
type="button" type="button"
class="magazzino__lettera" class="tab magazzino__lettera"
[class.magazzino__lettera--attiva]="letteraSelezionata() === lettera" [class.tab--attivo]="letteraSelezionata() === lettera"
(click)="selezionaLettera(lettera)" (click)="selezionaLettera(lettera)"
> >
{{ lettera }} {{ lettera }}
@@ -152,15 +160,21 @@
</td> </td>
<td>{{ voce.posizione ?? '—' }}</td> <td>{{ voce.posizione ?? '—' }}</td>
<td>{{ voce.note ?? '—' }}</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)="apriModificaVoce(voce)">Modifica</button>
<button type="button" class="btn btn-ghost" (click)="eliminaVoce(voce)">Elimina</button>
</td> </td>
</tr> </tr>
@if (voceEliminaErroreId() === voce.id) {
<tr>
<td colspan="6" class="magazzino__error">{{ voceEliminaErroreMsg() }}</td>
</tr>
}
} }
</tbody> </tbody>
</table> </table>
} }
} @else { } @else if (vista() === 'posizione') {
<div class="om-grid"> <div class="om-grid">
@for (gruppo of gruppiPerPosizione(); track gruppo.posizione) { @for (gruppo of gruppiPerPosizione(); track gruppo.posizione) {
<div class="card magazzino__posizione-card"> <div class="card magazzino__posizione-card">
@@ -176,12 +190,186 @@
<span [class]="'tag magazzino-badge magazzino-badge--' + voce.stato"> <span [class]="'tag magazzino-badge magazzino-badge--' + voce.stato">
{{ statoLabel(voce.stato) }} {{ statoLabel(voce.stato) }}
</span> </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> </li>
} }
</ul> </ul>
</div> </div>
} }
</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> </div>
} }
@@ -4,6 +4,7 @@ import { of, throwError } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../catalogo/materiali-api.service'; import { MaterialePubblico, MaterialiApiService } from '../catalogo/materiali-api.service';
import { MagazzinoApiService, MagazzinoVoce } from './magazzino-api.service'; import { MagazzinoApiService, MagazzinoVoce } from './magazzino-api.service';
import { GruppiMagazzinoApiService, GruppoMagazzino } from './gruppi-magazzino-api.service';
import { Magazzino } from './magazzino'; import { Magazzino } from './magazzino';
describe('Magazzino', () => { describe('Magazzino', () => {
@@ -13,8 +14,15 @@ describe('Magazzino', () => {
getMagazzino: ReturnType<typeof vi.fn>; getMagazzino: ReturnType<typeof vi.fn>;
aggiungiVoce: ReturnType<typeof vi.fn>; aggiungiVoce: ReturnType<typeof vi.fn>;
aggiornaVoce: ReturnType<typeof vi.fn>; aggiornaVoce: ReturnType<typeof vi.fn>;
eliminaVoce: ReturnType<typeof vi.fn>;
}; };
let materialiApi: { getMateriali: 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[] = [ const vociIniziali: MagazzinoVoce[] = [
{ {
@@ -26,7 +34,8 @@ describe('Magazzino', () => {
quantitaPosseduta: 10, quantitaPosseduta: 10,
stato: 'buono', stato: 'buono',
posizione: 'Sede', posizione: 'Sede',
note: null note: null,
gruppoId: null
}, },
{ {
id: 'voce-2', id: 'voce-2',
@@ -37,7 +46,30 @@ describe('Magazzino', () => {
quantitaPosseduta: 2, quantitaPosseduta: 2,
stato: 'mancante', stato: 'mancante',
posizione: null, 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: [ providers: [
provideRouter([]), provideRouter([]),
{ provide: MagazzinoApiService, useValue: magazzinoApi }, { provide: MagazzinoApiService, useValue: magazzinoApi },
{ provide: MaterialiApiService, useValue: materialiApi } { provide: MaterialiApiService, useValue: materialiApi },
{ provide: GruppiMagazzinoApiService, useValue: gruppiApi }
] ]
}).compileComponents(); }).compileComponents();
@@ -67,9 +100,16 @@ describe('Magazzino', () => {
magazzinoApi = { magazzinoApi = {
getMagazzino: vi.fn().mockReturnValue(of(vociIniziali)), getMagazzino: vi.fn().mockReturnValue(of(vociIniziali)),
aggiungiVoce: vi.fn(), aggiungiVoce: vi.fn(),
aggiornaVoce: vi.fn() aggiornaVoce: vi.fn(),
eliminaVoce: vi.fn()
}; };
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materialiCatalogo)) }; 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 () => { 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 () => { it('chiama aggiungiVoce con i dati del form e chiude il form al successo', async () => {
component.selezionaMateriale(materialiCatalogo[1]); 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 = { const nuovaVoce: MagazzinoVoce = {
id: 'voce-3', id: 'voce-3',
@@ -183,7 +229,8 @@ describe('Magazzino', () => {
quantitaPosseduta: 5, quantitaPosseduta: 5,
stato: 'da_riparare', stato: 'da_riparare',
posizione: 'Garage', posizione: 'Garage',
note: null note: null,
gruppoId: null
}; };
magazzinoApi.aggiungiVoce.mockReturnValue(of(nuovaVoce)); magazzinoApi.aggiungiVoce.mockReturnValue(of(nuovaVoce));
@@ -194,7 +241,8 @@ describe('Magazzino', () => {
quantitaPosseduta: 5, quantitaPosseduta: 5,
stato: 'da_riparare', stato: 'da_riparare',
posizione: 'Garage', posizione: 'Garage',
note: undefined note: undefined,
gruppoId: null
}); });
expect(component.voci()[0]).toEqual(nuovaVoce); expect(component.voci()[0]).toEqual(nuovaVoce);
expect(component.formAperto()).toBe(false); expect(component.formAperto()).toBe(false);
@@ -224,6 +272,7 @@ describe('Magazzino', () => {
expect(component.form.value.quantitaPosseduta).toBe(2); expect(component.form.value.quantitaPosseduta).toBe(2);
expect(component.form.value.stato).toBe('mancante'); expect(component.form.value.stato).toBe('mancante');
expect(component.form.value.note).toBe('Da riordinare'); 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 () => { it('chiama aggiornaVoce con l\'id corretto e azzera i campi vuoti a null', async () => {
@@ -240,9 +289,223 @@ describe('Magazzino', () => {
quantitaPosseduta: 4, quantitaPosseduta: 4,
stato: 'buono', stato: 'buono',
posizione: null, posizione: null,
note: 'Da riordinare' note: 'Da riordinare',
gruppoId: 'gruppo-figlio-1'
}); });
expect(component.voci().find((v) => v.id === 'voce-2')).toEqual(voceAggiornata); 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 { Component, OnInit, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop'; 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 { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
@@ -16,6 +16,12 @@ import {
MagazzinoVoce, MagazzinoVoce,
StatoMagazzinoVoce StatoMagazzinoVoce
} from './magazzino-api.service'; } from './magazzino-api.service';
import {
AggiornaGruppoMagazzinoInput,
CreaGruppoMagazzinoInput,
GruppiMagazzinoApiService,
GruppoMagazzino
} from './gruppi-magazzino-api.service';
interface MaterialeRiferimento { interface MaterialeRiferimento {
id: string; id: string;
@@ -35,17 +41,24 @@ const STATI: OpzioneStato[] = [
const SENZA_POSIZIONE = 'Senza posizione'; const SENZA_POSIZIONE = 'Senza posizione';
type Vista = 'lista' | 'posizione'; type Vista = 'lista' | 'posizione' | 'gruppo';
interface GruppoPosizione { interface GruppoPosizione {
posizione: string; posizione: string;
voci: MagazzinoVoce[]; voci: MagazzinoVoce[];
} }
interface GruppoSelezionabile {
id: string;
nome: string;
indentato: boolean;
}
@Component({ @Component({
selector: 'app-magazzino', selector: 'app-magazzino',
imports: [ imports: [
ReactiveFormsModule, ReactiveFormsModule,
FormsModule,
MatButtonModule, MatButtonModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
@@ -58,6 +71,7 @@ interface GruppoPosizione {
export class Magazzino implements OnInit { export class Magazzino implements OnInit {
private readonly magazzinoApi = inject(MagazzinoApiService); private readonly magazzinoApi = inject(MagazzinoApiService);
private readonly materialiApi = inject(MaterialiApiService); private readonly materialiApi = inject(MaterialiApiService);
private readonly gruppiApi = inject(GruppiMagazzinoApiService);
readonly displayedColumns = ['materiale', 'quantita', 'stato', 'posizione', 'note', 'azioni']; readonly displayedColumns = ['materiale', 'quantita', 'stato', 'posizione', 'note', 'azioni'];
readonly stati = STATI; readonly stati = STATI;
@@ -116,6 +130,41 @@ export class Magazzino implements OnInit {
return chiavi.map((posizione) => ({ posizione, voci: gruppi.get(posizione)! })); 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 formAperto = signal(false);
readonly voceInModificaId = signal<string | null>(null); readonly voceInModificaId = signal<string | null>(null);
readonly materialeSelezionato = signal<MaterialeRiferimento | null>(null); readonly materialeSelezionato = signal<MaterialeRiferimento | null>(null);
@@ -124,6 +173,9 @@ export class Magazzino implements OnInit {
readonly salvataggioInCorso = signal(false); readonly salvataggioInCorso = signal(false);
readonly salvataggioErrore = signal<string | null>(null); readonly salvataggioErrore = signal<string | null>(null);
readonly voceEliminaErroreId = signal<string | null>(null);
readonly voceEliminaErroreMsg = signal<string | null>(null);
readonly ricerca = new FormControl('', { nonNullable: true }); readonly ricerca = new FormControl('', { nonNullable: true });
private readonly ricercaValue = toSignal(this.ricerca.valueChanges, { initialValue: '' }); 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] }), stato: new FormControl<StatoMagazzinoVoce>('buono', { nonNullable: true, validators: [Validators.required] }),
posizione: new FormControl('', { nonNullable: true }), 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[]>(() => { readonly risultatiRicerca = computed<MaterialePubblico[]>(() => {
@@ -150,7 +203,7 @@ export class Magazzino implements OnInit {
); );
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
await this.carica(); await Promise.all([this.carica(), this.caricaGruppi()]);
} }
apriNuovaVoce(): void { apriNuovaVoce(): void {
@@ -159,7 +212,7 @@ export class Magazzino implements OnInit {
this.materialeErrore.set(null); this.materialeErrore.set(null);
this.salvataggioErrore.set(null); this.salvataggioErrore.set(null);
this.ricerca.setValue(''); 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); this.formAperto.set(true);
} }
@@ -173,7 +226,8 @@ export class Magazzino implements OnInit {
quantitaPosseduta: voce.quantitaPosseduta, quantitaPosseduta: voce.quantitaPosseduta,
stato: voce.stato, stato: voce.stato,
posizione: voce.posizione ?? '', posizione: voce.posizione ?? '',
note: voce.note ?? '' note: voce.note ?? '',
gruppoId: voce.gruppoId
}); });
this.formAperto.set(true); this.formAperto.set(true);
} }
@@ -228,7 +282,8 @@ export class Magazzino implements OnInit {
quantitaPosseduta: valori.quantitaPosseduta, quantitaPosseduta: valori.quantitaPosseduta,
stato: valori.stato, stato: valori.stato,
posizione: posizione.length > 0 ? posizione : null, posizione: posizione.length > 0 ? posizione : null,
note: note.length > 0 ? note : null note: note.length > 0 ? note : null,
gruppoId: valori.gruppoId
} satisfies AggiornaMagazzinoVoceInput) } satisfies AggiornaMagazzinoVoceInput)
) )
: await firstValueFrom( : await firstValueFrom(
@@ -237,7 +292,8 @@ export class Magazzino implements OnInit {
quantitaPosseduta: valori.quantitaPosseduta, quantitaPosseduta: valori.quantitaPosseduta,
stato: valori.stato, stato: valori.stato,
posizione: posizione.length > 0 ? posizione : undefined, posizione: posizione.length > 0 ? posizione : undefined,
note: note.length > 0 ? note : undefined note: note.length > 0 ? note : undefined,
gruppoId: valori.gruppoId
} satisfies AggiungiMagazzinoVoceInput) } satisfies AggiungiMagazzinoVoceInput)
); );
@@ -246,6 +302,11 @@ export class Magazzino implements OnInit {
return esisteGia ? voci.map((v) => (v.id === voce.id ? voce : v)) : [voce, ...voci]; return esisteGia ? voci.map((v) => (v.id === voce.id ? voce : v)) : [voce, ...voci];
}); });
this.formAperto.set(false); 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 { } catch {
this.salvataggioErrore.set('Impossibile salvare la voce di magazzino. Riprova più tardi.'); this.salvataggioErrore.set('Impossibile salvare la voce di magazzino. Riprova più tardi.');
} finally { } 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> { private async carica(): Promise<void> {
this.loading.set(true); this.loading.set(true);
this.loadError.set(null); this.loadError.set(null);
@@ -269,4 +349,147 @@ export class Magazzino implements OnInit {
this.loading.set(false); 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.";
}
} }