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