Add scouthub-magazzino-fe
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user