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