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
@@ -1,23 +0,0 @@
.catalogo-materiali__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.catalogo-materiali__filtri {
flex-wrap: wrap;
margin-bottom: var(--space-5);
width: fit-content;
}
.catalogo-materiali__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: var(--space-4);
}
.catalogo-materiali__error {
color: var(--color-accent-800);
}
@@ -1,50 +0,0 @@
<section class="catalogo-materiali om-page">
<div class="catalogo-materiali__header">
<div>
<h1 class="om-section-title">Catalogo materiali</h1>
<p class="om-section-sub">Materiali condivisi tra tutte le organizzazioni. Consultabile senza login.</p>
</div>
<a class="btn btn-ghost" routerLink="/">Liste modello per evento</a>
</div>
@if (loading()) {
<p class="om-empty">Caricamento catalogo…</p>
} @else if (loadError(); as message) {
<p class="catalogo-materiali__error" role="alert">{{ message }}</p>
} @else {
<div class="seg catalogo-materiali__filtri" role="group" aria-label="Filtra per categoria">
<button
type="button"
class="seg-opt"
[class.seg-opt--attiva]="categoriaSelezionata() === null"
(click)="selezionaCategoria(null)"
>
Tutte
</button>
@for (categoria of categorie(); track categoria) {
<button
type="button"
class="seg-opt"
[class.seg-opt--attiva]="categoriaSelezionata() === categoria"
(click)="selezionaCategoria(categoria)"
>
{{ categoria }}
</button>
}
</div>
@if (materialiFiltrati().length === 0) {
<p class="om-empty">Nessun materiale trovato.</p>
} @else {
<div class="catalogo-materiali__grid">
@for (materiale of materialiFiltrati(); track materiale.id) {
<div class="card materiale-card">
<div class="card-kicker">{{ materiale.categoria }}</div>
<div class="card-title">{{ materiale.nome }}</div>
<p class="card-body">Unità di misura: {{ materiale.unitaMisura }}</p>
</div>
}
</div>
}
}
</section>
@@ -1,79 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service';
import { CatalogoMateriali } from './catalogo-materiali';
describe('CatalogoMateriali', () => {
let component: CatalogoMateriali;
let fixture: ComponentFixture<CatalogoMateriali>;
let materialiApi: { getMateriali: ReturnType<typeof vi.fn> };
const materiali: MaterialePubblico[] = [
{ id: 'mat-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' },
{ id: 'mat-2', nome: 'Telo cerato', categoria: 'Campeggio', unitaMisura: 'pz' },
{ id: 'mat-3', nome: 'Moschettone', categoria: 'Attrezzatura', unitaMisura: 'pz' }
];
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [CatalogoMateriali],
providers: [provideRouter([]), { provide: MaterialiApiService, useValue: materialiApi }]
}).compileComponents();
fixture = TestBed.createComponent(CatalogoMateriali);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
it('mostra tutti i materiali del catalogo dopo il caricamento', async () => {
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
await setup();
expect(component.loading()).toBe(false);
expect(component.materiali()).toEqual(materiali);
const compiled = fixture.nativeElement as HTMLElement;
const cards = compiled.querySelectorAll('.materiale-card');
expect(cards.length).toBe(3);
});
it('filtra i materiali per categoria quando si seleziona un filtro', async () => {
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
await setup();
component.selezionaCategoria('Attrezzatura');
fixture.detectChanges();
expect(component.materialiFiltrati()).toEqual([materiali[0], materiali[2]]);
const compiled = fixture.nativeElement as HTMLElement;
const cards = compiled.querySelectorAll('.materiale-card');
expect(cards.length).toBe(2);
});
it('torna a mostrare tutti i materiali selezionando di nuovo "Tutte"', async () => {
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
await setup();
component.selezionaCategoria('Campeggio');
component.selezionaCategoria(null);
expect(component.materialiFiltrati()).toEqual(materiali);
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
materialiApi = { getMateriali: vi.fn().mockReturnValue(throwError(() => new Error('network error'))) };
await setup();
expect(component.loading()).toBe(false);
expect(component.loadError()).toBe('Impossibile caricare il catalogo dei materiali. Riprova più tardi.');
});
});
@@ -1,55 +0,0 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service';
@Component({
selector: 'app-catalogo-materiali',
imports: [MatButtonModule, MatCardModule, RouterLink],
templateUrl: './catalogo-materiali.html',
styleUrl: './catalogo-materiali.css'
})
export class CatalogoMateriali implements OnInit {
private readonly materialiApi = inject(MaterialiApiService);
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly materiali = signal<MaterialePubblico[]>([]);
readonly categoriaSelezionata = signal<string | null>(null);
readonly categorie = computed(() => {
const insieme = new Set(this.materiali().map((materiale) => materiale.categoria));
return Array.from(insieme).sort((a, b) => a.localeCompare(b));
});
readonly materialiFiltrati = computed(() => {
const categoria = this.categoriaSelezionata();
const materiali = this.materiali();
return categoria ? materiali.filter((materiale) => materiale.categoria === categoria) : materiali;
});
async ngOnInit(): Promise<void> {
await this.caricaMateriali();
}
selezionaCategoria(categoria: string | null): void {
this.categoriaSelezionata.set(categoria);
}
private async caricaMateriali(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const materiali = await firstValueFrom(this.materialiApi.getMateriali());
this.materiali.set(materiali);
} catch {
this.loadError.set('Impossibile caricare il catalogo dei materiali. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -8,11 +8,6 @@ export const CATALOGO_ROUTES: Routes = [
loadComponent: () =>
import('./catalogo-liste-modello/catalogo-liste-modello').then((m) => m.CatalogoListeModello)
},
{
// Pubblica: il catalogo del materiale è consultabile anche da chi non è autenticato.
path: 'catalogo-materiali',
loadComponent: () => import('./catalogo-materiali/catalogo-materiali').then((m) => m.CatalogoMateriali)
},
{
// Pubblica: la ricerca delle liste modello è consultabile anche da chi non è autenticato.
path: 'cerca-lista',
@@ -105,4 +105,8 @@ export class ListeApiService {
aggiornaLista(id: string, input: AggiornaListaInput): Observable<Lista> {
return this.http.put<Lista>(`${environment.magazzinoApiBaseUrl}/liste/${id}`, input);
}
eliminaLista(id: string): Observable<void> {
return this.http.delete<void>(`${environment.magazzinoApiBaseUrl}/liste/${id}`);
}
}
@@ -98,6 +98,11 @@
border: 1px solid var(--color-divider);
}
.riga-azioni {
display: flex;
gap: 8px;
}
.riga-modifica {
cursor: pointer;
padding: 9px 14px;
@@ -107,6 +112,22 @@
font-size: 14px;
}
.riga-elimina {
cursor: pointer;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-accent-800);
color: var(--color-accent-800);
font-weight: 600;
font-size: 14px;
}
.errore-inline {
color: var(--color-accent-800);
font-size: 13px;
margin-top: 6px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
@@ -37,6 +37,9 @@
❌ proposta rifiutata
</div>
}
@if (eliminaErroreId() === lista.id) {
<div class="errore-inline">{{ eliminaErroreMsg() }}</div>
}
</div>
@if (puoCambiareStato(lista)) {
<div class="stato-options">
@@ -58,7 +61,10 @@
🔒 solo il creatore può cambiare lo stato
</div>
}
<div class="riga-modifica" (click)="modifica(lista)">Modifica</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="modifica(lista)">Modifica</div>
<div class="riga-elimina" (click)="elimina(lista)">Elimina</div>
</div>
</div>
}
</div>
@@ -176,4 +176,37 @@ describe('ListeList', () => {
expect(component.loadError()).toBe('Impossibile caricare le liste. Riprova più tardi.');
});
describe('eliminazione di una lista', () => {
it('rimuove la lista dall\'elenco al successo', async () => {
listeApi = {
getListe: vi.fn().mockReturnValue(of(liste)),
eliminaLista: vi.fn().mockReturnValue(of(undefined))
} as unknown as typeof listeApi;
await setup();
await component.elimina(liste[0]);
expect((listeApi as unknown as { eliminaLista: ReturnType<typeof vi.fn> }).eliminaLista).toHaveBeenCalledWith(
'lista-1'
);
expect(component.liste().some((l) => l.id === 'lista-1')).toBe(false);
});
it('mostra un errore inline sulla riga se l\'eliminazione fallisce', async () => {
listeApi = {
getListe: vi.fn().mockReturnValue(of(liste)),
eliminaLista: vi
.fn()
.mockReturnValue(throwError(() => ({ error: { message: 'Lista usata come sotto-lista.' } })))
} as unknown as typeof listeApi;
await setup();
await component.elimina(liste[0]);
expect(component.eliminaErroreId()).toBe('lista-1');
expect(component.eliminaErroreMsg()).toBe('Lista usata come sotto-lista.');
expect(component.liste().some((l) => l.id === 'lista-1')).toBe(true);
});
});
});
@@ -43,6 +43,9 @@ export class ListeList implements OnInit {
readonly liste = signal<Lista[]>([]);
readonly stati = STATI_LISTA;
readonly eliminaErroreId = signal<string | null>(null);
readonly eliminaErroreMsg = signal<string | null>(null);
async ngOnInit(): Promise<void> {
await this.caricaListe();
}
@@ -122,4 +125,31 @@ export class ListeList implements OnInit {
error: () => this.loadError.set('Impossibile cambiare stato alla lista. Riprova più tardi.')
});
}
async elimina(lista: Lista): Promise<void> {
this.eliminaErroreId.set(null);
this.eliminaErroreMsg.set(null);
try {
await firstValueFrom(this.listeApi.eliminaLista(lista.id));
this.liste.update((liste) => liste.filter((l) => l.id !== lista.id));
} catch (err) {
this.eliminaErroreId.set(lista.id);
this.eliminaErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
private estraiMessaggioErrore(err: unknown): string {
if (err && typeof err === 'object' && 'error' in err) {
const body = (err as { error?: unknown }).error;
if (
body &&
typeof body === 'object' &&
'message' in body &&
typeof (body as { message?: unknown }).message === 'string'
) {
return (body as { message: string }).message;
}
}
return "Impossibile completare l'operazione.";
}
}
@@ -16,6 +16,7 @@ export interface MagazzinoVoce {
stato: StatoMagazzinoVoce;
posizione: string | null;
note: string | null;
gruppoId: string | null;
}
export interface AggiungiMagazzinoVoceInput {
@@ -24,6 +25,7 @@ export interface AggiungiMagazzinoVoceInput {
stato: StatoMagazzinoVoce;
posizione?: string;
note?: string;
gruppoId?: string | null;
}
export interface AggiornaMagazzinoVoceInput {
@@ -32,6 +34,7 @@ export interface AggiornaMagazzinoVoceInput {
stato?: StatoMagazzinoVoce;
posizione?: string | null;
note?: string | null;
gruppoId?: string | null;
}
@Injectable({ providedIn: 'root' })
@@ -49,4 +52,8 @@ export class MagazzinoApiService {
aggiornaVoce(id: string, input: AggiornaMagazzinoVoceInput): Observable<MagazzinoVoce> {
return this.http.put<MagazzinoVoce>(`${environment.magazzinoApiBaseUrl}/magazzino/${id}`, input);
}
eliminaVoce(id: string): Observable<void> {
return this.http.delete<void>(`${environment.magazzinoApiBaseUrl}/magazzino/${id}`);
}
}
@@ -19,15 +19,34 @@
font-size: 28px;
}
.magazzino__tabs {
/* Stile bottoni tab replicato 1:1 da tassonomie.css (.tabs/.tab/.tab--attivo),
per coerenza visiva tra le pagine con viste alternative a tab. */
.tabs {
display: flex;
width: 100%;
margin-bottom: var(--space-4);
gap: 6px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.magazzino__tabs .seg-opt {
flex: 1;
justify-content: center;
.tab {
cursor: pointer;
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
font-weight: 700;
background: var(--color-surface);
border: 1px solid var(--color-divider);
color: var(--color-text);
}
.tab--attivo {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.magazzino__tabs {
width: 100%;
}
.magazzino__error {
@@ -37,30 +56,13 @@
.magazzino__alfabeto {
display: flex;
flex-wrap: wrap;
gap: 4px;
gap: 6px;
margin-bottom: var(--space-4);
}
.magazzino__lettera {
min-width: 28px;
padding: 4px 6px;
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.magazzino__lettera:hover {
background: color-mix(in srgb, var(--color-text) 7%, transparent);
}
.magazzino__lettera--attiva {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
padding: 6px 12px;
}
.magazzino__contenuto--con-form {
@@ -85,6 +87,7 @@
.magazzino__posizione-voce {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
@@ -93,6 +96,11 @@
cursor: pointer;
}
.magazzino__posizione-voce-errore {
flex-basis: 100%;
margin: 0;
}
.magazzino__posizione-lista li:first-child {
border-top: none;
}
@@ -120,7 +128,7 @@
.magazzino__form {
padding: var(--space-4);
margin-bottom: var(--space-5);
margin-bottom: var(--space-6);
}
.magazzino__materiale {
@@ -179,6 +187,12 @@
width: 100%;
}
.magazzino__tabella-azioni {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.magazzino-badge--buono {
background: var(--color-accent-2-100);
color: var(--color-accent-2-800);
@@ -193,3 +207,38 @@
background: var(--color-neutral-200);
color: var(--color-neutral-800);
}
.magazzino__gruppo-nuovo {
margin-bottom: var(--space-6);
}
.magazzino__gruppo-form {
padding: var(--space-4);
margin-bottom: var(--space-6);
}
.magazzino__gruppo-card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-2);
}
.magazzino__gruppo-azioni {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.magazzino__gruppo-figli {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-3);
}
.magazzino__gruppo-figlio {
padding-left: var(--space-3);
border-left: 2px solid var(--color-divider);
}
@@ -6,18 +6,16 @@
}
</div>
<div class="seg magazzino__tabs" role="group" aria-label="Visualizzazione magazzino">
<button type="button" class="seg-opt" [class.seg-opt--attiva]="vista() === 'lista'" (click)="vista.set('lista')">
<div class="tabs magazzino__tabs" role="group" aria-label="Visualizzazione magazzino">
<button type="button" class="tab" [class.tab--attivo]="vista() === 'lista'" (click)="vista.set('lista')">
Per lista
</button>
<button
type="button"
class="seg-opt"
[class.seg-opt--attiva]="vista() === 'posizione'"
(click)="vista.set('posizione')"
>
<button type="button" class="tab" [class.tab--attivo]="vista() === 'posizione'" (click)="vista.set('posizione')">
Per posizione
</button>
<button type="button" class="tab" [class.tab--attivo]="vista() === 'gruppo'" (click)="vista.set('gruppo')">
Per gruppo
</button>
</div>
@if (loading()) {
@@ -93,6 +91,16 @@
<label for="mg-note">Note</label>
<input class="input" id="mg-note" formControlName="note" />
</div>
<div class="field">
<label for="mg-gruppo">Gruppo</label>
<select class="input" id="mg-gruppo" formControlName="gruppoId">
<option [ngValue]="null">Nessun gruppo</option>
@for (opzione of gruppiSelezionabili(); track opzione.id) {
<option [ngValue]="opzione.id">{{ opzione.indentato ? '— ' : '' }}{{ opzione.nome }}</option>
}
</select>
</div>
</div>
@if (salvataggioErrore(); as message) {
@@ -108,7 +116,7 @@
</form>
}
@if (voci().length === 0) {
@if (vista() !== 'gruppo' && voci().length === 0) {
<p class="om-empty">Nessuna voce in magazzino.</p>
} @else {
<div class="magazzino__contenuto" [class.magazzino__contenuto--con-form]="formAperto()">
@@ -117,8 +125,8 @@
@for (lettera of lettereDisponibili(); track lettera) {
<button
type="button"
class="magazzino__lettera"
[class.magazzino__lettera--attiva]="letteraSelezionata() === lettera"
class="tab magazzino__lettera"
[class.tab--attivo]="letteraSelezionata() === lettera"
(click)="selezionaLettera(lettera)"
>
{{ lettera }}
@@ -152,15 +160,21 @@
</td>
<td>{{ voce.posizione ?? '—' }}</td>
<td>{{ voce.note ?? '—' }}</td>
<td>
<td class="magazzino__tabella-azioni">
<button type="button" class="btn btn-ghost" (click)="apriModificaVoce(voce)">Modifica</button>
<button type="button" class="btn btn-ghost" (click)="eliminaVoce(voce)">Elimina</button>
</td>
</tr>
@if (voceEliminaErroreId() === voce.id) {
<tr>
<td colspan="6" class="magazzino__error">{{ voceEliminaErroreMsg() }}</td>
</tr>
}
}
</tbody>
</table>
}
} @else {
} @else if (vista() === 'posizione') {
<div class="om-grid">
@for (gruppo of gruppiPerPosizione(); track gruppo.posizione) {
<div class="card magazzino__posizione-card">
@@ -176,12 +190,186 @@
<span [class]="'tag magazzino-badge magazzino-badge--' + voce.stato">
{{ statoLabel(voce.stato) }}
</span>
<button
type="button"
class="btn btn-ghost"
(click)="$event.stopPropagation(); eliminaVoce(voce)"
>
Elimina
</button>
@if (voceEliminaErroreId() === voce.id) {
<p class="magazzino__error magazzino__posizione-voce-errore">{{ voceEliminaErroreMsg() }}</p>
}
</li>
}
</ul>
</div>
}
</div>
} @else {
@if (!gruppoFormAperto()) {
<button type="button" class="btn btn-primary magazzino__gruppo-nuovo" (click)="apriNuovoGruppo()">
+ Nuovo gruppo
</button>
}
@if (gruppoFormAperto()) {
<div class="magazzino__gruppo-form card elev-sm">
<h2>{{ gruppoEditId() ? 'Modifica gruppo' : 'Nuovo gruppo' }}</h2>
<div class="magazzino__campi">
<div class="field">
<label for="mg-gruppo-nome">Nome</label>
<input
class="input"
id="mg-gruppo-nome"
[ngModel]="gruppoNome()"
(ngModelChange)="gruppoNome.set($event)"
name="gruppoNome"
placeholder="Es. Sede, Ripostiglio A"
/>
</div>
<div class="field">
<label for="mg-gruppo-padre">Gruppo padre</label>
<select
class="input"
id="mg-gruppo-padre"
[ngModel]="gruppoParentId()"
(ngModelChange)="gruppoParentId.set($event)"
name="gruppoParentId"
>
<option [ngValue]="null">Nessuno (gruppo di primo livello)</option>
@for (radice of gruppiRadiceDisponibili(); track radice.id) {
<option [ngValue]="radice.id">{{ radice.nome }}</option>
}
</select>
</div>
</div>
@if (gruppoSalvataggioErrore(); as message) {
<p class="magazzino__error" role="alert">{{ message }}</p>
}
<div class="magazzino__form-azioni">
<button type="button" class="btn btn-primary" (click)="salvaGruppo()">
{{ gruppoEditId() ? 'Salva modifiche' : '+ Aggiungi gruppo' }}
</button>
<button type="button" class="btn btn-ghost" (click)="annullaGruppo()">Annulla</button>
</div>
</div>
}
@if (gruppi().length === 0) {
<p class="om-empty">Nessun gruppo creato.</p>
} @else {
<div class="om-grid">
@for (radice of gruppi(); track radice.id) {
<div class="card magazzino__posizione-card">
<div class="magazzino__gruppo-card-header">
<div class="card-title">{{ radice.nome }}</div>
<div class="magazzino__gruppo-azioni">
<button type="button" class="btn btn-ghost" (click)="apriNuovoGruppo(radice.id)">
+ Sottogruppo
</button>
<button type="button" class="btn btn-ghost" (click)="modificaGruppo(radice)">Modifica</button>
<button type="button" class="btn btn-ghost" (click)="eliminaGruppo(radice)">Elimina</button>
</div>
</div>
@if (radice.voci.length === 0 && radice.figli.length === 0) {
<p class="om-empty">Nessuna voce in questo gruppo.</p>
}
@if (radice.voci.length > 0) {
<ul class="magazzino__posizione-lista">
@for (voce of radice.voci; track voce.id) {
<li class="magazzino__posizione-voce" (click)="apriModificaVoce(voce)">
<div class="magazzino__posizione-voce-info">
<span class="magazzino__posizione-voce-nome">{{ voce.materialeNome }}</span>
<span class="magazzino__posizione-voce-qta">× {{ voce.quantitaPosseduta }}</span>
</div>
<span [class]="'tag magazzino-badge magazzino-badge--' + voce.stato">
{{ statoLabel(voce.stato) }}
</span>
<button
type="button"
class="btn btn-ghost"
(click)="$event.stopPropagation(); eliminaVoce(voce)"
>
Elimina
</button>
@if (voceEliminaErroreId() === voce.id) {
<p class="magazzino__error magazzino__posizione-voce-errore">
{{ voceEliminaErroreMsg() }}
</p>
}
</li>
}
</ul>
}
@if (gruppoEliminaErroreId() === radice.id) {
<p class="magazzino__error" role="alert">{{ gruppoEliminaErroreMsg() }}</p>
}
@if (radice.figli.length > 0) {
<div class="magazzino__gruppo-figli">
@for (figlio of radice.figli; track figlio.id) {
<div class="magazzino__gruppo-figlio">
<div class="magazzino__gruppo-card-header">
<div class="card-title">{{ figlio.nome }}</div>
<div class="magazzino__gruppo-azioni">
<button type="button" class="btn btn-ghost" (click)="modificaGruppo(figlio)">
Modifica
</button>
<button type="button" class="btn btn-ghost" (click)="eliminaGruppo(figlio)">
Elimina
</button>
</div>
</div>
@if (figlio.voci.length === 0) {
<p class="om-empty">Nessuna voce in questo gruppo.</p>
} @else {
<ul class="magazzino__posizione-lista">
@for (voce of figlio.voci; track voce.id) {
<li class="magazzino__posizione-voce" (click)="apriModificaVoce(voce)">
<div class="magazzino__posizione-voce-info">
<span class="magazzino__posizione-voce-nome">{{ voce.materialeNome }}</span>
<span class="magazzino__posizione-voce-qta">× {{ voce.quantitaPosseduta }}</span>
</div>
<span [class]="'tag magazzino-badge magazzino-badge--' + voce.stato">
{{ statoLabel(voce.stato) }}
</span>
<button
type="button"
class="btn btn-ghost"
(click)="$event.stopPropagation(); eliminaVoce(voce)"
>
Elimina
</button>
@if (voceEliminaErroreId() === voce.id) {
<p class="magazzino__error magazzino__posizione-voce-errore">
{{ voceEliminaErroreMsg() }}
</p>
}
</li>
}
</ul>
}
@if (gruppoEliminaErroreId() === figlio.id) {
<p class="magazzino__error" role="alert">{{ gruppoEliminaErroreMsg() }}</p>
}
</div>
}
</div>
}
</div>
}
</div>
}
}
</div>
}
@@ -4,6 +4,7 @@ import { of, throwError } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../catalogo/materiali-api.service';
import { MagazzinoApiService, MagazzinoVoce } from './magazzino-api.service';
import { GruppiMagazzinoApiService, GruppoMagazzino } from './gruppi-magazzino-api.service';
import { Magazzino } from './magazzino';
describe('Magazzino', () => {
@@ -13,8 +14,15 @@ describe('Magazzino', () => {
getMagazzino: ReturnType<typeof vi.fn>;
aggiungiVoce: ReturnType<typeof vi.fn>;
aggiornaVoce: ReturnType<typeof vi.fn>;
eliminaVoce: ReturnType<typeof vi.fn>;
};
let materialiApi: { getMateriali: ReturnType<typeof vi.fn> };
let gruppiApi: {
getGruppi: ReturnType<typeof vi.fn>;
creaGruppo: ReturnType<typeof vi.fn>;
aggiornaGruppo: ReturnType<typeof vi.fn>;
eliminaGruppo: ReturnType<typeof vi.fn>;
};
const vociIniziali: MagazzinoVoce[] = [
{
@@ -26,7 +34,8 @@ describe('Magazzino', () => {
quantitaPosseduta: 10,
stato: 'buono',
posizione: 'Sede',
note: null
note: null,
gruppoId: null
},
{
id: 'voce-2',
@@ -37,7 +46,30 @@ describe('Magazzino', () => {
quantitaPosseduta: 2,
stato: 'mancante',
posizione: null,
note: 'Da riordinare'
note: 'Da riordinare',
gruppoId: 'gruppo-figlio-1'
}
];
const gruppiIniziali: GruppoMagazzino[] = [
{
id: 'gruppo-radice-1',
orgId: 'org-1',
nome: 'Sede',
parentId: null,
creatoIl: '2026-07-31T00:00:00.000Z',
voci: [],
figli: [
{
id: 'gruppo-figlio-1',
orgId: 'org-1',
nome: 'Ripostiglio A',
parentId: 'gruppo-radice-1',
creatoIl: '2026-07-31T00:00:00.000Z',
voci: [vociIniziali[1]],
figli: []
}
]
}
];
@@ -52,7 +84,8 @@ describe('Magazzino', () => {
providers: [
provideRouter([]),
{ provide: MagazzinoApiService, useValue: magazzinoApi },
{ provide: MaterialiApiService, useValue: materialiApi }
{ provide: MaterialiApiService, useValue: materialiApi },
{ provide: GruppiMagazzinoApiService, useValue: gruppiApi }
]
}).compileComponents();
@@ -67,9 +100,16 @@ describe('Magazzino', () => {
magazzinoApi = {
getMagazzino: vi.fn().mockReturnValue(of(vociIniziali)),
aggiungiVoce: vi.fn(),
aggiornaVoce: vi.fn()
aggiornaVoce: vi.fn(),
eliminaVoce: vi.fn()
};
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materialiCatalogo)) };
gruppiApi = {
getGruppi: vi.fn().mockReturnValue(of(gruppiIniziali)),
creaGruppo: vi.fn(),
aggiornaGruppo: vi.fn(),
eliminaGruppo: vi.fn()
};
});
it('mostra la tabella con materiale, quantità, stato, posizione e note', async () => {
@@ -172,7 +212,13 @@ describe('Magazzino', () => {
it('chiama aggiungiVoce con i dati del form e chiude il form al successo', async () => {
component.selezionaMateriale(materialiCatalogo[1]);
component.form.setValue({ quantitaPosseduta: 5, stato: 'da_riparare', posizione: 'Garage', note: '' });
component.form.setValue({
quantitaPosseduta: 5,
stato: 'da_riparare',
posizione: 'Garage',
note: '',
gruppoId: null
});
const nuovaVoce: MagazzinoVoce = {
id: 'voce-3',
@@ -183,7 +229,8 @@ describe('Magazzino', () => {
quantitaPosseduta: 5,
stato: 'da_riparare',
posizione: 'Garage',
note: null
note: null,
gruppoId: null
};
magazzinoApi.aggiungiVoce.mockReturnValue(of(nuovaVoce));
@@ -194,7 +241,8 @@ describe('Magazzino', () => {
quantitaPosseduta: 5,
stato: 'da_riparare',
posizione: 'Garage',
note: undefined
note: undefined,
gruppoId: null
});
expect(component.voci()[0]).toEqual(nuovaVoce);
expect(component.formAperto()).toBe(false);
@@ -224,6 +272,7 @@ describe('Magazzino', () => {
expect(component.form.value.quantitaPosseduta).toBe(2);
expect(component.form.value.stato).toBe('mancante');
expect(component.form.value.note).toBe('Da riordinare');
expect(component.form.value.gruppoId).toBe('gruppo-figlio-1');
});
it('chiama aggiornaVoce con l\'id corretto e azzera i campi vuoti a null', async () => {
@@ -240,9 +289,223 @@ describe('Magazzino', () => {
quantitaPosseduta: 4,
stato: 'buono',
posizione: null,
note: 'Da riordinare'
note: 'Da riordinare',
gruppoId: 'gruppo-figlio-1'
});
expect(component.voci().find((v) => v.id === 'voce-2')).toEqual(voceAggiornata);
});
});
describe('eliminazione di una voce', () => {
beforeEach(async () => {
await setup();
});
it('rimuove la voce dall\'elenco al successo', async () => {
magazzinoApi.eliminaVoce.mockReturnValue(of(undefined));
await component.eliminaVoce(vociIniziali[0]);
expect(magazzinoApi.eliminaVoce).toHaveBeenCalledWith('voce-1');
expect(component.voci().some((v) => v.id === 'voce-1')).toBe(false);
});
it('rifà il fetch dei gruppi se la voce eliminata apparteneva a un gruppo', async () => {
magazzinoApi.eliminaVoce.mockReturnValue(of(undefined));
await component.eliminaVoce(vociIniziali[1]);
expect(gruppiApi.getGruppi).toHaveBeenCalledTimes(2);
});
it('non rifà il fetch dei gruppi se la voce eliminata non apparteneva a nessun gruppo', async () => {
magazzinoApi.eliminaVoce.mockReturnValue(of(undefined));
await component.eliminaVoce(vociIniziali[0]);
expect(gruppiApi.getGruppi).toHaveBeenCalledTimes(1);
});
it('mostra un errore inline se l\'eliminazione fallisce', async () => {
magazzinoApi.eliminaVoce.mockReturnValue(
throwError(() => ({ error: { message: 'Voce referenziata da un evento.' } }))
);
await component.eliminaVoce(vociIniziali[0]);
expect(component.voceEliminaErroreId()).toBe('voce-1');
expect(component.voceEliminaErroreMsg()).toBe('Voce referenziata da un evento.');
expect(component.voci().some((v) => v.id === 'voce-1')).toBe(true);
});
});
describe('vista per gruppo', () => {
beforeEach(async () => {
await setup();
});
it('mostra i gruppi radice con i figli annidati quando si passa alla vista "Per gruppo"', () => {
const compiled = fixture.nativeElement as HTMLElement;
const tabGruppo = Array.from(compiled.querySelectorAll('.tab')).find((el) =>
el.textContent?.includes('Per gruppo')
) as HTMLButtonElement;
tabGruppo.click();
fixture.detectChanges();
expect(component.vista()).toBe('gruppo');
expect(compiled.textContent).toContain('Sede');
const figlio = compiled.querySelector('.magazzino__gruppo-figlio');
expect(figlio?.textContent).toContain('Ripostiglio A');
expect(figlio?.textContent).toContain('Telo cerato');
});
it('tiene nascosta la card gruppo finché non si clicca "+ Nuovo gruppo", e "Annulla" la richiude', () => {
component.vista.set('gruppo');
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(component.gruppoFormAperto()).toBe(false);
expect(compiled.querySelector('.magazzino__gruppo-form')).toBeNull();
const apriBtn = compiled.querySelector('.magazzino__gruppo-nuovo') as HTMLButtonElement;
expect(apriBtn).toBeTruthy();
apriBtn.click();
fixture.detectChanges();
expect(component.gruppoFormAperto()).toBe(true);
expect(compiled.querySelector('.magazzino__gruppo-form')).toBeTruthy();
expect(compiled.querySelector('.magazzino__gruppo-nuovo')).toBeNull();
const annullaBtn = Array.from(compiled.querySelectorAll('.magazzino__gruppo-form .btn-ghost')).find((el) =>
el.textContent?.includes('Annulla')
) as HTMLButtonElement;
annullaBtn.click();
fixture.detectChanges();
expect(component.gruppoFormAperto()).toBe(false);
expect(compiled.querySelector('.magazzino__gruppo-form')).toBeNull();
});
it('modificaGruppo apre la card precompilata anche senza passare da "+ Nuovo gruppo"', () => {
component.modificaGruppo(gruppiIniziali[0]);
expect(component.gruppoFormAperto()).toBe(true);
expect(component.gruppoEditId()).toBe('gruppo-radice-1');
expect(component.gruppoNome()).toBe('Sede');
});
it('espone le opzioni indentate del select "Gruppo" nel form voce', () => {
component.apriNuovaVoce();
fixture.detectChanges();
expect(component.gruppiSelezionabili()).toEqual([
{ id: 'gruppo-radice-1', nome: 'Sede', indentato: false },
{ id: 'gruppo-figlio-1', nome: 'Ripostiglio A', indentato: true }
]);
});
it('crea un gruppo radice e lo aggiunge localmente all\'albero', async () => {
component.vista.set('gruppo');
component.gruppoNome.set('Magazzino esterno');
const nuovoGruppo: GruppoMagazzino = {
id: 'gruppo-radice-2',
orgId: 'org-1',
nome: 'Magazzino esterno',
parentId: null,
creatoIl: '2026-08-01T00:00:00.000Z',
voci: [],
figli: []
};
gruppiApi.creaGruppo.mockReturnValue(of(nuovoGruppo));
await component.salvaGruppo();
expect(gruppiApi.creaGruppo).toHaveBeenCalledWith({ nome: 'Magazzino esterno', parentId: null });
expect(component.gruppi().some((g) => g.id === 'gruppo-radice-2')).toBe(true);
expect(component.gruppoEditId()).toBeNull();
});
it('crea un sottogruppo con il padre precompilato da apriNuovoGruppo', async () => {
component.apriNuovoGruppo('gruppo-radice-1');
component.gruppoNome.set('Ripostiglio B');
const nuovoFiglio: GruppoMagazzino = {
id: 'gruppo-figlio-2',
orgId: 'org-1',
nome: 'Ripostiglio B',
parentId: 'gruppo-radice-1',
creatoIl: '2026-08-01T00:00:00.000Z',
voci: [],
figli: []
};
gruppiApi.creaGruppo.mockReturnValue(of(nuovoFiglio));
await component.salvaGruppo();
expect(gruppiApi.creaGruppo).toHaveBeenCalledWith({ nome: 'Ripostiglio B', parentId: 'gruppo-radice-1' });
const radice = component.gruppi().find((g) => g.id === 'gruppo-radice-1');
expect(radice?.figli.some((f) => f.id === 'gruppo-figlio-2')).toBe(true);
});
it('modifica il nome di un gruppo senza cambiare padre (patch locale, nessun refetch)', async () => {
component.modificaGruppo(gruppiIniziali[0]);
component.gruppoNome.set('Sede centrale');
const aggiornato: GruppoMagazzino = { ...gruppiIniziali[0], nome: 'Sede centrale', figli: [] };
gruppiApi.aggiornaGruppo.mockReturnValue(of(aggiornato));
await component.salvaGruppo();
expect(gruppiApi.aggiornaGruppo).toHaveBeenCalledWith('gruppo-radice-1', {
nome: 'Sede centrale',
parentId: null
});
expect(gruppiApi.getGruppi).toHaveBeenCalledTimes(1);
expect(component.gruppi().find((g) => g.id === 'gruppo-radice-1')?.nome).toBe('Sede centrale');
expect(component.gruppi().find((g) => g.id === 'gruppo-radice-1')?.figli.length).toBe(1);
});
it('elimina un gruppo radice e promuove il figlio (rifà il fetch, nessuna voce del figlio orfanata)', async () => {
// setup() nel beforeEach ha già consumato una chiamata a getGruppi per il
// caricamento iniziale: qui mockiamo solo la chiamata successiva, quella
// del refetch interno a eliminaGruppo. La radice eliminata non ha voci
// dirette (sono nel figlio), quindi la voce del figlio non va orfanata.
gruppiApi.eliminaGruppo.mockReturnValue(of(undefined));
const alberoDopoEliminazione: GruppoMagazzino[] = [
{ ...gruppiIniziali[0].figli[0], parentId: null }
];
gruppiApi.getGruppi.mockReturnValueOnce(of(alberoDopoEliminazione));
await component.eliminaGruppo(gruppiIniziali[0]);
expect(gruppiApi.eliminaGruppo).toHaveBeenCalledWith('gruppo-radice-1');
expect(gruppiApi.getGruppi).toHaveBeenCalledTimes(2);
expect(component.gruppi()).toEqual(alberoDopoEliminazione);
expect(component.voci().find((v) => v.id === 'voce-2')?.gruppoId).toBe('gruppo-figlio-1');
});
it('elimina un gruppo figlio e orfana la sua voce diretta', async () => {
gruppiApi.eliminaGruppo.mockReturnValue(of(undefined));
const alberoDopoEliminazione: GruppoMagazzino[] = [{ ...gruppiIniziali[0], figli: [] }];
gruppiApi.getGruppi.mockReturnValueOnce(of(alberoDopoEliminazione));
await component.eliminaGruppo(gruppiIniziali[0].figli[0]);
expect(gruppiApi.eliminaGruppo).toHaveBeenCalledWith('gruppo-figlio-1');
expect(component.voci().find((v) => v.id === 'voce-2')?.gruppoId).toBeNull();
});
it('mostra un errore inline se l\'eliminazione del gruppo fallisce', async () => {
gruppiApi.eliminaGruppo.mockReturnValue(
throwError(() => ({ error: { message: 'Il gruppo contiene ancora dei sottogruppi.' } }))
);
await component.eliminaGruppo(gruppiIniziali[0]);
expect(component.gruppoEliminaErroreId()).toBe('gruppo-radice-1');
expect(component.gruppoEliminaErroreMsg()).toBe('Il gruppo contiene ancora dei sottogruppi.');
expect(gruppiApi.getGruppi).toHaveBeenCalledTimes(1);
});
});
});
@@ -1,6 +1,6 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
@@ -16,6 +16,12 @@ import {
MagazzinoVoce,
StatoMagazzinoVoce
} from './magazzino-api.service';
import {
AggiornaGruppoMagazzinoInput,
CreaGruppoMagazzinoInput,
GruppiMagazzinoApiService,
GruppoMagazzino
} from './gruppi-magazzino-api.service';
interface MaterialeRiferimento {
id: string;
@@ -35,17 +41,24 @@ const STATI: OpzioneStato[] = [
const SENZA_POSIZIONE = 'Senza posizione';
type Vista = 'lista' | 'posizione';
type Vista = 'lista' | 'posizione' | 'gruppo';
interface GruppoPosizione {
posizione: string;
voci: MagazzinoVoce[];
}
interface GruppoSelezionabile {
id: string;
nome: string;
indentato: boolean;
}
@Component({
selector: 'app-magazzino',
imports: [
ReactiveFormsModule,
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
@@ -58,6 +71,7 @@ interface GruppoPosizione {
export class Magazzino implements OnInit {
private readonly magazzinoApi = inject(MagazzinoApiService);
private readonly materialiApi = inject(MaterialiApiService);
private readonly gruppiApi = inject(GruppiMagazzinoApiService);
readonly displayedColumns = ['materiale', 'quantita', 'stato', 'posizione', 'note', 'azioni'];
readonly stati = STATI;
@@ -116,6 +130,41 @@ export class Magazzino implements OnInit {
return chiavi.map((posizione) => ({ posizione, voci: gruppi.get(posizione)! }));
});
// Vista "per gruppo": l'albero (radici con figli e voci annidati) arriva già
// pronto dal backend — a differenza di "posizione" non è derivato da un
// computed sulle voci, perché qui la relazione è esplicita lato server.
readonly gruppiLoading = signal(true);
readonly gruppiLoadError = signal<string | null>(null);
readonly gruppi = signal<GruppoMagazzino[]>([]);
// Radici + figli appiattiti (con indicazione del rientro), usati sia dal
// select "Gruppo" nel form voce sia dal select "Gruppo padre" nel form gruppo.
readonly gruppiSelezionabili = computed<GruppoSelezionabile[]>(() => {
const risultato: GruppoSelezionabile[] = [];
for (const radice of this.gruppi()) {
risultato.push({ id: radice.id, nome: radice.nome, indentato: false });
for (const figlio of radice.figli) {
risultato.push({ id: figlio.id, nome: figlio.nome, indentato: true });
}
}
return risultato;
});
readonly gruppoFormAperto = signal(false);
readonly gruppoEditId = signal<string | null>(null);
readonly gruppoNome = signal('');
readonly gruppoParentId = signal<string | null>(null);
readonly gruppoSalvataggioErrore = signal<string | null>(null);
readonly gruppoEliminaErroreId = signal<string | null>(null);
readonly gruppoEliminaErroreMsg = signal<string | null>(null);
// Solo le radici, per il select "Gruppo padre": un gruppo può essere figlio
// solo di un gruppo di primo livello (vincolo lato backend), ed escludiamo il
// gruppo stesso quando si sta modificando.
readonly gruppiRadiceDisponibili = computed<GruppoMagazzino[]>(() =>
this.gruppi().filter((g) => g.id !== this.gruppoEditId())
);
readonly formAperto = signal(false);
readonly voceInModificaId = signal<string | null>(null);
readonly materialeSelezionato = signal<MaterialeRiferimento | null>(null);
@@ -124,6 +173,9 @@ export class Magazzino implements OnInit {
readonly salvataggioInCorso = signal(false);
readonly salvataggioErrore = signal<string | null>(null);
readonly voceEliminaErroreId = signal<string | null>(null);
readonly voceEliminaErroreMsg = signal<string | null>(null);
readonly ricerca = new FormControl('', { nonNullable: true });
private readonly ricercaValue = toSignal(this.ricerca.valueChanges, { initialValue: '' });
@@ -134,7 +186,8 @@ export class Magazzino implements OnInit {
}),
stato: new FormControl<StatoMagazzinoVoce>('buono', { nonNullable: true, validators: [Validators.required] }),
posizione: new FormControl('', { nonNullable: true }),
note: new FormControl('', { nonNullable: true })
note: new FormControl('', { nonNullable: true }),
gruppoId: new FormControl<string | null>(null)
});
readonly risultatiRicerca = computed<MaterialePubblico[]>(() => {
@@ -150,7 +203,7 @@ export class Magazzino implements OnInit {
);
async ngOnInit(): Promise<void> {
await this.carica();
await Promise.all([this.carica(), this.caricaGruppi()]);
}
apriNuovaVoce(): void {
@@ -159,7 +212,7 @@ export class Magazzino implements OnInit {
this.materialeErrore.set(null);
this.salvataggioErrore.set(null);
this.ricerca.setValue('');
this.form.reset({ quantitaPosseduta: 0, stato: 'buono', posizione: '', note: '' });
this.form.reset({ quantitaPosseduta: 0, stato: 'buono', posizione: '', note: '', gruppoId: null });
this.formAperto.set(true);
}
@@ -173,7 +226,8 @@ export class Magazzino implements OnInit {
quantitaPosseduta: voce.quantitaPosseduta,
stato: voce.stato,
posizione: voce.posizione ?? '',
note: voce.note ?? ''
note: voce.note ?? '',
gruppoId: voce.gruppoId
});
this.formAperto.set(true);
}
@@ -228,7 +282,8 @@ export class Magazzino implements OnInit {
quantitaPosseduta: valori.quantitaPosseduta,
stato: valori.stato,
posizione: posizione.length > 0 ? posizione : null,
note: note.length > 0 ? note : null
note: note.length > 0 ? note : null,
gruppoId: valori.gruppoId
} satisfies AggiornaMagazzinoVoceInput)
)
: await firstValueFrom(
@@ -237,7 +292,8 @@ export class Magazzino implements OnInit {
quantitaPosseduta: valori.quantitaPosseduta,
stato: valori.stato,
posizione: posizione.length > 0 ? posizione : undefined,
note: note.length > 0 ? note : undefined
note: note.length > 0 ? note : undefined,
gruppoId: valori.gruppoId
} satisfies AggiungiMagazzinoVoceInput)
);
@@ -246,6 +302,11 @@ export class Magazzino implements OnInit {
return esisteGia ? voci.map((v) => (v.id === voce.id ? voce : v)) : [voce, ...voci];
});
this.formAperto.set(false);
// La voce può essere stata assegnata/riassegnata a un gruppo: l'albero
// "per gruppo" va aggiornato di conseguenza (operazione non frequente,
// un refetch è più semplice e sicuro di un patch manuale dell'albero).
void this.caricaGruppi();
} catch {
this.salvataggioErrore.set('Impossibile salvare la voce di magazzino. Riprova più tardi.');
} finally {
@@ -253,6 +314,25 @@ export class Magazzino implements OnInit {
}
}
async eliminaVoce(voce: MagazzinoVoce): Promise<void> {
this.voceEliminaErroreId.set(null);
this.voceEliminaErroreMsg.set(null);
try {
await firstValueFrom(this.magazzinoApi.eliminaVoce(voce.id));
this.voci.update((voci) => voci.filter((v) => v.id !== voce.id));
// La voce eliminata poteva appartenere a un gruppo: l'albero "per
// gruppo" va aggiornato di conseguenza.
if (voce.gruppoId) {
void this.caricaGruppi();
}
} catch (err) {
this.voceEliminaErroreId.set(voce.id);
this.voceEliminaErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
private async carica(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
@@ -269,4 +349,147 @@ export class Magazzino implements OnInit {
this.loading.set(false);
}
}
// — Gruppi —
private async caricaGruppi(): Promise<void> {
this.gruppiLoading.set(true);
this.gruppiLoadError.set(null);
try {
const gruppi = await firstValueFrom(this.gruppiApi.getGruppi());
this.gruppi.set(gruppi);
} catch {
this.gruppiLoadError.set('Impossibile caricare i gruppi di magazzino. Riprova più tardi.');
} finally {
this.gruppiLoading.set(false);
}
}
apriNuovoGruppo(parentId: string | null = null): void {
this.gruppoEditId.set(null);
this.gruppoNome.set('');
this.gruppoParentId.set(parentId);
this.gruppoSalvataggioErrore.set(null);
this.gruppoFormAperto.set(true);
}
modificaGruppo(gruppo: GruppoMagazzino): void {
this.gruppoEditId.set(gruppo.id);
this.gruppoNome.set(gruppo.nome);
this.gruppoParentId.set(gruppo.parentId);
this.gruppoSalvataggioErrore.set(null);
this.gruppoFormAperto.set(true);
}
annullaGruppo(): void {
this.gruppoEditId.set(null);
this.gruppoNome.set('');
this.gruppoParentId.set(null);
this.gruppoSalvataggioErrore.set(null);
this.gruppoFormAperto.set(false);
}
async salvaGruppo(): Promise<void> {
const nome = this.gruppoNome().trim();
if (!nome) {
return;
}
this.gruppoSalvataggioErrore.set(null);
const editId = this.gruppoEditId();
const parentId = this.gruppoParentId();
try {
if (editId) {
const gruppoEsistente = this.trovaGruppo(editId);
const cambiaParent = gruppoEsistente?.parentId !== parentId;
const salvato = await firstValueFrom(
this.gruppiApi.aggiornaGruppo(editId, { nome, parentId } satisfies AggiornaGruppoMagazzinoInput)
);
if (cambiaParent) {
// Un gruppo che cambia padre "salta" da un punto all'altro
// dell'albero: più sicuro rifare il fetch completo che tentare un
// patch locale della struttura ad albero.
await this.caricaGruppi();
} else {
this.gruppi.update((lista) => this.sostituisciNodo(lista, salvato));
}
} else {
const creato = await firstValueFrom(
this.gruppiApi.creaGruppo({ nome, parentId } satisfies CreaGruppoMagazzinoInput)
);
if (creato.parentId) {
const idPadre = creato.parentId;
this.gruppi.update((lista) =>
lista.map((radice) => (radice.id === idPadre ? { ...radice, figli: [...radice.figli, creato] } : radice))
);
} else {
this.gruppi.update((lista) => [...lista, creato].sort((a, b) => a.nome.localeCompare(b.nome, 'it')));
}
}
this.annullaGruppo();
} catch (err) {
this.gruppoSalvataggioErrore.set(this.estraiMessaggioErrore(err));
}
}
async eliminaGruppo(gruppo: GruppoMagazzino): Promise<void> {
this.gruppoEliminaErroreId.set(null);
this.gruppoEliminaErroreMsg.set(null);
try {
await firstValueFrom(this.gruppiApi.eliminaGruppo(gruppo.id));
// L'eliminazione promuove eventuali figli a radice e "orfana" le voci
// direttamente assegnate al gruppo: un patch locale dell'albero
// rischierebbe di introdurre incoerenze, un refetch è più sicuro.
await this.caricaGruppi();
this.voci.update((voci) => voci.map((v) => (v.gruppoId === gruppo.id ? { ...v, gruppoId: null } : v)));
} catch (err) {
this.gruppoEliminaErroreId.set(gruppo.id);
this.gruppoEliminaErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
private trovaGruppo(id: string): GruppoMagazzino | undefined {
for (const radice of this.gruppi()) {
if (radice.id === id) {
return radice;
}
const figlio = radice.figli.find((f) => f.id === id);
if (figlio) {
return figlio;
}
}
return undefined;
}
private sostituisciNodo(lista: GruppoMagazzino[], aggiornato: GruppoMagazzino): GruppoMagazzino[] {
return lista.map((radice) => {
if (radice.id === aggiornato.id) {
return { ...aggiornato, figli: radice.figli };
}
return { ...radice, figli: radice.figli.map((f) => (f.id === aggiornato.id ? aggiornato : f)) };
});
}
private estraiMessaggioErrore(err: unknown): string {
if (err && typeof err === 'object' && 'error' in err) {
const body = (err as { error?: unknown }).error;
if (
body &&
typeof body === 'object' &&
'message' in body &&
typeof (body as { message?: unknown }).message === 'string'
) {
return (body as { message: string }).message;
}
}
return "Impossibile completare l'operazione.";
}
}