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,23 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { MaterialeProposta } from '../catalogo/materiali-api.service';
export type DecisioneProposta = 'approvato' | 'rifiutato';
@Injectable({ providedIn: 'root' })
export class MaterialiModerazioneApiService {
private readonly http = inject(HttpClient);
getProposte(): Observable<MaterialeProposta[]> {
return this.http.get<MaterialeProposta[]>(`${environment.magazzinoApiBaseUrl}/materiali/proposte`);
}
decidiProposta(id: string, decisione: DecisioneProposta): Observable<MaterialeProposta> {
return this.http.patch<MaterialeProposta>(`${environment.magazzinoApiBaseUrl}/materiali/proposte/${id}`, {
decisione
});
}
}
@@ -0,0 +1,17 @@
.moderazione__elenco {
max-width: 640px;
}
.moderazione__proposta {
max-width: none;
}
.moderazione__data {
opacity: 0.6;
}
.moderazione__error {
color: var(--color-accent-800);
font-size: 12px;
margin: var(--space-2) 0 0;
}
@@ -0,0 +1,44 @@
<section class="moderazione om-page">
<h1 class="om-section-title">Moderazione catalogo</h1>
<p class="om-section-sub">Materiali proposti dalle organizzazioni, in attesa di approvazione.</p>
@if (loading()) {
<p class="om-empty">Caricamento proposte…</p>
} @else if (loadError(); as message) {
<p class="moderazione__error" role="alert">{{ message }}</p>
} @else if (proposte().length === 0) {
<p class="om-empty">Nessuna proposta in attesa di approvazione.</p>
} @else {
<div class="om-stack moderazione__elenco">
@for (proposta of proposte(); track proposta.id) {
<div class="card elev-sm moderazione__proposta">
<div class="card-kicker">{{ proposta.categoria }} · {{ proposta.unitaMisura }}</div>
<div class="card-title">{{ proposta.nome }}</div>
<p class="card-body moderazione__data">Proposto il {{ proposta.creatoIl | slice: 0 : 10 }}</p>
<div class="om-row">
<button
type="button"
class="btn btn-primary"
[disabled]="decisioneInCorsoId() === proposta.id"
(click)="decidi(proposta, 'approvato')"
>
Approva
</button>
<button
type="button"
class="btn btn-ghost"
[disabled]="decisioneInCorsoId() === proposta.id"
(click)="decidi(proposta, 'rifiutato')"
>
Rifiuta
</button>
</div>
@if (decisioneErroreId() === proposta.id) {
<p class="moderazione__error" role="alert">Impossibile registrare la decisione. Riprova più tardi.</p>
}
</div>
}
</div>
}
</section>
@@ -0,0 +1,12 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
import { requireModeratoreGuard } from './require-moderatore.guard';
export const MODERAZIONE_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./moderazione').then((m) => m.Moderazione),
canActivate: [requireAuthGuard, requireModeratoreGuard]
}
];
@@ -0,0 +1,120 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { MaterialeProposta } from '../catalogo/materiali-api.service';
import { MaterialiModerazioneApiService } from './materiali-moderazione-api.service';
import { Moderazione } from './moderazione';
describe('Moderazione', () => {
let component: Moderazione;
let fixture: ComponentFixture<Moderazione>;
let moderazioneApi: { getProposte: ReturnType<typeof vi.fn>; decidiProposta: ReturnType<typeof vi.fn> };
const proposte: MaterialeProposta[] = [
{
id: 'prop-1',
nome: 'Fune da bucato',
categoria: 'Campeggio',
unitaMisura: 'pz',
stato: 'proposto',
propostoDaOrgId: 'org-1',
creatoIl: '2026-01-01T00:00:00.000Z'
},
{
id: 'prop-2',
nome: 'Piccone',
categoria: 'Attrezzatura',
unitaMisura: 'pz',
stato: 'proposto',
propostoDaOrgId: 'org-2',
creatoIl: '2026-01-02T00:00:00.000Z'
}
];
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [Moderazione],
providers: [provideRouter([]), { provide: MaterialiModerazioneApiService, useValue: moderazioneApi }]
}).compileComponents();
fixture = TestBed.createComponent(Moderazione);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
moderazioneApi = { getProposte: vi.fn().mockReturnValue(of(proposte)), decidiProposta: vi.fn() };
});
it('mostra la lista delle proposte in attesa', async () => {
await setup();
fixture.detectChanges();
expect(component.loading()).toBe(false);
expect(component.proposte()).toEqual(proposte);
const compiled = fixture.nativeElement as HTMLElement;
const righe = compiled.querySelectorAll('.moderazione__tabella tbody tr');
expect(righe.length).toBe(2);
expect(righe[0].textContent).toContain('Fune da bucato');
});
it('mostra un messaggio se non ci sono proposte in attesa', async () => {
moderazioneApi.getProposte.mockReturnValue(of([]));
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Nessuna proposta in attesa');
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
moderazioneApi.getProposte.mockReturnValue(throwError(() => new Error('network error')));
await setup();
expect(component.loadError()).toBe('Impossibile caricare le proposte in attesa. Riprova più tardi.');
});
describe('approvazione e rifiuto', () => {
beforeEach(async () => {
await setup();
});
it('approva una proposta e la rimuove dalla lista', async () => {
const propostaApprovata: MaterialeProposta = { ...proposte[0], stato: 'approvato' };
moderazioneApi.decidiProposta.mockReturnValue(of(propostaApprovata));
await component.decidi(proposte[0], 'approvato');
expect(moderazioneApi.decidiProposta).toHaveBeenCalledWith('prop-1', 'approvato');
expect(component.proposte().map((p) => p.id)).toEqual(['prop-2']);
});
it('rifiuta una proposta e la rimuove dalla lista', async () => {
const propostaRifiutata: MaterialeProposta = { ...proposte[1], stato: 'rifiutato' };
moderazioneApi.decidiProposta.mockReturnValue(of(propostaRifiutata));
await component.decidi(proposte[1], 'rifiutato');
expect(moderazioneApi.decidiProposta).toHaveBeenCalledWith('prop-2', 'rifiutato');
expect(component.proposte().map((p) => p.id)).toEqual(['prop-1']);
});
it('mostra un messaggio di errore sulla proposta interessata se la decisione fallisce', async () => {
moderazioneApi.decidiProposta.mockReturnValue(throwError(() => new Error('server error')));
await component.decidi(proposte[0], 'approvato');
expect(component.decisioneErroreId()).toBe('prop-1');
expect(component.proposte().map((p) => p.id)).toEqual(['prop-1', 'prop-2']);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Impossibile registrare la decisione');
});
});
});
@@ -0,0 +1,63 @@
import { SlicePipe } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { firstValueFrom } from 'rxjs';
import { MaterialeProposta } from '../catalogo/materiali-api.service';
import { DecisioneProposta, MaterialiModerazioneApiService } from './materiali-moderazione-api.service';
@Component({
selector: 'app-moderazione',
imports: [SlicePipe, MatButtonModule, MatTableModule],
templateUrl: './moderazione.html',
styleUrl: './moderazione.css'
})
export class Moderazione implements OnInit {
private readonly moderazioneApi = inject(MaterialiModerazioneApiService);
readonly displayedColumns = ['nome', 'categoria', 'unitaMisura', 'creatoIl', 'azioni'];
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly proposte = signal<MaterialeProposta[]>([]);
readonly decisioneInCorsoId = signal<string | null>(null);
readonly decisioneErroreId = signal<string | null>(null);
async ngOnInit(): Promise<void> {
await this.caricaProposte();
}
async decidi(proposta: MaterialeProposta, decisione: DecisioneProposta): Promise<void> {
if (this.decisioneInCorsoId()) {
return;
}
this.decisioneErroreId.set(null);
this.decisioneInCorsoId.set(proposta.id);
try {
await firstValueFrom(this.moderazioneApi.decidiProposta(proposta.id, decisione));
this.proposte.update((proposte) => proposte.filter((p) => p.id !== proposta.id));
} catch {
this.decisioneErroreId.set(proposta.id);
} finally {
this.decisioneInCorsoId.set(null);
}
}
private async caricaProposte(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const proposte = await firstValueFrom(this.moderazioneApi.getProposte());
this.proposte.set(proposte);
} catch {
this.loadError.set('Impossibile caricare le proposte in attesa. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,44 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, provideRouter, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import Keycloak from 'keycloak-js';
import { requireModeratoreGuard } from './require-moderatore.guard';
const route = {} as ActivatedRouteSnapshot;
const state = {} as RouterStateSnapshot;
function setup(roles: string[]): void {
const keycloakMock = { tokenParsed: { realm_access: { roles } } };
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: Keycloak, useValue: keycloakMock }]
});
}
describe('requireModeratoreGuard', () => {
it('lascia proseguire la navigazione se l\'utente ha il ruolo moderatore', () => {
setup(['moderatore']);
const result = TestBed.runInInjectionContext(() => requireModeratoreGuard(route, state));
expect(result).toBe(true);
});
it('reindirizza alla home per un utente senza il ruolo moderatore', () => {
setup(['censito']);
const result = TestBed.runInInjectionContext(() => requireModeratoreGuard(route, state));
const router = TestBed.inject(Router);
expect((result as UrlTree).toString()).toBe(router.parseUrl('/').toString());
});
it('reindirizza alla home se il token non ha ruoli', () => {
setup([]);
const result = TestBed.runInInjectionContext(() => requireModeratoreGuard(route, state));
const router = TestBed.inject(Router);
expect((result as UrlTree).toString()).toBe(router.parseUrl('/').toString());
});
});
@@ -0,0 +1,15 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import { MODERATORE_ROLE, extractRealmRoles } from '../core/auth/roles';
// La moderazione (es. catalogo materiali) è riservata al ruolo realm moderatore.
// Da usare insieme a requireAuthGuard: qui assumiamo che l'utente sia già autenticato.
export const requireModeratoreGuard: CanActivateFn = () => {
const router = inject(Router);
const keycloak = inject(Keycloak);
const roles = extractRealmRoles(keycloak.tokenParsed);
return roles.includes(MODERATORE_ROLE) ? true : router.parseUrl('/');
};