Add scouthub-magazzino-fe

This commit is contained in:
Lorenzo Sanesi
2026-07-25 11:51:35 +02:00
parent c34bca869f
commit 7f1d0d5ce3
80 changed files with 12851 additions and 0 deletions
@@ -0,0 +1,52 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type StatoMagazzinoVoce = 'buono' | 'da_riparare' | 'mancante';
export interface MagazzinoVoce {
id: string;
orgId: string;
materialeId: string;
materialeNome: string;
materialeCategoria: string;
quantitaPosseduta: number;
stato: StatoMagazzinoVoce;
posizione: string | null;
note: string | null;
}
export interface AggiungiMagazzinoVoceInput {
materialeId: string;
quantitaPosseduta: number;
stato: StatoMagazzinoVoce;
posizione?: string;
note?: string;
}
export interface AggiornaMagazzinoVoceInput {
materialeId?: string;
quantitaPosseduta?: number;
stato?: StatoMagazzinoVoce;
posizione?: string | null;
note?: string | null;
}
@Injectable({ providedIn: 'root' })
export class MagazzinoApiService {
private readonly http = inject(HttpClient);
getMagazzino(): Observable<MagazzinoVoce[]> {
return this.http.get<MagazzinoVoce[]>(`${environment.magazzinoApiBaseUrl}/magazzino`);
}
aggiungiVoce(input: AggiungiMagazzinoVoceInput): Observable<MagazzinoVoce> {
return this.http.post<MagazzinoVoce>(`${environment.magazzinoApiBaseUrl}/magazzino`, input);
}
aggiornaVoce(id: string, input: AggiornaMagazzinoVoceInput): Observable<MagazzinoVoce> {
return this.http.put<MagazzinoVoce>(`${environment.magazzinoApiBaseUrl}/magazzino/${id}`, input);
}
}
@@ -0,0 +1,83 @@
.magazzino__header {
margin-bottom: var(--space-2);
}
.magazzino__error {
color: var(--color-accent-800);
}
.magazzino__form {
padding: var(--space-4);
margin-bottom: var(--space-5);
}
.magazzino__materiale {
margin-bottom: var(--space-3);
}
.magazzino__materiale-scelto {
display: flex;
align-items: center;
gap: var(--space-3);
}
.magazzino__ricerca-field {
width: 100%;
}
.magazzino__risultati {
list-style: none;
margin: var(--space-2) 0 0;
padding: 0;
}
.magazzino__risultati li {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) 0;
}
.magazzino__nessun-risultato {
color: var(--color-text);
opacity: 0.7;
font-size: 14px;
}
.magazzino__campo-errore {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-accent-800);
}
.magazzino__campi {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-3) var(--space-4);
}
.magazzino__form-azioni {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
}
.magazzino__tabella {
width: 100%;
}
.magazzino-badge--buono {
background: var(--color-accent-2-100);
color: var(--color-accent-2-800);
}
.magazzino-badge--da_riparare {
background: var(--color-accent-100);
color: var(--color-accent-800);
}
.magazzino-badge--mancante {
background: var(--color-neutral-200);
color: var(--color-neutral-800);
}
@@ -0,0 +1,138 @@
<section class="magazzino om-page">
<div class="magazzino__header om-toolbar">
<div>
<h1 class="om-section-title">Giacenze di magazzino</h1>
<p class="om-section-sub" style="margin-bottom:0">Quantità, stato e posizione dei materiali della tua organizzazione.</p>
</div>
@if (!formAperto()) {
<button type="button" class="btn btn-primary" (click)="apriNuovaVoce()">Aggiungi voce</button>
}
</div>
@if (loading()) {
<p class="om-empty">Caricamento magazzino…</p>
} @else if (loadError(); as message) {
<p class="magazzino__error" role="alert">{{ message }}</p>
} @else {
@if (formAperto()) {
<form class="magazzino__form card elev-sm" (submit)="$event.preventDefault(); submit()" novalidate>
<h2>{{ voceInModificaId() ? 'Modifica voce' : 'Nuova voce di magazzino' }}</h2>
<div class="magazzino__materiale">
@if (materialeSelezionato(); as materiale) {
<p class="magazzino__materiale-scelto">
Materiale: <strong>{{ materiale.nome }}</strong>
<button type="button" class="btn btn-ghost" (click)="materialeSelezionato.set(null)">Cambia</button>
</p>
} @else {
<div class="field magazzino__ricerca-field">
<label for="mg-ricerca">Cerca materiale nel catalogo</label>
<input class="input" id="mg-ricerca" [formControl]="ricerca" placeholder="Es. corda" />
</div>
@if (risultatiRicerca().length > 0) {
<ul class="magazzino__risultati">
@for (materiale of risultatiRicerca(); track materiale.id) {
<li>
<span>{{ materiale.nome }} ({{ materiale.categoria }})</span>
<button type="button" class="btn btn-secondary" (click)="selezionaMateriale(materiale)">
Seleziona
</button>
</li>
}
</ul>
} @else if (nessunRisultato()) {
<p class="magazzino__nessun-risultato">
Nessun materiale trovato per "{{ ricerca.value }}".
<a [routerLink]="['/proponi-materiale']" [queryParams]="{ nome: ricerca.value }">
Proponi un nuovo materiale
</a>
</p>
}
}
@if (materialeErrore(); as message) {
<p class="magazzino__error" role="alert">{{ message }}</p>
}
</div>
<div class="magazzino__campi" [formGroup]="form">
<div class="field">
<label for="mg-quantita">Quantità posseduta</label>
<input class="input" id="mg-quantita" type="number" min="0" step="1" formControlName="quantitaPosseduta" />
@if (form.controls.quantitaPosseduta.hasError('required')) {
<p class="magazzino__campo-errore">La quantità è obbligatoria.</p>
} @else if (form.controls.quantitaPosseduta.hasError('min')) {
<p class="magazzino__campo-errore">La quantità non può essere negativa.</p>
}
</div>
<div class="field">
<label for="mg-stato">Stato</label>
<select class="input" id="mg-stato" formControlName="stato">
@for (opzione of stati; track opzione.value) {
<option [value]="opzione.value">{{ opzione.label }}</option>
}
</select>
</div>
<div class="field">
<label for="mg-posizione">Posizione</label>
<input class="input" id="mg-posizione" formControlName="posizione" placeholder="Es. Sede, ripostiglio A" />
</div>
<div class="field">
<label for="mg-note">Note</label>
<input class="input" id="mg-note" formControlName="note" />
</div>
</div>
@if (salvataggioErrore(); as message) {
<p class="magazzino__error" role="alert">{{ message }}</p>
}
<div class="magazzino__form-azioni">
<button type="submit" class="btn btn-primary" [disabled]="salvataggioInCorso()">
{{ salvataggioInCorso() ? 'Salvataggio in corso…' : 'Salva voce' }}
</button>
<button type="button" class="btn btn-ghost" (click)="annulla()">Annulla</button>
</div>
</form>
}
@if (voci().length === 0) {
<p class="om-empty">Nessuna voce in magazzino.</p>
} @else {
<table class="table magazzino__tabella">
<thead>
<tr>
<th>Materiale</th>
<th>Quantità</th>
<th>Stato</th>
<th>Posizione</th>
<th>Note</th>
<th></th>
</tr>
</thead>
<tbody>
@for (voce of voci(); track voce.id) {
<tr>
<td>{{ voce.materialeNome }}</td>
<td>{{ voce.quantitaPosseduta }}</td>
<td>
<span [class]="'tag magazzino-badge magazzino-badge--' + voce.stato">
{{ voce.stato === 'buono' ? 'Buono' : voce.stato === 'da_riparare' ? 'Da riparare' : 'Mancante' }}
</span>
</td>
<td>{{ voce.posizione ?? '—' }}</td>
<td>{{ voce.note ?? '—' }}</td>
<td>
<button type="button" class="btn btn-ghost" (click)="apriModificaVoce(voce)">Modifica</button>
</td>
</tr>
}
</tbody>
</table>
}
}
</section>
@@ -0,0 +1,11 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
export const MAGAZZINO_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./magazzino').then((m) => m.Magazzino),
canActivate: [requireAuthGuard]
}
];
@@ -0,0 +1,248 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../catalogo/materiali-api.service';
import { MagazzinoApiService, MagazzinoVoce } from './magazzino-api.service';
import { Magazzino } from './magazzino';
describe('Magazzino', () => {
let component: Magazzino;
let fixture: ComponentFixture<Magazzino>;
let magazzinoApi: {
getMagazzino: ReturnType<typeof vi.fn>;
aggiungiVoce: ReturnType<typeof vi.fn>;
aggiornaVoce: ReturnType<typeof vi.fn>;
};
let materialiApi: { getMateriali: ReturnType<typeof vi.fn> };
const vociIniziali: MagazzinoVoce[] = [
{
id: 'voce-1',
orgId: 'org-1',
materialeId: 'mat-1',
materialeNome: 'Corda',
materialeCategoria: 'Attrezzatura',
quantitaPosseduta: 10,
stato: 'buono',
posizione: 'Sede',
note: null
},
{
id: 'voce-2',
orgId: 'org-1',
materialeId: 'mat-2',
materialeNome: 'Telo cerato',
materialeCategoria: 'Campeggio',
quantitaPosseduta: 2,
stato: 'mancante',
posizione: null,
note: 'Da riordinare'
}
];
const materialiCatalogo: MaterialePubblico[] = [
{ id: 'mat-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' },
{ id: 'mat-3', nome: 'Torcia', categoria: 'Attrezzatura', unitaMisura: 'pz' }
];
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [Magazzino],
providers: [
provideRouter([]),
{ provide: MagazzinoApiService, useValue: magazzinoApi },
{ provide: MaterialiApiService, useValue: materialiApi }
]
}).compileComponents();
fixture = TestBed.createComponent(Magazzino);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
magazzinoApi = {
getMagazzino: vi.fn().mockReturnValue(of(vociIniziali)),
aggiungiVoce: vi.fn(),
aggiornaVoce: vi.fn()
};
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materialiCatalogo)) };
});
it('mostra la tabella con materiale, quantità, stato, posizione e note', async () => {
await setup();
fixture.detectChanges();
expect(component.loading()).toBe(false);
const compiled = fixture.nativeElement as HTMLElement;
const righe = compiled.querySelectorAll('.magazzino__tabella tbody tr');
expect(righe.length).toBe(2);
expect(righe[0].textContent).toContain('Corda');
expect(righe[0].textContent).toContain('10');
expect(righe[0].textContent).toContain('Sede');
expect(righe[1].textContent).toContain('Da riordinare');
});
it('mostra il badge colorato corretto per ogni stato', async () => {
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const badge0 = compiled.querySelectorAll('.magazzino-badge')[0];
const badge1 = compiled.querySelectorAll('.magazzino-badge')[1];
expect(badge0.classList.contains('magazzino-badge--buono')).toBe(true);
expect(badge0.textContent).toContain('Buono');
expect(badge1.classList.contains('magazzino-badge--mancante')).toBe(true);
expect(badge1.textContent).toContain('Mancante');
});
it('mostra un messaggio se il magazzino è vuoto', async () => {
magazzinoApi.getMagazzino.mockReturnValue(of([]));
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Nessuna voce in magazzino.');
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
magazzinoApi.getMagazzino.mockReturnValue(throwError(() => new Error('network error')));
await setup();
expect(component.loadError()).toBe('Impossibile caricare il magazzino. Riprova più tardi.');
});
describe('ricerca materiale nel form', () => {
beforeEach(async () => {
await setup();
component.apriNuovaVoce();
fixture.detectChanges();
});
it('filtra i risultati per nome', () => {
component.ricerca.setValue('torcia');
fixture.detectChanges();
expect(component.risultatiRicerca().map((m) => m.id)).toEqual(['mat-3']);
});
it('mostra il link per proporre un materiale se la ricerca non trova nulla', () => {
component.ricerca.setValue('materiale inesistente');
fixture.detectChanges();
expect(component.nessunRisultato()).toBe(true);
const compiled = fixture.nativeElement as HTMLElement;
const link = compiled.querySelector('.magazzino__nessun-risultato a') as HTMLAnchorElement;
expect(link).toBeTruthy();
expect(link.getAttribute('href')).toBe('/proponi-materiale?nome=materiale%20inesistente');
});
it('seleziona un materiale dal catalogo e lo mostra come scelto', () => {
component.selezionaMateriale(materialiCatalogo[1]);
fixture.detectChanges();
expect(component.materialeSelezionato()).toEqual({ id: 'mat-3', nome: 'Torcia' });
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.magazzino__materiale-scelto')?.textContent).toContain('Torcia');
});
});
describe('aggiunta di una nuova voce', () => {
beforeEach(async () => {
await setup();
component.apriNuovaVoce();
fixture.detectChanges();
});
it('richiede la selezione di un materiale prima di salvare', async () => {
await component.submit();
expect(component.materialeErrore()).toBe('Seleziona un materiale dal catalogo.');
expect(magazzinoApi.aggiungiVoce).not.toHaveBeenCalled();
});
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: '' });
const nuovaVoce: MagazzinoVoce = {
id: 'voce-3',
orgId: 'org-1',
materialeId: 'mat-3',
materialeNome: 'Torcia',
materialeCategoria: 'Attrezzatura',
quantitaPosseduta: 5,
stato: 'da_riparare',
posizione: 'Garage',
note: null
};
magazzinoApi.aggiungiVoce.mockReturnValue(of(nuovaVoce));
await component.submit();
expect(magazzinoApi.aggiungiVoce).toHaveBeenCalledWith({
materialeId: 'mat-3',
quantitaPosseduta: 5,
stato: 'da_riparare',
posizione: 'Garage',
note: undefined
});
expect(component.voci()[0]).toEqual(nuovaVoce);
expect(component.formAperto()).toBe(false);
});
it('mostra un messaggio di errore se il salvataggio fallisce', async () => {
component.selezionaMateriale(materialiCatalogo[1]);
magazzinoApi.aggiungiVoce.mockReturnValue(throwError(() => new Error('server error')));
await component.submit();
expect(component.salvataggioErrore()).toBe('Impossibile salvare la voce di magazzino. Riprova più tardi.');
expect(component.formAperto()).toBe(true);
});
});
describe('modifica di una voce esistente', () => {
beforeEach(async () => {
await setup();
});
it('precompila il form con i dati della voce selezionata', () => {
component.apriModificaVoce(vociIniziali[1]);
fixture.detectChanges();
expect(component.materialeSelezionato()).toEqual({ id: 'mat-2', nome: 'Telo cerato' });
expect(component.form.value.quantitaPosseduta).toBe(2);
expect(component.form.value.stato).toBe('mancante');
expect(component.form.value.note).toBe('Da riordinare');
});
it('chiama aggiornaVoce con l\'id corretto e azzera i campi vuoti a null', async () => {
component.apriModificaVoce(vociIniziali[1]);
component.form.patchValue({ quantitaPosseduta: 4, stato: 'buono', posizione: '' });
const voceAggiornata: MagazzinoVoce = { ...vociIniziali[1], quantitaPosseduta: 4, stato: 'buono', posizione: null };
magazzinoApi.aggiornaVoce.mockReturnValue(of(voceAggiornata));
await component.submit();
expect(magazzinoApi.aggiornaVoce).toHaveBeenCalledWith('voce-2', {
materialeId: 'mat-2',
quantitaPosseduta: 4,
stato: 'buono',
posizione: null,
note: 'Da riordinare'
});
expect(component.voci().find((v) => v.id === 'voce-2')).toEqual(voceAggiornata);
});
});
});
@@ -0,0 +1,208 @@
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 { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../catalogo/materiali-api.service';
import {
AggiornaMagazzinoVoceInput,
AggiungiMagazzinoVoceInput,
MagazzinoApiService,
MagazzinoVoce,
StatoMagazzinoVoce
} from './magazzino-api.service';
interface MaterialeRiferimento {
id: string;
nome: string;
}
interface OpzioneStato {
value: StatoMagazzinoVoce;
label: string;
}
const STATI: OpzioneStato[] = [
{ value: 'buono', label: 'Buono' },
{ value: 'da_riparare', label: 'Da riparare' },
{ value: 'mancante', label: 'Mancante' }
];
@Component({
selector: 'app-magazzino',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatTableModule
],
templateUrl: './magazzino.html',
styleUrl: './magazzino.css'
})
export class Magazzino implements OnInit {
private readonly magazzinoApi = inject(MagazzinoApiService);
private readonly materialiApi = inject(MaterialiApiService);
readonly displayedColumns = ['materiale', 'quantita', 'stato', 'posizione', 'note', 'azioni'];
readonly stati = STATI;
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly voci = signal<MagazzinoVoce[]>([]);
readonly materialiCatalogo = signal<MaterialePubblico[]>([]);
readonly formAperto = signal(false);
readonly voceInModificaId = signal<string | null>(null);
readonly materialeSelezionato = signal<MaterialeRiferimento | null>(null);
readonly materialeErrore = signal<string | null>(null);
readonly salvataggioInCorso = signal(false);
readonly salvataggioErrore = signal<string | null>(null);
readonly ricerca = new FormControl('', { nonNullable: true });
private readonly ricercaValue = toSignal(this.ricerca.valueChanges, { initialValue: '' });
readonly form = new FormGroup({
quantitaPosseduta: new FormControl(0, {
nonNullable: true,
validators: [Validators.required, Validators.min(0)]
}),
stato: new FormControl<StatoMagazzinoVoce>('buono', { nonNullable: true, validators: [Validators.required] }),
posizione: new FormControl('', { nonNullable: true }),
note: new FormControl('', { nonNullable: true })
});
readonly risultatiRicerca = computed<MaterialePubblico[]>(() => {
const termine = this.ricercaValue().trim().toLowerCase();
if (termine.length === 0) {
return [];
}
return this.materialiCatalogo().filter((materiale) => materiale.nome.toLowerCase().includes(termine));
});
readonly nessunRisultato = computed(
() => this.ricercaValue().trim().length > 0 && this.risultatiRicerca().length === 0
);
async ngOnInit(): Promise<void> {
await this.carica();
}
apriNuovaVoce(): void {
this.voceInModificaId.set(null);
this.materialeSelezionato.set(null);
this.materialeErrore.set(null);
this.salvataggioErrore.set(null);
this.ricerca.setValue('');
this.form.reset({ quantitaPosseduta: 0, stato: 'buono', posizione: '', note: '' });
this.formAperto.set(true);
}
apriModificaVoce(voce: MagazzinoVoce): void {
this.voceInModificaId.set(voce.id);
this.materialeSelezionato.set({ id: voce.materialeId, nome: voce.materialeNome });
this.materialeErrore.set(null);
this.salvataggioErrore.set(null);
this.ricerca.setValue('');
this.form.reset({
quantitaPosseduta: voce.quantitaPosseduta,
stato: voce.stato,
posizione: voce.posizione ?? '',
note: voce.note ?? ''
});
this.formAperto.set(true);
}
annulla(): void {
this.formAperto.set(false);
}
selezionaMateriale(materiale: MaterialePubblico): void {
this.materialeSelezionato.set({ id: materiale.id, nome: materiale.nome });
this.materialeErrore.set(null);
this.ricerca.setValue('');
}
async submit(): Promise<void> {
if (this.salvataggioInCorso()) {
return;
}
const materiale = this.materialeSelezionato();
if (!materiale) {
this.materialeErrore.set('Seleziona un materiale dal catalogo.');
return;
}
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.salvataggioErrore.set(null);
this.salvataggioInCorso.set(true);
const valori = this.form.getRawValue();
const posizione = valori.posizione.trim();
const note = valori.note.trim();
const voceId = this.voceInModificaId();
try {
const voce = voceId
? await firstValueFrom(
this.magazzinoApi.aggiornaVoce(voceId, {
materialeId: materiale.id,
quantitaPosseduta: valori.quantitaPosseduta,
stato: valori.stato,
posizione: posizione.length > 0 ? posizione : null,
note: note.length > 0 ? note : null
} satisfies AggiornaMagazzinoVoceInput)
)
: await firstValueFrom(
this.magazzinoApi.aggiungiVoce({
materialeId: materiale.id,
quantitaPosseduta: valori.quantitaPosseduta,
stato: valori.stato,
posizione: posizione.length > 0 ? posizione : undefined,
note: note.length > 0 ? note : undefined
} satisfies AggiungiMagazzinoVoceInput)
);
this.voci.update((voci) => {
const esisteGia = voci.some((v) => v.id === voce.id);
return esisteGia ? voci.map((v) => (v.id === voce.id ? voce : v)) : [voce, ...voci];
});
this.formAperto.set(false);
} catch {
this.salvataggioErrore.set('Impossibile salvare la voce di magazzino. Riprova più tardi.');
} finally {
this.salvataggioInCorso.set(false);
}
}
private async carica(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const voci = await firstValueFrom(this.magazzinoApi.getMagazzino());
this.voci.set(voci);
const materiali = await firstValueFrom(this.materialiApi.getMateriali());
this.materialiCatalogo.set(materiali);
} catch {
this.loadError.set('Impossibile caricare il magazzino. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}