Sistemato magazzino

This commit is contained in:
Lorenzo Sanesi
2026-08-01 03:12:23 +02:00
parent de921abcd6
commit 0fdf395733
128 changed files with 9307 additions and 3076 deletions
+1 -1
View File
@@ -24,7 +24,7 @@
"input": "public"
}
],
"styles": ["src/material-theme.scss", "src/styles.css"]
"styles": ["src/styles.css"]
},
"configurations": {
"production": {
+3
View File
@@ -0,0 +1,3 @@
.app-shell {
min-height: 100vh;
}
+5 -1
View File
@@ -1 +1,5 @@
<router-outlet />
<div class="app-shell">
<app-header />
<router-outlet />
</div>
+2 -6
View File
@@ -15,11 +15,7 @@ export const routes: Routes = [
loadChildren: () => import('./magazzino/magazzino.routes').then((m) => m.MAGAZZINO_ROUTES)
},
{
path: 'eventi',
loadChildren: () => import('./eventi/eventi.routes').then((m) => m.EVENTI_ROUTES)
},
{
path: 'moderazione',
loadChildren: () => import('./moderazione/moderazione.routes').then((m) => m.MODERAZIONE_ROUTES)
path: 'tassonomie',
loadChildren: () => import('./tassonomie/tassonomie.routes').then((m) => m.TASSONOMIE_ROUTES)
}
];
+14
View File
@@ -1,10 +1,24 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import Keycloak from 'keycloak-js';
import { KEYCLOAK_EVENT_SIGNAL, KeycloakEvent, KeycloakEventType } from 'keycloak-angular';
import { App } from './app';
import { routes } from './app.routes';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter(routes),
{ provide: Keycloak, useValue: { authenticated: false, tokenParsed: {} } },
{
provide: KEYCLOAK_EVENT_SIGNAL,
useValue: signal<KeycloakEvent>({ type: KeycloakEventType.Ready, args: true })
}
]
}).compileComponents();
});
+3 -1
View File
@@ -1,9 +1,11 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Header } from './shared/header/header';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
imports: [RouterOutlet, Header],
templateUrl: './app.html',
styleUrl: './app.css'
})
@@ -0,0 +1,37 @@
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
const JSON_STRING_HEADERS = new HttpHeaders({ 'Content-Type': 'application/json' });
export type GruppoAutocomplete = 'tipoEvento' | 'materiale';
export interface AutocompleteObject {
id: string;
nome: string;
gruppo: GruppoAutocomplete;
}
export interface AutocompleteGroup {
label: string;
gruppo: GruppoAutocomplete;
objectsList: AutocompleteObject[];
}
@Injectable({ providedIn: 'root' })
export class AutocompleteApiService {
private readonly http = inject(HttpClient);
search(keyword: string): Observable<AutocompleteGroup[]> {
// Il backend si aspetta come body una stringa JSON nuda (es. "corda"), non un oggetto:
// va serializzata esplicitamente per evitare che HttpClient la invii come text/plain non
// incapsulata (stesso pattern di scouthub-attivita-fe).
return this.http.post<AutocompleteGroup[]>(
`${environment.magazzinoApiBaseUrl}/autocomplete/search`,
JSON.stringify(keyword),
{ headers: JSON_STRING_HEADERS }
);
}
}
@@ -1,24 +1,95 @@
.catalogo-liste-modello__header {
display: flex;
align-items: center;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: 0;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.catalogo-liste-modello__title {
font-size: 30px;
margin: 0;
font-weight: 700;
}
.catalogo-liste-modello__search-link {
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: 10px;
padding: 10px 16px;
color: var(--color-text);
opacity: 0.7;
min-width: 220px;
text-decoration: none;
}
.catalogo-liste-modello__error {
color: var(--color-accent-800);
}
.tipo-evento-gruppo {
margin-bottom: var(--space-8);
.catalogo-liste-modello__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.lista-modello-card {
cursor: pointer;
padding: 18px;
gap: 10px;
border-radius: 14px;
}
.lista-modello-card:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.lista-modello-card__altre {
opacity: 0.7;
font-style: italic;
}
.lista-modello-card__chip {
align-self: flex-start;
padding: 4px 10px;
border-radius: 999px;
background: var(--color-header);
border: 1px solid var(--color-divider);
font-size: 12px;
font-weight: 600;
}
.lista-modello-card__voci {
margin: 0;
padding-left: 1.1rem;
padding-left: 0;
list-style: none;
font-size: 13px;
opacity: 0.8;
display: flex;
flex-direction: column;
gap: 4px;
}
.lista-modello-card__voci li {
display: flex;
align-items: center;
gap: 6px;
}
.lista-modello-card__finta-checkbox {
font-size: 12px;
line-height: 1;
}
.lista-modello-card__voce-testo--spuntata {
text-decoration: line-through;
opacity: 0.6;
}
.lista-modello-card__avviso {
@@ -1,53 +1,84 @@
<section class="catalogo-liste-modello om-page">
<div class="catalogo-liste-modello__header">
<h1 class="om-section-title">Liste modello per tipo evento</h1>
<a class="btn btn-ghost" routerLink="/">Catalogo materiali</a>
<h1 class="catalogo-liste-modello__title">Liste materiali pubblicate</h1>
<a class="catalogo-liste-modello__search-link" routerLink="/cerca-lista">
<span>🔍</span><span>Cerca lista...</span>
</a>
</div>
<p class="om-section-sub">Liste standard, pronte da usare come base per una lista della tua organizzazione.</p>
@if (loading()) {
<p class="om-empty">Caricamento liste modello…</p>
} @else if (loadError(); as message) {
<p class="catalogo-liste-modello__error" role="alert">{{ message }}</p>
} @else if (gruppi().length === 0) {
} @else if (liste().length === 0) {
<p class="om-empty">Nessuna lista modello disponibile.</p>
} @else {
@for (gruppo of gruppi(); track gruppo.tipoEvento.id) {
<section class="tipo-evento-gruppo">
<h2>{{ gruppo.tipoEvento.nome }}</h2>
<div class="tipo-evento-gruppo__liste om-grid">
@for (lista of gruppo.liste; track lista.id) {
<div class="card lista-modello-card">
<div class="card-title">{{ lista.nome }}</div>
<ul class="lista-modello-card__voci card-body">
@for (voce of lista.voci; track voce.materialeId) {
<li>{{ voce.nome }} — {{ voce.quantita }} {{ voce.unitaMisura }}</li>
}
</ul>
@if (!isAuthenticated) {
<p class="lista-modello-card__avviso">Accedi per usare questa lista come base.</p>
<div class="catalogo-liste-modello__grid">
@for (lista of liste(); track lista.id) {
<div
class="card lista-modello-card"
role="link"
tabindex="0"
(click)="onCardClick($event, lista.id)"
(keydown.enter)="apriDettaglio(lista.id)"
>
<div class="card-title">{{ lista.nome }}</div>
@if (lista.tipoEventoNome) {
<span class="lista-modello-card__chip">{{ lista.tipoEventoNome }}</span>
}
<ul class="lista-modello-card__voci card-body">
@for (item of lista.vociPreview; track item.tipo === 'materiale' ? item.materialeId : item.id) {
@if (item.tipo === 'materiale') {
<li>
<span class="lista-modello-card__finta-checkbox">{{
isVoceSpuntata(lista.id, item.materialeId) ? '☑️' : '⬜'
}}</span>
<span [class.lista-modello-card__voce-testo--spuntata]="isVoceSpuntata(lista.id, item.materialeId)">
{{ item.nome }} — {{ item.quantita }} {{ item.unitaMisura }}
</span>
</li>
} @else {
<li>
<span class="lista-modello-card__finta-checkbox">{{
isSottoListaSpuntata(lista.id, item) ? '☑️' : '⬜'
}}</span>
<span [class.lista-modello-card__voce-testo--spuntata]="isSottoListaSpuntata(lista.id, item)">
{{ item.nome }}
</span>
</li>
}
}
@if (lista.vociRestanti > 0) {
<li class="lista-modello-card__altre">
+{{ lista.vociRestanti }} altr{{ lista.vociRestanti === 1 ? 'a' : 'e' }} voc{{
lista.vociRestanti === 1 ? 'e' : 'i'
}}
</li>
}
</ul>
@if (creazioneErroreId() === lista.id) {
<p class="lista-modello-card__errore" role="alert">
Impossibile creare la lista. Riprova più tardi.
</p>
}
@if (!isAuthenticated) {
<p class="lista-modello-card__avviso">Accedi per usare questa lista come base.</p>
}
<button
type="button"
class="btn btn-secondary btn-block"
[disabled]="creazioneInCorsoId() === lista.id"
(click)="usaComeBase(lista.id)"
>
Usa come base
</button>
</div>
@if (creazioneErroreId() === lista.id) {
<p class="lista-modello-card__errore" role="alert">
Impossibile creare la lista. Riprova più tardi.
</p>
}
@if (isAuthenticated) {
<button
type="button"
class="btn btn-secondary btn-block"
[disabled]="creazioneInCorsoId() === lista.id"
(click)="onUsaComeBase(lista.id)"
>
Usa come base
</button>
}
</div>
</section>
}
}
</div>
}
</section>
@@ -3,8 +3,7 @@ import { provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import Keycloak from 'keycloak-js';
import { Lista, ListeApiService } from '../liste-api.service';
import { ListaModello, ListeModelloApiService } from '../liste-modello-api.service';
import { Lista, ListeApiService } from '../../liste/liste-api.service';
import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
import { CatalogoListeModello } from './catalogo-liste-modello';
@@ -12,28 +11,52 @@ describe('CatalogoListeModello', () => {
let component: CatalogoListeModello;
let fixture: ComponentFixture<CatalogoListeModello>;
let tipiEventoApi: { getTipiEvento: ReturnType<typeof vi.fn> };
let listeModelloApi: { getListeModello: ReturnType<typeof vi.fn> };
let listeApi: { creaListaDaModello: ReturnType<typeof vi.fn> };
let listeApi: { getListePubbliche: ReturnType<typeof vi.fn>; creaListaDaFork: ReturnType<typeof vi.fn> };
let keycloak: { authenticated: boolean | undefined; login: ReturnType<typeof vi.fn> };
let router: Router;
const tipiEvento: TipoEvento[] = [
{ id: 'te-1', nome: 'Uscita' },
{ id: 'te-2', nome: 'Campo estivo' }
{ id: 'te-1', nome: 'Uscita', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' },
{ id: 'te-2', nome: 'Campo estivo', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' }
];
const listeModello: ListaModello[] = [
const listeModello: Lista[] = [
{
id: 'lm-1',
nome: 'Uscita di un giorno',
orgId: null,
stato: 'pubblico',
statoModerazione: 'approvato',
tipoEventoId: 'te-1',
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }]
parentId: null,
creataIl: '2026-01-01',
creataDaMe: false,
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }],
sottoListe: [
{
id: 'lm-kit-ps',
nome: 'Kit di pronto soccorso',
voci: [{ materialeId: 'mat-ps-1', nome: 'Garze', unitaMisura: 'pz', quantita: 5 }]
}
]
},
{
id: 'lm-2',
nome: 'Campo estivo standard',
orgId: null,
stato: 'pubblico',
statoModerazione: 'approvato',
tipoEventoId: 'te-2',
voci: [{ materialeId: 'mat-2', nome: 'Telo cerato', unitaMisura: 'pz', quantita: 5 }]
parentId: null,
creataIl: '2026-01-01',
creataDaMe: false,
voci: Array.from({ length: 7 }, (_, i) => ({
materialeId: `mat-${i + 2}`,
nome: `Materiale ${i + 2}`,
unitaMisura: 'pz',
quantita: 1
})),
sottoListe: []
}
];
@@ -45,7 +68,6 @@ describe('CatalogoListeModello', () => {
providers: [
provideRouter([]),
{ provide: TipiEventoApiService, useValue: tipiEventoApi },
{ provide: ListeModelloApiService, useValue: listeModelloApi },
{ provide: ListeApiService, useValue: listeApi },
{ provide: Keycloak, useValue: keycloak }
]
@@ -53,6 +75,7 @@ describe('CatalogoListeModello', () => {
router = TestBed.inject(Router);
vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(CatalogoListeModello);
component = fixture.componentInstance;
@@ -62,12 +85,12 @@ describe('CatalogoListeModello', () => {
}
beforeEach(() => {
sessionStorage.clear();
tipiEventoApi = { getTipiEvento: vi.fn().mockReturnValue(of(tipiEvento)) };
listeModelloApi = { getListeModello: vi.fn().mockReturnValue(of(listeModello)) };
listeApi = { creaListaDaModello: vi.fn() };
listeApi = { getListePubbliche: vi.fn().mockReturnValue(of(listeModello)), creaListaDaFork: vi.fn() };
});
it('mostra le liste modello raggruppate per tipo evento con voci e quantità', async () => {
it('mostra tutte le liste modello insieme, con il tipo evento come chip nella card', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
@@ -75,13 +98,108 @@ describe('CatalogoListeModello', () => {
expect(component.loading()).toBe(false);
const compiled = fixture.nativeElement as HTMLElement;
const gruppi = compiled.querySelectorAll('.tipo-evento-gruppo');
expect(gruppi.length).toBe(2);
expect(gruppi[0].querySelector('h2')?.textContent).toContain('Uscita');
expect(gruppi[0].textContent).toContain('Corda — 2 pz');
const cards = compiled.querySelectorAll('.lista-modello-card');
expect(cards.length).toBe(2);
expect(cards[0].querySelector('.lista-modello-card__chip')?.textContent).toContain('Uscita');
expect(cards[0].textContent).toContain('Corda — 2 pz');
expect(cards[1].querySelector('.lista-modello-card__chip')?.textContent).toContain('Campo estivo');
});
it('mostra solo le prime 5 voci di una lista con più voci, con il conteggio delle restanti', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const cards = compiled.querySelectorAll('.lista-modello-card');
const voci = cards[1].querySelectorAll('.lista-modello-card__voci li:not(.lista-modello-card__altre)');
expect(voci.length).toBe(5);
expect(cards[1].querySelector('.lista-modello-card__altre')?.textContent).toContain('+2 altre voci');
});
it('il click sulla card naviga al dettaglio della lista modello', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const card = compiled.querySelector('.lista-modello-card') as HTMLElement;
card.click();
expect(router.navigate).toHaveBeenCalledWith(['/lista-modello', 'lm-1']);
});
it('le voci non sono interattive: il click su una voce apre comunque il dettaglio', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-modello-card__voci input')).toBeNull();
const voce = compiled.querySelector('.lista-modello-card__voci li') as HTMLElement;
voce.click();
expect(router.navigate).toHaveBeenCalledWith(['/lista-modello', 'lm-1']);
});
it('mostra una finta checkbox per ogni voce, spuntata e barrata se già segnata (es. dal dettaglio)', async () => {
keycloak = { authenticated: false, login: vi.fn() };
sessionStorage.setItem('scouthub-magazzino:lista-modello-voci-spuntate:lm-1', JSON.stringify(['mat-1']));
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const voce = compiled.querySelector('.lista-modello-card__voci li') as HTMLElement;
expect(voce.querySelector('.lista-modello-card__finta-checkbox')?.textContent).toContain('☑️');
expect(voce.querySelector('.lista-modello-card__voce-testo--spuntata')).not.toBeNull();
});
it('mostra una finta checkbox vuota per una voce non segnata', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const voce = compiled.querySelector('.lista-modello-card__voci li') as HTMLElement;
expect(voce.querySelector('.lista-modello-card__finta-checkbox')?.textContent).toContain('⬜');
expect(voce.querySelector('.lista-modello-card__voce-testo--spuntata')).toBeNull();
});
it('mostra una sotto-lista allegata come una voce in più, con solo il titolo (non le sue voci)', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const cards = compiled.querySelectorAll('.lista-modello-card');
const voci = cards[0].querySelectorAll('.lista-modello-card__voci li:not(.lista-modello-card__altre)');
expect(voci.length).toBe(2);
expect(cards[0].textContent).toContain('Kit di pronto soccorso');
expect(cards[0].textContent).not.toContain('Garze');
});
it('mostra la sotto-lista come spuntata solo se tutte le sue voci sono spuntate', async () => {
keycloak = { authenticated: false, login: vi.fn() };
sessionStorage.setItem('scouthub-magazzino:lista-modello-voci-spuntate:lm-1', JSON.stringify(['mat-ps-1']));
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const voci = compiled.querySelectorAll('.lista-modello-card__voci li:not(.lista-modello-card__altre)');
const vocesottoLista = Array.from(voci).find((li) => li.textContent?.includes('Kit di pronto soccorso'));
expect(vocesottoLista?.querySelector('.lista-modello-card__finta-checkbox')?.textContent).toContain('☑️');
});
it('mostra la sotto-lista come non spuntata se manca almeno una delle sue voci', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const voci = compiled.querySelectorAll('.lista-modello-card__voci li:not(.lista-modello-card__altre)');
const vocesottoLista = Array.from(voci).find((li) => li.textContent?.includes('Kit di pronto soccorso'));
expect(vocesottoLista?.querySelector('.lista-modello-card__finta-checkbox')?.textContent).toContain('⬜');
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
@@ -108,21 +226,18 @@ describe('CatalogoListeModello', () => {
expect(avvisi[0].textContent).toContain('Accedi per usare questa lista come base');
});
it('avvia il login e non chiama l\'API al click sul pulsante', async () => {
it('non mostra il pulsante "Usa come base"', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-modello-card button')).toBeNull();
});
it('avvia il login e non chiama l\'API se invocato comunque', async () => {
await component.usaComeBase('lm-1');
expect(keycloak.login).toHaveBeenCalledWith({ redirectUri: window.location.href });
expect(listeApi.creaListaDaModello).not.toHaveBeenCalled();
expect(listeApi.creaListaDaFork).not.toHaveBeenCalled();
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
it('il click sul pulsante nel DOM avvia il login', () => {
const compiled = fixture.nativeElement as HTMLElement;
const button = compiled.querySelector('.lista-modello-card button') as HTMLButtonElement;
button.click();
expect(keycloak.login).toHaveBeenCalled();
});
});
describe('pulsante "Usa come base" — utente autenticato', () => {
@@ -132,25 +247,60 @@ describe('CatalogoListeModello', () => {
fixture.detectChanges();
});
it('il click sul pulsante "Usa come base" non apre anche il dettaglio della card', () => {
listeApi.creaListaDaFork.mockReturnValue(
of({
id: 'lp-1',
nome: 'x',
orgId: null,
stato: 'bozza',
statoModerazione: null,
tipoEventoId: null,
parentId: 'lm-1',
creataIl: '2026-01-01',
voci: [],
sottoListe: []
})
);
const compiled = fixture.nativeElement as HTMLElement;
const button = compiled.querySelector('.lista-modello-card button') as HTMLButtonElement;
button.click();
expect(router.navigate).not.toHaveBeenCalled();
});
it('non mostra l\'avviso di accesso', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-modello-card__avviso')).toBeNull();
});
it('chiama POST /liste/da-modello/:id e reindirizza alla lista privata creata', async () => {
const listaCreata: Lista = { id: 'lista-privata-1', nome: 'Uscita di un giorno', orgId: 'org-1', creataIl: '2026-01-01', voci: [] };
listeApi.creaListaDaModello.mockReturnValue(of(listaCreata));
it('chiama POST /liste/da-fork/:id e reindirizza alla lista privata creata', async () => {
const listaCreata: Lista = {
id: 'lista-privata-1',
nome: 'Uscita di un giorno',
orgId: null,
stato: 'bozza',
statoModerazione: null,
tipoEventoId: null,
parentId: 'lm-1',
creataDaMe: true,
creataIl: '2026-01-01',
voci: [],
sottoListe: []
};
listeApi.creaListaDaFork.mockReturnValue(of(listaCreata));
await component.usaComeBase('lm-1');
expect(keycloak.login).not.toHaveBeenCalled();
expect(listeApi.creaListaDaModello).toHaveBeenCalledWith('lm-1');
expect(listeApi.creaListaDaFork).toHaveBeenCalledWith('lm-1');
expect(router.navigateByUrl).toHaveBeenCalledWith('/liste/lista-privata-1');
expect(component.creazioneInCorsoId()).toBeNull();
});
it('mostra un messaggio di errore sulla lista interessata se la creazione fallisce', async () => {
listeApi.creaListaDaModello.mockReturnValue(throwError(() => new Error('server error')));
listeApi.creaListaDaFork.mockReturnValue(throwError(() => new Error('server error')));
await component.usaComeBase('lm-1');
@@ -5,13 +5,23 @@ import { Router, RouterLink } from '@angular/router';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { ListeApiService } from '../liste-api.service';
import { ListaModello, ListeModelloApiService } from '../liste-modello-api.service';
import { Lista, ListaVoce, ListeApiService, SottoLista } from '../../liste/liste-api.service';
import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
import { VoceCheckStorageService } from '../voce-check-storage.service';
interface GruppoTipoEvento {
tipoEvento: TipoEvento;
liste: ListaModello[];
const VOCI_PREVIEW_LIMIT = 5;
// In home una sotto-lista allegata compare come una voce in più tra le altre (non come un
// chip a parte): questo tipo unisce voci materiale e sotto-liste in un'unica sequenza per la
// preview troncata a VOCI_PREVIEW_LIMIT.
type ItemAnteprima =
| ({ tipo: 'materiale' } & ListaVoce)
| ({ tipo: 'sottoLista' } & SottoLista);
interface ListaModelloVista extends Lista {
tipoEventoNome: string;
vociPreview: ItemAnteprima[];
vociRestanti: number;
}
@Component({
@@ -22,26 +32,32 @@ interface GruppoTipoEvento {
})
export class CatalogoListeModello implements OnInit {
private readonly tipiEventoApi = inject(TipiEventoApiService);
private readonly listeModelloApi = inject(ListeModelloApiService);
private readonly listeApi = inject(ListeApiService);
private readonly keycloak = inject(Keycloak);
private readonly router = inject(Router);
private readonly voceCheckStorage = inject(VoceCheckStorageService);
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly tipiEvento = signal<TipoEvento[]>([]);
readonly listeModello = signal<ListaModello[]>([]);
readonly listeModello = signal<Lista[]>([]);
readonly creazioneInCorsoId = signal<string | null>(null);
readonly creazioneErroreId = signal<string | null>(null);
readonly gruppi = computed<GruppoTipoEvento[]>(() => {
const liste = this.listeModello();
return this.tipiEvento()
.map((tipoEvento) => ({
tipoEvento,
liste: liste.filter((lista) => lista.tipoEventoId === tipoEvento.id)
}))
.filter((gruppo) => gruppo.liste.length > 0);
readonly liste = computed<ListaModelloVista[]>(() => {
const nomiPerTipoEvento = new Map(this.tipiEvento().map((tipoEvento) => [tipoEvento.id, tipoEvento.nome]));
return this.listeModello().map((lista) => {
const items: ItemAnteprima[] = [
...lista.voci.map((voce): ItemAnteprima => ({ tipo: 'materiale', ...voce })),
...lista.sottoListe.map((sottoLista): ItemAnteprima => ({ tipo: 'sottoLista', ...sottoLista }))
];
return {
...lista,
tipoEventoNome: (lista.tipoEventoId && nomiPerTipoEvento.get(lista.tipoEventoId)) ?? '',
vociPreview: items.slice(0, VOCI_PREVIEW_LIMIT),
vociRestanti: Math.max(0, items.length - VOCI_PREVIEW_LIMIT)
};
});
});
get isAuthenticated(): boolean {
@@ -52,6 +68,40 @@ export class CatalogoListeModello implements OnInit {
await this.carica();
}
onCardClick(event: Event, listaModelloId: string): void {
// Le voci nella home non sono più interattive: l'unico elemento cliccabile dentro la
// card, oltre alla card stessa, è il pulsante "Usa come base", che non deve anche aprire
// il dettaglio.
const target = event.target as HTMLElement;
if (target.closest('button')) {
return;
}
this.apriDettaglio(listaModelloId);
}
apriDettaglio(listaModelloId: string): void {
this.router.navigate(['/lista-modello', listaModelloId]);
}
// Le voci in home sono di sola lettura (il click apre il dettaglio): la spunta è solo una
// finta checkbox che riflette lo stato salvato dal dettaglio, non è cliccabile qui.
isVoceSpuntata(listaModelloId: string, materialeId: string): boolean {
return this.voceCheckStorage.isSpuntata(listaModelloId, materialeId);
}
// Una sotto-lista risulta spuntata in home solo se lo sono tutte le sue voci materiale
// (spuntate nel dettaglio, nello stesso namespace della lista padre).
isSottoListaSpuntata(listaModelloId: string, sottoLista: SottoLista): boolean {
return (
sottoLista.voci.length > 0 &&
sottoLista.voci.every((voce) => this.voceCheckStorage.isSpuntata(listaModelloId, voce.materialeId))
);
}
onUsaComeBase(listaModelloId: string): void {
void this.usaComeBase(listaModelloId);
}
async usaComeBase(listaModelloId: string): Promise<void> {
if (!this.isAuthenticated) {
this.keycloak.login({ redirectUri: window.location.href });
@@ -62,7 +112,7 @@ export class CatalogoListeModello implements OnInit {
this.creazioneInCorsoId.set(listaModelloId);
try {
const lista = await firstValueFrom(this.listeApi.creaListaDaModello(listaModelloId));
const lista = await firstValueFrom(this.listeApi.creaListaDaFork(listaModelloId));
this.creazioneInCorsoId.set(null);
await this.router.navigateByUrl(`/liste/${lista.id}`);
} catch {
@@ -77,7 +127,7 @@ export class CatalogoListeModello implements OnInit {
try {
const tipiEvento = await firstValueFrom(this.tipiEventoApi.getTipiEvento());
const listeModello = await firstValueFrom(this.listeModelloApi.getListeModello());
const listeModello = await firstValueFrom(this.listeApi.getListePubbliche());
this.tipiEvento.set(tipiEvento);
this.listeModello.set(listeModello);
} catch {
@@ -6,12 +6,6 @@
margin-bottom: var(--space-4);
}
.catalogo-materiali__area-privata {
margin-bottom: var(--space-5);
padding-left: 0;
padding-right: 0;
}
.catalogo-materiali__filtri {
flex-wrap: wrap;
margin-bottom: var(--space-5);
@@ -4,21 +4,9 @@
<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">Liste modello per evento</a>
<a class="btn btn-ghost" routerLink="/">Liste modello per evento</a>
</div>
@if (isAuthenticated) {
<nav class="nav catalogo-materiali__area-privata">
<a routerLink="/liste">Le tue liste</a>
<a routerLink="/magazzino">Magazzino</a>
<a routerLink="/eventi">Crea evento</a>
<a routerLink="/proponi-materiale">Proponi materiale</a>
@if (isModeratore) {
<a routerLink="/moderazione">Moderazione catalogo</a>
}
</nav>
}
@if (loading()) {
<p class="om-empty">Caricamento catalogo…</p>
} @else if (loadError(); as message) {
@@ -1,7 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import Keycloak from 'keycloak-js';
import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service';
import { CatalogoMateriali } from './catalogo-materiali';
@@ -10,10 +9,6 @@ describe('CatalogoMateriali', () => {
let component: CatalogoMateriali;
let fixture: ComponentFixture<CatalogoMateriali>;
let materialiApi: { getMateriali: ReturnType<typeof vi.fn> };
let keycloak: {
authenticated: boolean | undefined;
tokenParsed?: { realm_access?: { roles?: string[] } };
};
const materiali: MaterialePubblico[] = [
{ id: 'mat-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' },
@@ -24,11 +19,7 @@ describe('CatalogoMateriali', () => {
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [CatalogoMateriali],
providers: [
provideRouter([]),
{ provide: MaterialiApiService, useValue: materialiApi },
{ provide: Keycloak, useValue: keycloak }
]
providers: [provideRouter([]), { provide: MaterialiApiService, useValue: materialiApi }]
}).compileComponents();
fixture = TestBed.createComponent(CatalogoMateriali);
@@ -38,10 +29,6 @@ describe('CatalogoMateriali', () => {
fixture.detectChanges();
}
beforeEach(() => {
keycloak = { authenticated: false };
});
it('mostra tutti i materiali del catalogo dopo il caricamento', async () => {
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
@@ -89,39 +76,4 @@ describe('CatalogoMateriali', () => {
expect(component.loading()).toBe(false);
expect(component.loadError()).toBe('Impossibile caricare il catalogo dei materiali. Riprova più tardi.');
});
describe('area privata e voce di menu "Moderazione catalogo"', () => {
beforeEach(() => {
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
});
it('non mostra la nav dell\'area privata per un utente non autenticato', async () => {
keycloak = { authenticated: false };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.catalogo-materiali__area-privata')).toBeNull();
});
it('mostra la nav dell\'area privata ma non "Moderazione catalogo" per un utente senza il ruolo moderatore', async () => {
keycloak = { authenticated: true, tokenParsed: { realm_access: { roles: ['censito'] } } };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.catalogo-materiali__area-privata')).toBeTruthy();
const link = Array.from(compiled.querySelectorAll('.catalogo-materiali__area-privata a')).find((a) =>
a.textContent?.includes('Moderazione catalogo')
);
expect(link).toBeUndefined();
});
it('mostra "Moderazione catalogo" per un utente con il ruolo moderatore', async () => {
keycloak = { authenticated: true, tokenParsed: { realm_access: { roles: ['moderatore'] } } };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const link = compiled.querySelector('.catalogo-materiali__area-privata a[href="/moderazione"]');
expect(link).toBeTruthy();
});
});
});
@@ -2,10 +2,8 @@ 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 Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles';
import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service';
@Component({
@@ -16,7 +14,6 @@ import { MaterialePubblico, MaterialiApiService } from '../materiali-api.service
})
export class CatalogoMateriali implements OnInit {
private readonly materialiApi = inject(MaterialiApiService);
private readonly keycloak = inject(Keycloak);
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
@@ -34,14 +31,6 @@ export class CatalogoMateriali implements OnInit {
return categoria ? materiali.filter((materiale) => materiale.categoria === categoria) : materiali;
});
get isAuthenticated(): boolean {
return this.keycloak.authenticated ?? false;
}
get isModeratore(): boolean {
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
}
async ngOnInit(): Promise<void> {
await this.caricaMateriali();
}
@@ -1,24 +1,27 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
export const CATALOGO_ROUTES: Routes = [
{
// Pubblica: il catalogo del materiale è consultabile anche da chi non è autenticato.
path: '',
loadComponent: () => import('./catalogo-materiali/catalogo-materiali').then((m) => m.CatalogoMateriali)
},
{
// Pubblica: le liste modello sono consultabili da chi non è autenticato;
// solo la creazione della lista privata a partire dal modello richiede il login.
path: 'liste-modello',
path: '',
loadComponent: () =>
import('./catalogo-liste-modello/catalogo-liste-modello').then((m) => m.CatalogoListeModello)
},
{
// Privata: proporre un materiale richiede un'org sul token (POST /materiali/proposte).
path: 'proponi-materiale',
loadComponent: () => import('./proponi-materiale/proponi-materiale').then((m) => m.ProponiMateriale),
canActivate: [requireAuthGuard]
// 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',
loadComponent: () => import('./cerca-lista/cerca-lista').then((m) => m.CercaLista)
},
{
// Pubblica: il dettaglio di una lista modello è consultabile anche da chi non è autenticato.
path: 'lista-modello/:id',
loadComponent: () =>
import('./lista-modello-dettaglio/lista-modello-dettaglio').then((m) => m.ListaModelloDettaglio)
}
];
@@ -0,0 +1,155 @@
.page-title {
font-size: 28px;
margin: 0 0 18px;
font-weight: 700;
}
.search-input-wrap {
position: relative;
max-width: 640px;
}
.search-input {
width: 100%;
box-sizing: border-box;
padding: 14px 16px;
font-size: 16px;
border-radius: 10px;
border: 1px solid var(--color-divider);
background: var(--color-surface);
color: var(--color-text);
}
.suggestions {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: 10px;
box-shadow: var(--shadow-md);
z-index: 5;
max-height: 360px;
overflow: auto;
}
.suggestion-group-label {
padding: 10px 16px 4px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.suggestion-item {
padding: 10px 16px;
cursor: pointer;
font-size: 15px;
}
.suggestion-item:hover {
background: var(--color-accent-100);
}
.active-filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 16px 0 28px;
}
.filter-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
background: var(--color-accent-200);
border: 1px solid var(--color-accent-300);
font-size: 14px;
font-weight: 600;
color: var(--color-accent-700);
}
.filter-remove {
cursor: pointer;
font-weight: 700;
}
.cerca-lista__error {
color: var(--color-accent-800);
}
.cerca-lista__results-label {
font-weight: 700;
opacity: 0.7;
font-size: 14px;
margin-bottom: var(--space-3);
}
.lista-modello-card {
cursor: pointer;
padding: 18px;
gap: 10px;
border-radius: 14px;
}
.lista-modello-card:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.lista-modello-card__chip {
align-self: flex-start;
padding: 4px 10px;
border-radius: 999px;
background: var(--color-header);
border: 1px solid var(--color-divider);
font-size: 12px;
font-weight: 600;
}
.lista-modello-card__voci {
margin: 0;
padding-left: 0;
list-style: none;
font-size: 13px;
opacity: 0.8;
display: flex;
flex-direction: column;
gap: 4px;
}
.lista-modello-card__voci li {
display: flex;
align-items: center;
gap: 6px;
}
.lista-modello-card__finta-checkbox {
font-size: 12px;
line-height: 1;
}
.lista-modello-card__voce-testo--spuntata {
text-decoration: line-through;
opacity: 0.6;
}
.lista-modello-card__altre {
opacity: 0.7;
font-style: italic;
}
.lista-modello-card__avviso {
color: var(--color-text);
opacity: 0.6;
font-size: 12px;
}
.lista-modello-card__errore {
color: var(--color-accent-800);
font-size: 12px;
}
@@ -0,0 +1,115 @@
<section class="cerca-lista om-page">
<h1 class="page-title">Cerca lista</h1>
<div class="search-input-wrap">
<input
class="search-input"
type="text"
[value]="searchQuery()"
(input)="onQueryChange($event)"
(keydown)="onQueryKeyDown($event)"
placeholder="Cerca per nome, tipo evento, materiale..."
/>
@if (showSuggestions()) {
<div class="suggestions">
@for (group of suggestionGroups(); track group.label) {
<div class="suggestion-group-label">{{ group.label }}</div>
@for (suggestion of group.objectsList; track suggestion.id) {
<div class="suggestion-item" (click)="selectSuggestion(group.gruppo, suggestion)">
{{ suggestion.nome }}
</div>
}
}
</div>
}
</div>
<div class="active-filters">
@for (filtro of activeFilters(); track $index) {
<div class="filter-chip">
<span>{{ filtroLabel(filtro.gruppo) }}: {{ filtro.nome }}</span>
<span class="filter-remove" (click)="removeFiltro($index)">×</span>
</div>
}
</div>
@if (loading()) {
<p class="om-empty">Caricamento liste modello…</p>
} @else if (loadError(); as message) {
<p class="cerca-lista__error" role="alert">{{ message }}</p>
} @else {
<div class="cerca-lista__results-label">{{ resultsLabel() }}</div>
@if (risultati().length === 0) {
<p class="om-empty">Nessuna lista trovata con questi filtri.</p>
} @else {
<div class="om-grid">
@for (lista of risultati(); track lista.id) {
<div
class="card lista-modello-card"
role="link"
tabindex="0"
(click)="onCardClick($event, lista.id)"
(keydown.enter)="apriDettaglio(lista.id)"
>
<div class="card-title">{{ lista.nome }}</div>
@if (lista.tipoEventoNome) {
<span class="lista-modello-card__chip">{{ lista.tipoEventoNome }}</span>
}
<ul class="lista-modello-card__voci card-body">
@for (item of lista.vociPreview; track item.tipo === 'materiale' ? item.materialeId : item.id) {
@if (item.tipo === 'materiale') {
<li>
<span class="lista-modello-card__finta-checkbox">{{
isVoceSpuntata(lista.id, item.materialeId) ? '☑️' : '⬜'
}}</span>
<span [class.lista-modello-card__voce-testo--spuntata]="isVoceSpuntata(lista.id, item.materialeId)">
{{ item.nome }} — {{ item.quantita }} {{ item.unitaMisura }}
</span>
</li>
} @else {
<li>
<span class="lista-modello-card__finta-checkbox">{{
isSottoListaSpuntata(lista.id, item) ? '☑️' : '⬜'
}}</span>
<span [class.lista-modello-card__voce-testo--spuntata]="isSottoListaSpuntata(lista.id, item)">
{{ item.nome }}
</span>
</li>
}
}
@if (lista.vociRestanti > 0) {
<li class="lista-modello-card__altre">
+{{ lista.vociRestanti }} altr{{ lista.vociRestanti === 1 ? 'a' : 'e' }} voc{{
lista.vociRestanti === 1 ? 'e' : 'i'
}}
</li>
}
</ul>
@if (!isAuthenticated) {
<p class="lista-modello-card__avviso">Accedi per usare questa lista come base.</p>
}
@if (creazioneErroreId() === lista.id) {
<p class="lista-modello-card__errore" role="alert">
Impossibile creare la lista. Riprova più tardi.
</p>
}
@if (isAuthenticated) {
<button
type="button"
class="btn btn-secondary btn-block"
[disabled]="creazioneInCorsoId() === lista.id"
(click)="onUsaComeBase(lista.id)"
>
Usa come base
</button>
}
</div>
}
</div>
}
}
</section>
@@ -0,0 +1,231 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import { catchError, debounceTime, distinctUntilChanged, firstValueFrom, of, switchMap } from 'rxjs';
import { AutocompleteApiService, AutocompleteGroup } from '../autocomplete-api.service';
import { Lista, ListaVoce, ListeApiService, SottoLista } from '../../liste/liste-api.service';
import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
import { VoceCheckStorageService } from '../voce-check-storage.service';
const VOCI_PREVIEW_LIMIT = 5;
const AUTOCOMPLETE_DEBOUNCE_MS = 300;
// Stessa preview usata in home: voci materiale e sotto-liste unite in un'unica sequenza
// troncata a VOCI_PREVIEW_LIMIT, per avere le card identiche tra home e ricerca.
type ItemAnteprima =
| ({ tipo: 'materiale' } & ListaVoce)
| ({ tipo: 'sottoLista' } & SottoLista);
interface ListaModelloVista extends Lista {
tipoEventoNome: string;
vociPreview: ItemAnteprima[];
vociRestanti: number;
}
// Stesso modello di ricerca a filtri combinabili di scouthub-attivita-fe (Search + GruppoFiltro/
// SearchObjectDto): l'utente digita, sceglie un suggerimento (o preme Invio per un filtro testuale
// libero) e ottiene un "chip" che si combina in AND con gli altri filtri attivi.
type GruppoFiltro = 'testo' | 'tipoEvento' | 'materiale';
interface FiltroAttivo {
id: string | null;
nome: string;
gruppo: GruppoFiltro;
}
@Component({
selector: 'app-cerca-lista',
imports: [],
templateUrl: './cerca-lista.html',
styleUrl: './cerca-lista.css'
})
export class CercaLista implements OnInit {
private readonly autocompleteApi = inject(AutocompleteApiService);
private readonly tipiEventoApi = inject(TipiEventoApiService);
private readonly listeApi = inject(ListeApiService);
private readonly keycloak = inject(Keycloak);
private readonly router = inject(Router);
private readonly voceCheckStorage = inject(VoceCheckStorageService);
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly tipiEvento = signal<TipoEvento[]>([]);
readonly listeModello = signal<Lista[]>([]);
readonly creazioneInCorsoId = signal<string | null>(null);
readonly creazioneErroreId = signal<string | null>(null);
readonly searchQuery = signal('');
readonly activeFilters = signal<FiltroAttivo[]>([]);
readonly suggestionGroups = signal<AutocompleteGroup[]>([]);
readonly showSuggestions = computed(() => this.suggestionGroups().length > 0);
private readonly liste = computed<ListaModelloVista[]>(() => {
const nomiPerTipoEvento = new Map(this.tipiEvento().map((tipoEvento) => [tipoEvento.id, tipoEvento.nome]));
return this.listeModello().map((lista) => {
const items: ItemAnteprima[] = [
...lista.voci.map((voce): ItemAnteprima => ({ tipo: 'materiale', ...voce })),
...lista.sottoListe.map((sottoLista): ItemAnteprima => ({ tipo: 'sottoLista', ...sottoLista }))
];
return {
...lista,
tipoEventoNome: (lista.tipoEventoId && nomiPerTipoEvento.get(lista.tipoEventoId)) ?? '',
vociPreview: items.slice(0, VOCI_PREVIEW_LIMIT),
vociRestanti: Math.max(0, items.length - VOCI_PREVIEW_LIMIT)
};
});
});
readonly risultati = computed<ListaModelloVista[]>(() => {
const filtri = this.activeFilters();
if (filtri.length === 0) {
return this.liste();
}
return this.liste().filter((lista) => filtri.every((filtro) => this.listaMatchFiltro(lista, filtro)));
});
readonly resultsLabel = computed(() => {
const n = this.risultati().length;
return `${n} risultat${n === 1 ? 'o' : 'i'}`;
});
get isAuthenticated(): boolean {
return this.keycloak.authenticated ?? false;
}
constructor() {
// Come in scouthub-attivita-fe: aspetta che l'utente smetta di scrivere per
// AUTOCOMPLETE_DEBOUNCE_MS e poi interroga un'unica search di autocomplete lato backend
// (non liste già precaricate in memoria).
toObservable(this.searchQuery)
.pipe(
debounceTime(AUTOCOMPLETE_DEBOUNCE_MS),
distinctUntilChanged(),
switchMap((query) => {
const testo = query.trim();
if (!testo) {
return of<AutocompleteGroup[]>([]);
}
return this.autocompleteApi.search(testo).pipe(catchError(() => of<AutocompleteGroup[]>([])));
}),
takeUntilDestroyed()
)
.subscribe((groups) => this.suggestionGroups.set(groups));
}
async ngOnInit(): Promise<void> {
await this.carica();
}
onQueryChange(event: Event): void {
this.searchQuery.set((event.target as HTMLInputElement).value);
}
onQueryKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' && this.searchQuery().trim()) {
this.addFiltro({ id: null, nome: this.searchQuery().trim(), gruppo: 'testo' });
}
}
selectSuggestion(gruppo: GruppoFiltro, suggestion: { id: string; nome: string }): void {
this.addFiltro({ id: suggestion.id, nome: suggestion.nome, gruppo });
}
removeFiltro(index: number): void {
this.activeFilters.update((filtri) => filtri.filter((_, i) => i !== index));
}
filtroLabel(gruppo: GruppoFiltro): string {
const etichette: Record<GruppoFiltro, string> = {
testo: 'Testo',
tipoEvento: 'Tipo evento',
materiale: 'Materiale'
};
return etichette[gruppo];
}
onCardClick(event: Event, listaModelloId: string): void {
// Come in home: l'unico elemento cliccabile dentro la card, oltre alla card stessa, è il
// pulsante "Usa come base", che non deve anche aprire il dettaglio.
const target = event.target as HTMLElement;
if (target.closest('button')) {
return;
}
this.apriDettaglio(listaModelloId);
}
apriDettaglio(listaModelloId: string): void {
this.router.navigate(['/lista-modello', listaModelloId]);
}
isVoceSpuntata(listaModelloId: string, materialeId: string): boolean {
return this.voceCheckStorage.isSpuntata(listaModelloId, materialeId);
}
isSottoListaSpuntata(listaModelloId: string, sottoLista: SottoLista): boolean {
return (
sottoLista.voci.length > 0 &&
sottoLista.voci.every((voce) => this.voceCheckStorage.isSpuntata(listaModelloId, voce.materialeId))
);
}
onUsaComeBase(listaModelloId: string): void {
void this.usaComeBase(listaModelloId);
}
async usaComeBase(listaModelloId: string): Promise<void> {
if (!this.isAuthenticated) {
this.keycloak.login({ redirectUri: window.location.href });
return;
}
this.creazioneErroreId.set(null);
this.creazioneInCorsoId.set(listaModelloId);
try {
const lista = await firstValueFrom(this.listeApi.creaListaDaFork(listaModelloId));
this.creazioneInCorsoId.set(null);
await this.router.navigateByUrl(`/liste/${lista.id}`);
} catch {
this.creazioneInCorsoId.set(null);
this.creazioneErroreId.set(listaModelloId);
}
}
private addFiltro(filtro: FiltroAttivo): void {
this.activeFilters.update((filtri) => [...filtri, filtro]);
this.searchQuery.set('');
}
private listaMatchFiltro(lista: ListaModelloVista, filtro: FiltroAttivo): boolean {
switch (filtro.gruppo) {
case 'tipoEvento':
return lista.tipoEventoId === filtro.id;
case 'materiale':
return lista.voci.some((voce) => voce.materialeId === filtro.id);
case 'testo': {
const testo = filtro.nome.toLowerCase();
return lista.nome.toLowerCase().includes(testo) || lista.voci.some((voce) => voce.nome.toLowerCase().includes(testo));
}
}
}
private async carica(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const tipiEvento = await firstValueFrom(this.tipiEventoApi.getTipiEvento());
const listeModello = await firstValueFrom(this.listeApi.getListePubbliche());
this.tipiEvento.set(tipiEvento);
this.listeModello.set(listeModello);
} catch {
this.loadError.set('Impossibile caricare le liste modello. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,69 @@
.lista-modello-dettaglio {
max-width: 640px;
}
.lista-modello-dettaglio__titolo {
font-size: 32px;
margin: 0 0 var(--space-3);
font-weight: 700;
}
.lista-modello-dettaglio__chip {
display: inline-flex;
padding: 4px 10px;
border-radius: 999px;
background: var(--color-accent-100);
color: var(--color-accent-800);
font-size: 12px;
font-weight: 600;
margin-bottom: var(--space-5);
}
.lista-modello-dettaglio__voci {
margin: 0 0 var(--space-5);
padding-left: 0;
list-style: none;
font-size: 15px;
display: flex;
flex-direction: column;
gap: 8px;
}
.lista-modello-dettaglio__voce {
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
}
.lista-modello-dettaglio__voce-testo--spuntata {
text-decoration: line-through;
opacity: 0.6;
}
.lista-modello-dettaglio__sotto-lista {
margin: 0 0 var(--space-5);
}
.lista-modello-dettaglio__sotto-lista > .lista-modello-dettaglio__voce {
margin-bottom: var(--space-2);
}
.lista-modello-dettaglio__sotto-lista-titolo {
font-size: 17px;
font-weight: 600;
}
.lista-modello-dettaglio__voci--indentate {
padding-left: 28px;
}
.lista-modello-dettaglio__avviso {
opacity: 0.6;
font-size: 13px;
}
.lista-modello-dettaglio__errore {
color: var(--color-accent-800);
font-size: 13px;
}
@@ -0,0 +1,80 @@
@if (lista(); as l) {
<div class="lista-modello-dettaglio om-page">
<h1 class="lista-modello-dettaglio__titolo">{{ l.nome }}</h1>
@if (tipoEventoNome()) {
<span class="lista-modello-dettaglio__chip">{{ tipoEventoNome() }}</span>
}
<ul class="lista-modello-dettaglio__voci">
@for (voce of l.voci; track voce.materialeId) {
<li>
<label class="lista-modello-dettaglio__voce">
<input
type="checkbox"
[checked]="isVoceSpuntata(voce.materialeId)"
(change)="toggleVoce(voce.materialeId)"
/>
<span [class.lista-modello-dettaglio__voce-testo--spuntata]="isVoceSpuntata(voce.materialeId)">
{{ voce.nome }} — {{ voce.quantita }} {{ voce.unitaMisura }}
</span>
</label>
</li>
}
</ul>
@for (sottoLista of l.sottoListe; track sottoLista.id) {
<div class="lista-modello-dettaglio__sotto-lista">
<label class="lista-modello-dettaglio__voce">
<input
type="checkbox"
[checked]="isSottoListaSpuntata(sottoLista)"
(change)="toggleSottoLista(sottoLista)"
/>
<span
class="lista-modello-dettaglio__sotto-lista-titolo"
[class.lista-modello-dettaglio__voce-testo--spuntata]="isSottoListaSpuntata(sottoLista)"
>
{{ sottoLista.nome }}
</span>
</label>
<ul class="lista-modello-dettaglio__voci lista-modello-dettaglio__voci--indentate">
@for (voce of sottoLista.voci; track voce.materialeId) {
<li>
<label class="lista-modello-dettaglio__voce">
<input
type="checkbox"
[checked]="isVoceSpuntata(voce.materialeId)"
(change)="toggleVoce(voce.materialeId)"
/>
<span [class.lista-modello-dettaglio__voce-testo--spuntata]="isVoceSpuntata(voce.materialeId)">
{{ voce.nome }} — {{ voce.quantita }} {{ voce.unitaMisura }}
</span>
</label>
</li>
}
</ul>
</div>
}
@if (!isAuthenticated) {
<p class="lista-modello-dettaglio__avviso">Accedi per usare questa lista come base.</p>
}
@if (creazioneErrore()) {
<p class="lista-modello-dettaglio__errore" role="alert">
Impossibile creare la lista. Riprova più tardi.
</p>
}
@if (isAuthenticated) {
<button type="button" class="btn btn-primary" [disabled]="creazioneInCorso()" (click)="usaComeBase()">
Usa come base
</button>
}
</div>
}
@if (!lista() && !loading()) {
<div class="lista-modello-dettaglio om-page om-empty">Lista non trovata.</div>
}
@@ -0,0 +1,212 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import Keycloak from 'keycloak-js';
import { Lista, ListeApiService } from '../../liste/liste-api.service';
import { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
import { ListaModelloDettaglio } from './lista-modello-dettaglio';
describe('ListaModelloDettaglio', () => {
let component: ListaModelloDettaglio;
let fixture: ComponentFixture<ListaModelloDettaglio>;
let tipiEventoApi: { getTipiEvento: ReturnType<typeof vi.fn> };
let listeApi: { getListaPubblica: ReturnType<typeof vi.fn>; creaListaDaFork: ReturnType<typeof vi.fn> };
let keycloak: { authenticated: boolean | undefined; login: ReturnType<typeof vi.fn> };
let router: Router;
const tipiEvento: TipoEvento[] = [
{ id: 'te-1', nome: 'Uscita', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' }
];
async function flushUntil(predicate: () => boolean, maxTentativi = 20): Promise<void> {
for (let tentativi = 0; !predicate() && tentativi < maxTentativi; tentativi++) {
await Promise.resolve();
fixture.detectChanges();
}
}
const lista: Lista = {
id: 'lm-1',
nome: 'Uscita di un giorno',
orgId: null,
stato: 'pubblico',
statoModerazione: 'approvato',
tipoEventoId: 'te-1',
parentId: null,
creataIl: '2026-01-01',
creataDaMe: false,
voci: [
{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 },
{ materialeId: 'mat-2', nome: 'Torcia', unitaMisura: 'pz', quantita: 1 }
],
sottoListe: [
{
id: 'lm-kit-ps',
nome: 'Kit di pronto soccorso',
voci: [
{ materialeId: 'mat-3', nome: 'Garze', unitaMisura: 'pz', quantita: 5 },
{ materialeId: 'mat-4', nome: 'Cerotti', unitaMisura: 'pz', quantita: 10 }
]
}
]
};
async function setup(id: string | null = 'lm-1'): Promise<void> {
await TestBed.configureTestingModule({
imports: [ListaModelloDettaglio],
providers: [
provideRouter([]),
{ provide: TipiEventoApiService, useValue: tipiEventoApi },
{ provide: ListeApiService, useValue: listeApi },
{ provide: Keycloak, useValue: keycloak },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => id } } } }
]
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true);
fixture = TestBed.createComponent(ListaModelloDettaglio);
component = fixture.componentInstance;
fixture.detectChanges();
// whenStable() non intercetta in modo affidabile la Promise avviata da ngOnInit in questo
// componente (a differenza di altri con lo stesso pattern) — attendiamo esplicitamente che
// il caricamento finisca invece di fidarci del tracking automatico dello zone di test.
await flushUntil(() => !component.loading());
}
beforeEach(() => {
sessionStorage.clear();
tipiEventoApi = { getTipiEvento: vi.fn().mockReturnValue(of(tipiEvento)) };
listeApi = { getListaPubblica: vi.fn().mockReturnValue(of(lista)), creaListaDaFork: vi.fn() };
});
it('mostra nome, chip tipo evento e voci della lista', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-modello-dettaglio__titolo')?.textContent).toContain('Uscita di un giorno');
expect(compiled.querySelector('.lista-modello-dettaglio__chip')?.textContent).toContain('Uscita');
expect(compiled.textContent).toContain('Corda — 2 pz');
expect(compiled.textContent).toContain('Torcia — 1 pz');
});
it('mostra le sotto-liste con titolo e voci indentate', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-modello-dettaglio__sotto-lista-titolo')?.textContent).toContain(
'Kit di pronto soccorso'
);
expect(compiled.textContent).toContain('Garze — 5 pz');
expect(compiled.querySelector('.lista-modello-dettaglio__voci--indentate')).not.toBeNull();
});
it('il checkbox della sotto-lista non è spuntato se manca almeno una voce interna', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const checkboxSottoLista = compiled.querySelector(
'.lista-modello-dettaglio__sotto-lista > .lista-modello-dettaglio__voce input'
) as HTMLInputElement;
expect(checkboxSottoLista.checked).toBe(false);
});
it('cliccando il checkbox della sotto-lista si spuntano automaticamente tutte le sue voci', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const checkboxSottoLista = compiled.querySelector(
'.lista-modello-dettaglio__sotto-lista > .lista-modello-dettaglio__voce input'
) as HTMLInputElement;
checkboxSottoLista.click();
fixture.detectChanges();
expect(component.isVoceSpuntata('mat-3')).toBe(true);
expect(component.isVoceSpuntata('mat-4')).toBe(true);
const voci = compiled.querySelectorAll('.lista-modello-dettaglio__voci--indentate input');
voci.forEach((input) => expect((input as HTMLInputElement).checked).toBe(true));
});
it('cliccando di nuovo il checkbox della sotto-lista (tutta spuntata) la despunta tutta', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const checkboxSottoLista = compiled.querySelector(
'.lista-modello-dettaglio__sotto-lista > .lista-modello-dettaglio__voce input'
) as HTMLInputElement;
checkboxSottoLista.click();
fixture.detectChanges();
checkboxSottoLista.click();
fixture.detectChanges();
expect(component.isVoceSpuntata('mat-3')).toBe(false);
expect(component.isVoceSpuntata('mat-4')).toBe(false);
});
it('mostra "Lista non trovata" se il caricamento fallisce', async () => {
listeApi = { getListaPubblica: vi.fn().mockReturnValue(throwError(() => new Error('404'))), creaListaDaFork: vi.fn() };
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Lista non trovata');
});
it('spunta una voce al click sulla checkbox e barra il testo, persistendo in sessionStorage', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const checkbox = compiled.querySelector('.lista-modello-dettaglio__voce input') as HTMLInputElement;
checkbox.click();
fixture.detectChanges();
const testo = compiled.querySelector('.lista-modello-dettaglio__voce span');
expect(testo?.classList.contains('lista-modello-dettaglio__voce-testo--spuntata')).toBe(true);
expect(sessionStorage.getItem('scouthub-magazzino:lista-modello-voci-spuntate:lm-1')).toContain('mat-1');
});
it('nasconde il pulsante "Usa come base" se non autenticato', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('button')).toBeNull();
expect(compiled.textContent).toContain('Accedi per usare questa lista come base');
});
it('crea la lista privata e reindirizza quando autenticato', async () => {
keycloak = { authenticated: true, login: vi.fn() };
const listaCreata: Lista = {
id: 'lp-1',
nome: 'Uscita di un giorno',
orgId: null,
stato: 'bozza',
statoModerazione: null,
tipoEventoId: null,
parentId: 'lm-1',
creataIl: '2026-01-01',
creataDaMe: true,
voci: [],
sottoListe: []
};
listeApi.creaListaDaFork.mockReturnValue(of(listaCreata));
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const button = compiled.querySelector('button') as HTMLButtonElement;
button.click();
await flushUntil(() => (router.navigateByUrl as ReturnType<typeof vi.fn>).mock.calls.length > 0);
expect(listeApi.creaListaDaFork).toHaveBeenCalledWith('lm-1');
expect(router.navigateByUrl).toHaveBeenCalledWith('/liste/lp-1');
});
});
@@ -0,0 +1,116 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import { firstValueFrom } from 'rxjs';
import { Lista, ListeApiService, SottoLista } from '../../liste/liste-api.service';
import { TipiEventoApiService } from '../tipi-evento-api.service';
import { VoceCheckStorageService } from '../voce-check-storage.service';
@Component({
selector: 'app-lista-modello-dettaglio',
imports: [],
templateUrl: './lista-modello-dettaglio.html',
styleUrl: './lista-modello-dettaglio.css'
})
export class ListaModelloDettaglio implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly tipiEventoApi = inject(TipiEventoApiService);
private readonly listeApi = inject(ListeApiService);
private readonly keycloak = inject(Keycloak);
private readonly voceCheckStorage = inject(VoceCheckStorageService);
readonly loading = signal(true);
readonly lista = signal<Lista | null>(null);
readonly tipoEventoNome = signal('');
readonly creazioneInCorso = signal(false);
readonly creazioneErrore = signal(false);
get isAuthenticated(): boolean {
return this.keycloak.authenticated ?? false;
}
async ngOnInit(): Promise<void> {
const id = this.route.snapshot.paramMap.get('id');
if (!id) {
this.loading.set(false);
return;
}
await this.carica(id);
}
isVoceSpuntata(materialeId: string): boolean {
const listaId = this.lista()?.id;
return !!listaId && this.voceCheckStorage.isSpuntata(listaId, materialeId);
}
toggleVoce(materialeId: string): void {
const listaId = this.lista()?.id;
if (!listaId) {
return;
}
this.voceCheckStorage.toggle(listaId, materialeId);
}
// Una sotto-lista è spuntata solo se lo sono tutte le sue voci materiale.
isSottoListaSpuntata(sottoLista: SottoLista): boolean {
const listaId = this.lista()?.id;
return (
!!listaId &&
sottoLista.voci.length > 0 &&
sottoLista.voci.every((voce) => this.voceCheckStorage.isSpuntata(listaId, voce.materialeId))
);
}
// Il checkbox sul nome della sotto-lista spunta/despunta in blocco tutte le sue voci.
toggleSottoLista(sottoLista: SottoLista): void {
const listaId = this.lista()?.id;
if (!listaId) {
return;
}
const nuovoStato = !this.isSottoListaSpuntata(sottoLista);
for (const voce of sottoLista.voci) {
this.voceCheckStorage.setSpuntata(listaId, voce.materialeId, nuovoStato);
}
}
async usaComeBase(): Promise<void> {
const lista = this.lista();
if (!lista) {
return;
}
if (!this.isAuthenticated) {
this.keycloak.login({ redirectUri: window.location.href });
return;
}
this.creazioneErrore.set(false);
this.creazioneInCorso.set(true);
try {
const nuovaLista = await firstValueFrom(this.listeApi.creaListaDaFork(lista.id));
this.creazioneInCorso.set(false);
await this.router.navigateByUrl(`/liste/${nuovaLista.id}`);
} catch {
this.creazioneInCorso.set(false);
this.creazioneErrore.set(true);
}
}
private async carica(id: string): Promise<void> {
this.loading.set(true);
try {
const lista = await firstValueFrom(this.listeApi.getListaPubblica(id));
const tipiEvento = await firstValueFrom(this.tipiEventoApi.getTipiEvento());
this.lista.set(lista);
this.tipoEventoNome.set(tipiEvento.find((tipoEvento) => tipoEvento.id === lista.tipoEventoId)?.nome ?? '');
} catch {
this.lista.set(null);
} finally {
this.loading.set(false);
}
}
}
@@ -1,29 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface ListaVoce {
materialeId: string;
nome: string;
unitaMisura: string;
quantita: number;
}
export interface Lista {
id: string;
nome: string;
orgId: string;
creataIl: string;
voci: ListaVoce[];
}
@Injectable({ providedIn: 'root' })
export class ListeApiService {
private readonly http = inject(HttpClient);
creaListaDaModello(listaModelloId: string): Observable<Lista> {
return this.http.post<Lista>(`${environment.magazzinoApiBaseUrl}/liste/da-modello/${listaModelloId}`, {});
}
}
@@ -1,28 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface ListaModelloVoce {
materialeId: string;
nome: string;
unitaMisura: string;
quantita: number;
}
export interface ListaModello {
id: string;
nome: string;
tipoEventoId: string;
voci: ListaModelloVoce[];
}
@Injectable({ providedIn: 'root' })
export class ListeModelloApiService {
private readonly http = inject(HttpClient);
getListeModello(): Observable<ListaModello[]> {
return this.http.get<ListaModello[]>(`${environment.magazzinoApiBaseUrl}/liste-modello`);
}
}
@@ -1,4 +1,4 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
@@ -17,6 +17,12 @@ export interface ProponiMaterialeInput {
unitaMisura: string;
}
export interface MaterialeInput {
nome: string;
categoria: string;
unitaMisura: string;
}
export interface MaterialeProposta {
id: string;
nome: string;
@@ -31,11 +37,24 @@ export interface MaterialeProposta {
export class MaterialiApiService {
private readonly http = inject(HttpClient);
getMateriali(): Observable<MaterialePubblico[]> {
return this.http.get<MaterialePubblico[]>(`${environment.magazzinoApiBaseUrl}/materiali`);
getMateriali(nome?: string): Observable<MaterialePubblico[]> {
const params = nome ? new HttpParams().set('nome', nome) : undefined;
return this.http.get<MaterialePubblico[]>(`${environment.magazzinoApiBaseUrl}/materiali`, { params });
}
proponiMateriale(input: ProponiMaterialeInput): Observable<MaterialeProposta> {
return this.http.post<MaterialeProposta>(`${environment.magazzinoApiBaseUrl}/materiali/proposte`, input);
}
creaMateriale(input: MaterialeInput): Observable<MaterialePubblico> {
return this.http.post<MaterialePubblico>(`${environment.magazzinoApiBaseUrl}/materiali`, input);
}
aggiornaMateriale(id: string, input: MaterialeInput): Observable<MaterialePubblico> {
return this.http.put<MaterialePubblico>(`${environment.magazzinoApiBaseUrl}/materiali/${id}`, input);
}
eliminaMateriale(id: string): Observable<void> {
return this.http.delete<void>(`${environment.magazzinoApiBaseUrl}/materiali/${id}`);
}
}
@@ -1,21 +0,0 @@
.proponi-materiale {
max-width: 520px;
}
.proponi-materiale__field {
width: 100%;
}
.proponi-materiale__campo-errore {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-accent-800);
}
.proponi-materiale__error {
color: var(--color-accent-800);
}
.proponi-materiale__successo {
margin-bottom: var(--space-4);
}
@@ -1,58 +0,0 @@
<section class="proponi-materiale om-page">
<h1 class="om-section-title">Proponi un nuovo materiale</h1>
<p class="om-section-sub">
La proposta entra in coda di moderazione: il materiale verrà aggiunto al catalogo pubblico
solo dopo l'approvazione.
</p>
@if (successo()) {
<p class="tag tag-accent-2 proponi-materiale__successo" role="status">
Proposta inviata: in attesa di moderazione
</p>
}
<form class="proponi-materiale__form om-stack" (submit)="$event.preventDefault(); submit()" novalidate>
<div class="field proponi-materiale__field">
<label for="pm-nome">Nome del materiale</label>
<input class="input" id="pm-nome" [formControl]="nome" placeholder="Es. Fune da bucato" />
@if (nome.hasError('required')) {
<p class="proponi-materiale__campo-errore">Il nome è obbligatorio.</p>
} @else if (nome.hasError('minlength')) {
<p class="proponi-materiale__campo-errore">Il nome deve avere almeno 2 caratteri.</p>
} @else if (nome.hasError('maxlength')) {
<p class="proponi-materiale__campo-errore">Il nome non può superare i 100 caratteri.</p>
}
</div>
<div class="field proponi-materiale__field">
<label for="pm-categoria">Categoria</label>
<input class="input" id="pm-categoria" [formControl]="categoria" placeholder="Es. Attrezzatura" />
@if (categoria.hasError('required')) {
<p class="proponi-materiale__campo-errore">La categoria è obbligatoria.</p>
} @else if (categoria.hasError('maxlength')) {
<p class="proponi-materiale__campo-errore">La categoria non può superare i 100 caratteri.</p>
}
</div>
<div class="field proponi-materiale__field">
<label for="pm-unita">Unità di misura</label>
<input class="input" id="pm-unita" [formControl]="unitaMisura" placeholder="Es. pz" />
@if (unitaMisura.hasError('required')) {
<p class="proponi-materiale__campo-errore">L'unità di misura è obbligatoria.</p>
} @else if (unitaMisura.hasError('maxlength')) {
<p class="proponi-materiale__campo-errore">L'unità di misura non può superare i 20 caratteri.</p>
}
</div>
@if (errorMessage(); as message) {
<p class="proponi-materiale__error" role="alert">{{ message }}</p>
}
<div class="om-row">
<button type="submit" class="btn btn-primary" [disabled]="submitting()">
{{ submitting() ? 'Invio in corso…' : 'Invia proposta' }}
</button>
<a class="btn btn-ghost" routerLink="/">Annulla</a>
</div>
</form>
</section>
@@ -1,96 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { MaterialeProposta, MaterialiApiService } from '../materiali-api.service';
import { ProponiMateriale } from './proponi-materiale';
describe('ProponiMateriale', () => {
let component: ProponiMateriale;
let fixture: ComponentFixture<ProponiMateriale>;
let materialiApi: { proponiMateriale: ReturnType<typeof vi.fn> };
async function setup(nomeSuggerito: string | null = null): Promise<void> {
materialiApi = { proponiMateriale: vi.fn() };
await TestBed.configureTestingModule({
imports: [ProponiMateriale],
providers: [
provideRouter([]),
{ provide: MaterialiApiService, useValue: materialiApi },
{
provide: ActivatedRoute,
useValue: {
snapshot: { queryParamMap: convertToParamMap(nomeSuggerito ? { nome: nomeSuggerito } : {}) }
}
}
]
}).compileComponents();
fixture = TestBed.createComponent(ProponiMateriale);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
it('precompila il nome dal query param', async () => {
await setup('Fune da bucato');
expect(component.nome.value).toBe('Fune da bucato');
});
it('non chiama l\'API se il form non è valido e marca i controlli come touched', async () => {
await setup();
component.nome.setValue('');
await component.submit();
expect(materialiApi.proponiMateriale).not.toHaveBeenCalled();
expect(component.nome.touched).toBe(true);
expect(component.categoria.touched).toBe(true);
expect(component.unitaMisura.touched).toBe(true);
});
it('invia la proposta e mostra un messaggio di successo', async () => {
await setup();
component.nome.setValue('Fune da bucato');
component.categoria.setValue('Campeggio');
component.unitaMisura.setValue('pz');
const proposta: MaterialeProposta = {
id: 'mat-nuovo',
nome: 'Fune da bucato',
categoria: 'Campeggio',
unitaMisura: 'pz',
stato: 'proposto',
propostoDaOrgId: 'org-1',
creatoIl: '2026-01-01T00:00:00.000Z'
};
materialiApi.proponiMateriale.mockReturnValue(of(proposta));
await component.submit();
expect(materialiApi.proponiMateriale).toHaveBeenCalledWith({
nome: 'Fune da bucato',
categoria: 'Campeggio',
unitaMisura: 'pz'
});
expect(component.successo()).toBe(true);
expect(component.submitting()).toBe(false);
expect(component.nome.value).toBe('');
});
it('mostra un messaggio di errore se l\'invio fallisce', async () => {
await setup();
component.nome.setValue('Fune da bucato');
component.categoria.setValue('Campeggio');
component.unitaMisura.setValue('pz');
materialiApi.proponiMateriale.mockReturnValue(throwError(() => new Error('server error')));
await component.submit();
expect(component.errorMessage()).toBe('Impossibile inviare la proposta. Riprova più tardi.');
expect(component.successo()).toBe(false);
});
});
@@ -1,72 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { FormControl, 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 { ActivatedRoute, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { MaterialiApiService } from '../materiali-api.service';
@Component({
selector: 'app-proponi-materiale',
imports: [ReactiveFormsModule, RouterLink, MatButtonModule, MatFormFieldModule, MatInputModule],
templateUrl: './proponi-materiale.html',
styleUrl: './proponi-materiale.css'
})
export class ProponiMateriale {
private readonly route = inject(ActivatedRoute);
private readonly materialiApi = inject(MaterialiApiService);
readonly nome = new FormControl(this.route.snapshot.queryParamMap.get('nome') ?? '', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(2), Validators.maxLength(100)]
});
readonly categoria = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(100)]
});
readonly unitaMisura = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(20)]
});
readonly submitting = signal(false);
readonly errorMessage = signal<string | null>(null);
readonly successo = signal(false);
async submit(): Promise<void> {
if (this.submitting()) {
return;
}
if (this.nome.invalid || this.categoria.invalid || this.unitaMisura.invalid) {
this.nome.markAsTouched();
this.categoria.markAsTouched();
this.unitaMisura.markAsTouched();
return;
}
this.errorMessage.set(null);
this.successo.set(false);
this.submitting.set(true);
try {
await firstValueFrom(
this.materialiApi.proponiMateriale({
nome: this.nome.value.trim(),
categoria: this.categoria.value.trim(),
unitaMisura: this.unitaMisura.value.trim()
})
);
this.submitting.set(false);
this.successo.set(true);
this.nome.reset('');
this.categoria.reset('');
this.unitaMisura.reset('');
} catch {
this.submitting.set(false);
this.errorMessage.set('Impossibile inviare la proposta. Riprova più tardi.');
}
}
}
@@ -1,19 +1,55 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type StatoTipoEvento = 'confermata' | 'da_approvare';
export interface TipoEvento {
id: string;
nome: string;
stato: StatoTipoEvento;
creatoDaOrgId: string | null;
creatoIl: string;
}
@Injectable({ providedIn: 'root' })
export class TipiEventoApiService {
private readonly http = inject(HttpClient);
getTipiEvento(): Observable<TipoEvento[]> {
return this.http.get<TipoEvento[]>(`${environment.magazzinoApiBaseUrl}/tipi-evento`);
// Pubblico: solo i tipi evento confermati (catalogo liste, autocomplete wizard).
getTipiEvento(nome?: string): Observable<TipoEvento[]> {
const params = nome ? new HttpParams().set('nome', nome) : undefined;
return this.http.get<TipoEvento[]>(`${environment.magazzinoApiBaseUrl}/tipi-evento`, { params });
}
// Moderazione: tutti gli stati, incluse le proposte in attesa.
getTipiEventoModerazione(): Observable<TipoEvento[]> {
return this.http.get<TipoEvento[]>(`${environment.magazzinoApiBaseUrl}/tipi-evento/moderazione`);
}
createTipoEvento(nome: string): Observable<TipoEvento> {
return this.http.post<TipoEvento>(`${environment.magazzinoApiBaseUrl}/tipi-evento`, { nome });
}
proponiTipoEvento(nome: string): Observable<TipoEvento> {
return this.http.post<TipoEvento>(`${environment.magazzinoApiBaseUrl}/tipi-evento/proposte`, { nome });
}
updateTipoEvento(id: string, nome: string): Observable<TipoEvento> {
return this.http.put<TipoEvento>(`${environment.magazzinoApiBaseUrl}/tipi-evento/${id}`, { nome });
}
deleteTipoEvento(id: string): Observable<void> {
return this.http.delete<void>(`${environment.magazzinoApiBaseUrl}/tipi-evento/${id}`);
}
approvaTipoEvento(id: string): Observable<TipoEvento> {
return this.http.post<TipoEvento>(`${environment.magazzinoApiBaseUrl}/tipi-evento/${id}/approva`, {});
}
rifiutaTipoEvento(id: string): Observable<void> {
return this.http.post<void>(`${environment.magazzinoApiBaseUrl}/tipi-evento/${id}/rifiuta`, {});
}
}
@@ -0,0 +1,48 @@
import { Injectable } from '@angular/core';
// Le spunte sulle voci di una lista modello sono solo un promemoria visivo lato utente
// (niente checklist reale come per gli Eventi): non vanno al backend, restano nella
// sessionStorage del browser così sopravvivono alla navigazione tra home/dettaglio/ricerca
// ma si azzerano alla chiusura della scheda.
const STORAGE_KEY_PREFIX = 'scouthub-magazzino:lista-modello-voci-spuntate:';
@Injectable({ providedIn: 'root' })
export class VoceCheckStorageService {
isSpuntata(listaModelloId: string, materialeId: string): boolean {
return this.leggi(listaModelloId).has(materialeId);
}
toggle(listaModelloId: string, materialeId: string): void {
this.setSpuntata(listaModelloId, materialeId, !this.isSpuntata(listaModelloId, materialeId));
}
// Usato per il check/uncheck massivo di una sotto-lista dal suo checkbox riassuntivo:
// a differenza di toggle() imposta uno stato preciso invece di invertirlo.
setSpuntata(listaModelloId: string, materialeId: string, spuntata: boolean): void {
const spuntate = this.leggi(listaModelloId);
if (spuntata) {
spuntate.add(materialeId);
} else {
spuntate.delete(materialeId);
}
this.scrivi(listaModelloId, spuntate);
}
private leggi(listaModelloId: string): Set<string> {
try {
const raw = sessionStorage.getItem(STORAGE_KEY_PREFIX + listaModelloId);
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
} catch {
return new Set();
}
}
private scrivi(listaModelloId: string, spuntate: Set<string>): void {
try {
sessionStorage.setItem(STORAGE_KEY_PREFIX + listaModelloId, JSON.stringify([...spuntate]));
} catch {
// sessionStorage non disponibile (es. modalità privata restrittiva): la spunta
// resta solo per la sessione corrente del componente, nessun errore da mostrare.
}
}
}
@@ -0,0 +1,14 @@
export type TipoNotifica =
| 'MATERIALE_PROPOSTO'
| 'CATEGORIA_PROPOSTA'
| 'TIPO_EVENTO_PROPOSTO'
| 'LISTA_PROPOSTA';
export interface Notifica {
id: number;
tipo: TipoNotifica;
messaggio: string;
link: string | null;
letta: boolean;
dataCreazione: string;
}
@@ -0,0 +1,30 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import { Notifica } from '../models/notifica.model';
@Injectable({
providedIn: 'root',
})
export class NotificheService {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.magazzinoApiBaseUrl}/notifiche`;
getLista(): Observable<Notifica[]> {
return this.http.get<Notifica[]>(`${this.baseUrl}`);
}
getCountNonLette(): Observable<{ count: number }> {
return this.http.get<{ count: number }>(`${this.baseUrl}/non-lette/count`);
}
segnaLetta(id: number): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/${id}/letta`, {});
}
segnaTutteLette(): Observable<void> {
return this.http.put<void>(`${this.baseUrl}/letta-tutte`, {});
}
}
@@ -1,17 +0,0 @@
.crea-evento {
max-width: 520px;
}
.crea-evento__field {
width: 100%;
}
.crea-evento__campo-errore {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-accent-800);
}
.crea-evento__error {
color: var(--color-accent-800);
}
@@ -1,57 +0,0 @@
<section class="crea-evento om-page">
<h1 class="om-section-title">Nuovo evento</h1>
<p class="om-section-sub">Crea un evento a partire da una lista della tua organizzazione.</p>
@if (loading()) {
<p class="om-empty">Caricamento liste…</p>
} @else if (loadError(); as message) {
<p class="crea-evento__error" role="alert">{{ message }}</p>
} @else if (liste().length === 0) {
<p class="om-empty">
Non hai ancora nessuna lista da collegare a un evento. Creane una nella sezione
<a routerLink="/liste">Le tue liste</a>.
</p>
} @else {
<form class="crea-evento__form om-stack" (submit)="$event.preventDefault(); submit()" novalidate>
<div class="field crea-evento__field">
<label for="ce-nome">Nome evento</label>
<input class="input" id="ce-nome" [formControl]="nome" placeholder="Es. Campo estivo 2026" />
@if (nome.hasError('required')) {
<p class="crea-evento__campo-errore">Il nome dell'evento è obbligatorio.</p>
} @else if (nome.hasError('minlength')) {
<p class="crea-evento__campo-errore">Il nome deve avere almeno 3 caratteri.</p>
} @else if (nome.hasError('maxlength')) {
<p class="crea-evento__campo-errore">Il nome non può superare i 100 caratteri.</p>
}
</div>
<div class="field crea-evento__field">
<label for="ce-data">Data</label>
<input class="input" id="ce-data" type="date" [formControl]="data" />
@if (data.hasError('required')) {
<p class="crea-evento__campo-errore">La data è obbligatoria.</p>
}
</div>
<div class="field crea-evento__field">
<label for="ce-lista">Lista materiale</label>
<select class="input" id="ce-lista" [formControl]="listaId">
@for (lista of liste(); track lista.id) {
<option [value]="lista.id">{{ lista.nome }}</option>
}
</select>
@if (listaId.hasError('required')) {
<p class="crea-evento__campo-errore">Seleziona una lista.</p>
}
</div>
@if (errorMessage(); as message) {
<p class="crea-evento__error" role="alert">{{ message }}</p>
}
<button type="submit" class="btn btn-primary" [disabled]="submitting()">
{{ submitting() ? 'Creazione in corso…' : 'Crea evento' }}
</button>
</form>
}
</section>
@@ -1,122 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { Lista, ListeApiService } from '../../liste/liste-api.service';
import { EventiApiService, EventoDettaglio } from '../eventi-api.service';
import { CreaEvento } from './crea-evento';
describe('CreaEvento', () => {
let component: CreaEvento;
let fixture: ComponentFixture<CreaEvento>;
let listeApi: { getListe: ReturnType<typeof vi.fn> };
let eventiApi: { creaEvento: ReturnType<typeof vi.fn> };
let router: Router;
const liste: Lista[] = [
{ id: 'lista-1', nome: 'Campo estivo 2026', orgId: 'org-1', creataIl: '2026-01-01', voci: [] },
{ id: 'lista-2', nome: 'Uscita di un giorno', orgId: 'org-1', creataIl: '2026-01-02', voci: [] }
];
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [CreaEvento],
providers: [
provideRouter([]),
{ provide: ListeApiService, useValue: listeApi },
{ provide: EventiApiService, useValue: eventiApi }
]
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(CreaEvento);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) };
eventiApi = { creaEvento: vi.fn() };
});
it('mostra le liste disponibili nel select', async () => {
await setup();
expect(component.loading()).toBe(false);
expect(component.liste()).toEqual(liste);
});
it('mostra un messaggio se non ci sono liste disponibili', async () => {
listeApi.getListe.mockReturnValue(of([]));
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Non hai ancora nessuna lista');
});
it('mostra un messaggio di errore se il caricamento delle liste fallisce', async () => {
listeApi.getListe.mockReturnValue(throwError(() => new Error('network error')));
await setup();
expect(component.loadError()).toBe('Impossibile caricare le liste disponibili. Riprova più tardi.');
});
describe('validazione e invio del form', () => {
beforeEach(async () => {
await setup();
});
it('non chiama l\'API se il form non è valido e marca i controlli come touched', async () => {
await component.submit();
expect(eventiApi.creaEvento).not.toHaveBeenCalled();
expect(component.nome.touched).toBe(true);
expect(component.data.touched).toBe(true);
expect(component.listaId.touched).toBe(true);
});
it('crea l\'evento e reindirizza al suo dettaglio', async () => {
component.nome.setValue('Campo estivo 2026');
component.data.setValue('2026-08-01');
component.listaId.setValue('lista-1');
const evento: EventoDettaglio = {
id: 'evento-1',
orgId: 'org-1',
nome: 'Campo estivo 2026',
listaId: 'lista-1',
data: '2026-08-01T00:00:00.000Z',
voci: []
};
eventiApi.creaEvento.mockReturnValue(of(evento));
await component.submit();
expect(eventiApi.creaEvento).toHaveBeenCalledWith({
nome: 'Campo estivo 2026',
data: '2026-08-01',
listaId: 'lista-1'
});
expect(router.navigate).toHaveBeenCalledWith(['/eventi', 'evento-1']);
expect(component.submitting()).toBe(false);
});
it('mostra un messaggio di errore se la creazione fallisce', async () => {
component.nome.setValue('Campo estivo 2026');
component.data.setValue('2026-08-01');
component.listaId.setValue('lista-1');
eventiApi.creaEvento.mockReturnValue(throwError(() => new Error('server error')));
await component.submit();
expect(component.errorMessage()).toBe("Impossibile creare l'evento. Riprova più tardi.");
expect(router.navigate).not.toHaveBeenCalled();
});
});
});
@@ -1,86 +0,0 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormControl, 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 { Router, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { Lista, ListeApiService } from '../../liste/liste-api.service';
import { EventiApiService } from '../eventi-api.service';
@Component({
selector: 'app-crea-evento',
imports: [ReactiveFormsModule, RouterLink, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule],
templateUrl: './crea-evento.html',
styleUrl: './crea-evento.css'
})
export class CreaEvento implements OnInit {
private readonly listeApi = inject(ListeApiService);
private readonly eventiApi = inject(EventiApiService);
private readonly router = inject(Router);
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly liste = signal<Lista[]>([]);
readonly nome = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3), Validators.maxLength(100)]
});
readonly data = new FormControl('', { nonNullable: true, validators: [Validators.required] });
readonly listaId = new FormControl<string | null>(null, { validators: [Validators.required] });
readonly submitting = signal(false);
readonly errorMessage = signal<string | null>(null);
async ngOnInit(): Promise<void> {
await this.caricaListe();
}
async submit(): Promise<void> {
if (this.submitting()) {
return;
}
if (this.nome.invalid || this.data.invalid || this.listaId.invalid) {
this.nome.markAsTouched();
this.data.markAsTouched();
this.listaId.markAsTouched();
return;
}
this.errorMessage.set(null);
this.submitting.set(true);
try {
const evento = await firstValueFrom(
this.eventiApi.creaEvento({
nome: this.nome.value.trim(),
data: this.data.value,
listaId: this.listaId.value!
})
);
this.submitting.set(false);
await this.router.navigate(['/eventi', evento.id]);
} catch {
this.submitting.set(false);
this.errorMessage.set("Impossibile creare l'evento. Riprova più tardi.");
}
}
private async caricaListe(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const liste = await firstValueFrom(this.listeApi.getListe());
this.liste.set(liste);
} catch {
this.loadError.set('Impossibile caricare le liste disponibili. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -1,53 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface EventoVoce {
materialeId: string;
nome: string;
unitaMisura: string;
quantitaRichiesta: number;
quantitaPosseduta: number;
portato: boolean;
note: string | null;
}
export interface EventoDettaglio {
id: string;
orgId: string;
nome: string;
listaId: string;
data: string;
voci: EventoVoce[];
}
export interface CreaEventoInput {
nome: string;
listaId: string;
data: string;
}
export interface CheckVoceInput {
materialeId: string;
portato?: boolean;
note?: string | null;
}
@Injectable({ providedIn: 'root' })
export class EventiApiService {
private readonly http = inject(HttpClient);
creaEvento(input: CreaEventoInput): Observable<EventoDettaglio> {
return this.http.post<EventoDettaglio>(`${environment.magazzinoApiBaseUrl}/eventi`, input);
}
getEvento(id: string): Observable<EventoDettaglio> {
return this.http.get<EventoDettaglio>(`${environment.magazzinoApiBaseUrl}/eventi/${id}`);
}
aggiornaCheck(id: string, voci: CheckVoceInput[]): Observable<EventoDettaglio> {
return this.http.patch<EventoDettaglio>(`${environment.magazzinoApiBaseUrl}/eventi/${id}/check`, { voci });
}
}
@@ -1,16 +0,0 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
export const EVENTI_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./crea-evento/crea-evento').then((m) => m.CreaEvento),
canActivate: [requireAuthGuard]
},
{
path: ':id',
loadComponent: () => import('./evento-dettaglio/evento-dettaglio').then((m) => m.EventoDettaglioComponent),
canActivate: [requireAuthGuard]
}
];
@@ -1,52 +0,0 @@
.evento-dettaglio {
max-width: 800px;
}
.evento-dettaglio__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-2);
}
.evento-dettaglio__data {
color: var(--color-text);
opacity: 0.7;
margin: 0;
}
.evento-dettaglio__tabella {
margin-top: var(--space-5);
margin-bottom: var(--space-5);
}
.evento-dettaglio__quantita--insufficiente {
color: var(--color-accent-800);
font-weight: 600;
}
.evento-dettaglio__badge-manca {
margin-left: var(--space-2);
}
.evento-dettaglio__checkbox {
width: 18px;
height: 18px;
accent-color: var(--color-accent);
cursor: pointer;
}
.evento-dettaglio__note {
width: 100%;
min-height: 32px;
}
.evento-dettaglio__error {
color: var(--color-accent-800);
}
.evento-dettaglio__successo {
display: inline-flex;
margin-bottom: var(--space-3);
}
@@ -1,74 +0,0 @@
<section class="evento-dettaglio om-page">
@if (loading()) {
<p class="om-empty">Caricamento evento…</p>
} @else if (loadError(); as message) {
<p class="evento-dettaglio__error" role="alert">{{ message }}</p>
} @else if (evento(); as ev) {
<div class="evento-dettaglio__header">
<div>
<h1 class="om-section-title">{{ ev.nome }}</h1>
<p class="evento-dettaglio__data">{{ ev.data | slice: 0 : 10 }}</p>
</div>
<a class="btn btn-ghost" routerLink="/eventi">Crea un altro evento</a>
</div>
<table class="table evento-dettaglio__tabella">
<thead>
<tr>
<th>Materiale</th>
<th>Richiesta</th>
<th>In magazzino</th>
<th>Portato</th>
<th>Note</th>
</tr>
</thead>
<tbody>
@for (voce of voci(); track voce.materialeId) {
<tr>
<td>{{ voce.nome }}</td>
<td>{{ voce.quantitaRichiesta }} {{ voce.unitaMisura }}</td>
<td [class.evento-dettaglio__quantita--insufficiente]="quantitaInsufficiente(voce)">
{{ voce.quantitaPosseduta }} {{ voce.unitaMisura }}
@if (quantitaInsufficiente(voce)) {
<span class="tag tag-accent evento-dettaglio__badge-manca">manca</span>
}
</td>
<td>
<input
type="checkbox"
class="evento-dettaglio__checkbox"
[checked]="voce.portato"
(change)="togglePortato(voce.materialeId, $any($event.target).checked)"
[attr.aria-label]="'Portato: ' + voce.nome"
/>
</td>
<td>
<input
type="text"
class="input evento-dettaglio__note"
[value]="voce.note"
(change)="modificaNote(voce.materialeId, $any($event.target).value)"
[attr.aria-label]="'Note per ' + voce.nome"
/>
</td>
</tr>
} @empty {
<tr>
<td colspan="5">La lista collegata a questo evento non ha voci.</td>
</tr>
}
</tbody>
</table>
@if (salvataggioErrore(); as message) {
<p class="evento-dettaglio__error" role="alert">{{ message }}</p>
}
@if (salvataggioOk()) {
<p class="tag tag-accent-2 evento-dettaglio__successo" role="status">Check salvato</p>
}
<button type="button" class="btn btn-primary" [disabled]="salvataggioInCorso()" (click)="salva()">
{{ salvataggioInCorso() ? 'Salvataggio in corso…' : 'Salva check' }}
</button>
}
</section>
@@ -1,148 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { EventiApiService, EventoDettaglio } from '../eventi-api.service';
import { EventoDettaglioComponent } from './evento-dettaglio';
describe('EventoDettaglioComponent', () => {
let component: EventoDettaglioComponent;
let fixture: ComponentFixture<EventoDettaglioComponent>;
let eventiApi: { getEvento: ReturnType<typeof vi.fn>; aggiornaCheck: ReturnType<typeof vi.fn> };
const eventoIniziale: EventoDettaglio = {
id: 'evento-1',
orgId: 'org-1',
nome: 'Campo estivo 2026',
listaId: 'lista-1',
data: '2026-08-01T00:00:00.000Z',
voci: [
{
materialeId: 'mat-1',
nome: 'Corda',
unitaMisura: 'pz',
quantitaRichiesta: 5,
quantitaPosseduta: 2,
portato: false,
note: null
},
{
materialeId: 'mat-2',
nome: 'Telo cerato',
unitaMisura: 'pz',
quantitaRichiesta: 1,
quantitaPosseduta: 3,
portato: true,
note: 'Controllato'
}
]
};
async function setup(eventoId = 'evento-1'): Promise<void> {
await TestBed.configureTestingModule({
imports: [EventoDettaglioComponent],
providers: [
provideRouter([]),
{ provide: EventiApiService, useValue: eventiApi },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => eventoId } } } }
]
}).compileComponents();
fixture = TestBed.createComponent(EventoDettaglioComponent);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
eventiApi = { getEvento: vi.fn().mockReturnValue(of(eventoIniziale)), aggiornaCheck: vi.fn() };
});
it('mostra le voci con quantità richiesta e disponibile in magazzino', async () => {
await setup();
fixture.detectChanges();
expect(component.loading()).toBe(false);
const compiled = fixture.nativeElement as HTMLElement;
const righe = compiled.querySelectorAll('.evento-dettaglio__tabella tbody tr');
expect(righe.length).toBe(2);
expect(righe[0].textContent).toContain('Corda');
expect(righe[0].textContent).toContain('5 pz');
expect(righe[0].textContent).toContain('2 pz');
});
it('evidenzia con un badge la quantità insufficiente in magazzino', async () => {
await setup();
expect(component.quantitaInsufficiente(component.voci()[0])).toBe(true);
expect(component.quantitaInsufficiente(component.voci()[1])).toBe(false);
const compiled = fixture.nativeElement as HTMLElement;
const badge = compiled.querySelector('.evento-dettaglio__badge-manca');
expect(badge).toBeTruthy();
expect(compiled.querySelectorAll('.evento-dettaglio__badge-manca').length).toBe(1);
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
eventiApi.getEvento.mockReturnValue(throwError(() => new Error('network error')));
await setup();
expect(component.loadError()).toBe("Impossibile caricare l'evento. Riprova più tardi.");
});
describe('modifica del check locale', () => {
beforeEach(async () => {
await setup();
});
it('aggiorna lo stato "portato" di una voce', () => {
component.togglePortato('mat-1', true);
expect(component.voci().find((v) => v.materialeId === 'mat-1')?.portato).toBe(true);
});
it('aggiorna le note di una voce', () => {
component.modificaNote('mat-1', 'Verificare prima di partire');
expect(component.voci().find((v) => v.materialeId === 'mat-1')?.note).toBe('Verificare prima di partire');
});
});
describe('salvataggio del check', () => {
beforeEach(async () => {
await setup();
});
it('invia tutte le voci a aggiornaCheck con note vuote normalizzate a null', async () => {
component.togglePortato('mat-1', true);
component.modificaNote('mat-1', ' ');
const eventoAggiornato: EventoDettaglio = {
...eventoIniziale,
voci: eventoIniziale.voci.map((v) => (v.materialeId === 'mat-1' ? { ...v, portato: true } : v))
};
eventiApi.aggiornaCheck.mockReturnValue(of(eventoAggiornato));
await component.salva();
expect(eventiApi.aggiornaCheck).toHaveBeenCalledWith('evento-1', [
{ materialeId: 'mat-1', portato: true, note: null },
{ materialeId: 'mat-2', portato: true, note: 'Controllato' }
]);
expect(component.salvataggioOk()).toBe(true);
expect(component.voci()[0].portato).toBe(true);
});
it('mostra un messaggio di errore se il salvataggio fallisce', async () => {
eventiApi.aggiornaCheck.mockReturnValue(throwError(() => new Error('server error')));
await component.salva();
expect(component.salvataggioErrore()).toBe('Impossibile salvare il check della lista. Riprova più tardi.');
expect(component.salvataggioOk()).toBe(false);
});
});
});
@@ -1,102 +0,0 @@
import { SlicePipe } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { CheckVoceInput, EventiApiService, EventoDettaglio } from '../eventi-api.service';
interface VoceCheckEditor {
materialeId: string;
nome: string;
unitaMisura: string;
quantitaRichiesta: number;
quantitaPosseduta: number;
portato: boolean;
note: string;
}
function toEditor(voci: EventoDettaglio['voci']): VoceCheckEditor[] {
return voci.map((v) => ({ ...v, note: v.note ?? '' }));
}
@Component({
selector: 'app-evento-dettaglio',
imports: [SlicePipe, RouterLink, MatButtonModule, MatCheckboxModule, MatInputModule],
templateUrl: './evento-dettaglio.html',
styleUrl: './evento-dettaglio.css'
})
export class EventoDettaglioComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly eventiApi = inject(EventiApiService);
private readonly eventoId = this.route.snapshot.paramMap.get('id') ?? '';
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly evento = signal<EventoDettaglio | null>(null);
readonly voci = signal<VoceCheckEditor[]>([]);
readonly salvataggioInCorso = signal(false);
readonly salvataggioErrore = signal<string | null>(null);
readonly salvataggioOk = signal(false);
async ngOnInit(): Promise<void> {
await this.carica();
}
quantitaInsufficiente(voce: VoceCheckEditor): boolean {
return voce.quantitaPosseduta < voce.quantitaRichiesta;
}
togglePortato(materialeId: string, portato: boolean): void {
this.voci.update((voci) => voci.map((v) => (v.materialeId === materialeId ? { ...v, portato } : v)));
}
modificaNote(materialeId: string, note: string): void {
this.voci.update((voci) => voci.map((v) => (v.materialeId === materialeId ? { ...v, note } : v)));
}
async salva(): Promise<void> {
if (this.salvataggioInCorso()) {
return;
}
this.salvataggioErrore.set(null);
this.salvataggioOk.set(false);
this.salvataggioInCorso.set(true);
try {
const input: CheckVoceInput[] = this.voci().map((v) => ({
materialeId: v.materialeId,
portato: v.portato,
note: v.note.trim().length > 0 ? v.note.trim() : null
}));
const eventoAggiornato = await firstValueFrom(this.eventiApi.aggiornaCheck(this.eventoId, input));
this.evento.set(eventoAggiornato);
this.voci.set(toEditor(eventoAggiornato.voci));
this.salvataggioOk.set(true);
} catch {
this.salvataggioErrore.set('Impossibile salvare il check della lista. Riprova più tardi.');
} finally {
this.salvataggioInCorso.set(false);
}
}
private async carica(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const evento = await firstValueFrom(this.eventiApi.getEvento(this.eventoId));
this.evento.set(evento);
this.voci.set(toEditor(evento.voci));
} catch {
this.loadError.set('Impossibile caricare l\'evento. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -1,59 +0,0 @@
.lista-editor {
max-width: 720px;
}
.lista-editor__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: 0;
}
.lista-editor__tabella {
margin-bottom: var(--space-5);
}
.lista-editor__quantita {
width: 4.5rem;
min-height: 32px;
padding: 4px 10px;
}
.lista-editor__ricerca {
margin-bottom: var(--space-5);
}
.lista-editor__ricerca-field {
width: 100%;
}
.lista-editor__risultati {
list-style: none;
margin: var(--space-2) 0 0;
padding: 0;
}
.lista-editor__risultati li {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) 0;
border-bottom: 1px solid var(--color-divider);
}
.lista-editor__nessun-risultato {
color: var(--color-text);
opacity: 0.7;
font-size: 14px;
}
.lista-editor__error {
color: var(--color-accent-800);
}
.lista-editor__successo {
display: inline-flex;
margin-bottom: var(--space-3);
}
@@ -1,92 +0,0 @@
<section class="lista-editor om-page">
@if (loading()) {
<p class="om-empty">Caricamento lista…</p>
} @else if (loadError(); as message) {
<p class="lista-editor__error" role="alert">{{ message }}</p>
} @else {
<div class="lista-editor__header">
<h1 class="om-section-title">{{ lista()?.nome }}</h1>
<a class="btn btn-ghost" routerLink="/liste">Torna alle tue liste</a>
</div>
<p class="om-section-sub">Le liste filtrano sempre per la tua organizzazione.</p>
<table class="table lista-editor__tabella">
<thead>
<tr>
<th>Materiale</th>
<th>Quantità</th>
<th>Unità</th>
<th></th>
</tr>
</thead>
<tbody>
@for (voce of voci(); track voce.materialeId) {
<tr>
<td>{{ voce.nome }}</td>
<td>
<input
type="number"
min="1"
step="1"
class="input lista-editor__quantita"
[value]="voce.quantita"
(change)="modificaQuantita(voce.materialeId, $any($event.target).value)"
/>
</td>
<td>{{ voce.unitaMisura }}</td>
<td>
<button
type="button"
class="btn btn-ghost"
(click)="rimuoviVoce(voce.materialeId)"
[attr.aria-label]="'Rimuovi ' + voce.nome"
>
Rimuovi
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="4">Nessun materiale in questa lista.</td>
</tr>
}
</tbody>
</table>
<div class="lista-editor__ricerca">
<div class="field lista-editor__ricerca-field">
<label for="le-ricerca">Cerca materiale da aggiungere</label>
<input class="input" id="le-ricerca" [formControl]="ricerca" placeholder="Es. corda" />
</div>
@if (risultatiRicerca().length > 0) {
<ul class="lista-editor__risultati">
@for (materiale of risultatiRicerca(); track materiale.id) {
<li>
<span>{{ materiale.nome }} ({{ materiale.categoria }})</span>
<button type="button" class="btn btn-secondary" (click)="aggiungiMateriale(materiale)">Aggiungi</button>
</li>
}
</ul>
} @else if (nessunRisultato()) {
<p class="lista-editor__nessun-risultato">
Nessun materiale trovato per "{{ ricerca.value }}".
<a [routerLink]="['/proponi-materiale']" [queryParams]="{ nome: ricerca.value }">
Proponi un nuovo materiale
</a>
</p>
}
</div>
@if (salvataggioErrore(); as message) {
<p class="lista-editor__error" role="alert">{{ message }}</p>
}
@if (salvataggioOk()) {
<p class="tag tag-accent-2 lista-editor__successo" role="status">Modifiche salvate</p>
}
<button type="button" class="btn btn-primary" [disabled]="salvataggioInCorso()" (click)="salva()">
{{ salvataggioInCorso() ? 'Salvataggio in corso…' : 'Salva modifiche' }}
</button>
}
</section>
@@ -1,181 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../../catalogo/materiali-api.service';
import { Lista, ListeApiService } from '../liste-api.service';
import { ListaEditor } from './lista-editor';
describe('ListaEditor', () => {
let component: ListaEditor;
let fixture: ComponentFixture<ListaEditor>;
let listeApi: { getListe: ReturnType<typeof vi.fn>; aggiornaLista: ReturnType<typeof vi.fn> };
let materialiApi: { getMateriali: ReturnType<typeof vi.fn> };
const listaIniziale: Lista = {
id: 'lista-1',
nome: 'Campo estivo 2026',
orgId: 'org-1',
creataIl: '2026-01-01',
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }]
};
const materialiCatalogo: MaterialePubblico[] = [
{ id: 'mat-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' },
{ id: 'mat-2', nome: 'Telo cerato', categoria: 'Campeggio', unitaMisura: 'pz' },
{ id: 'mat-3', nome: 'Torcia', categoria: 'Attrezzatura', unitaMisura: 'pz' }
];
async function setup(listaId = 'lista-1'): Promise<void> {
await TestBed.configureTestingModule({
imports: [ListaEditor],
providers: [
provideRouter([]),
{ provide: ListeApiService, useValue: listeApi },
{ provide: MaterialiApiService, useValue: materialiApi },
{ provide: ActivatedRoute, useValue: { snapshot: { paramMap: { get: () => listaId } } } }
]
}).compileComponents();
fixture = TestBed.createComponent(ListaEditor);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
listeApi = { getListe: vi.fn().mockReturnValue(of([listaIniziale])), aggiornaLista: vi.fn() };
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materialiCatalogo)) };
});
it('mostra le voci della lista dopo il caricamento', async () => {
await setup();
fixture.detectChanges();
expect(component.loading()).toBe(false);
expect(component.voci()).toEqual(listaIniziale.voci);
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Campo estivo 2026');
const righe = compiled.querySelectorAll('.lista-editor__tabella tbody tr');
expect(righe.length).toBe(1);
expect(righe[0].textContent).toContain('Corda');
});
it('mostra un errore se la lista non viene trovata', async () => {
listeApi.getListe.mockReturnValue(of([]));
await setup();
expect(component.loadError()).toBe('Lista non trovata.');
});
it('mostra un errore se il caricamento fallisce', async () => {
listeApi.getListe.mockReturnValue(throwError(() => new Error('network error')));
await setup();
expect(component.loadError()).toBe('Impossibile caricare la lista. Riprova più tardi.');
});
describe('ricerca e aggiunta materiali dal catalogo', () => {
beforeEach(async () => {
await setup();
});
it('esclude dai risultati i materiali già presenti in lista', () => {
component.ricerca.setValue('corda');
fixture.detectChanges();
expect(component.risultatiRicerca()).toEqual([]);
});
it('trova un materiale non ancora in lista e lo aggiunge al click su "Aggiungi"', () => {
component.ricerca.setValue('telo');
fixture.detectChanges();
expect(component.risultatiRicerca().map((m) => m.id)).toEqual(['mat-2']);
component.aggiungiMateriale(materialiCatalogo[1]);
fixture.detectChanges();
expect(component.voci().map((v) => v.materialeId)).toEqual(['mat-1', 'mat-2']);
expect(component.ricerca.value).toBe('');
const compiled = fixture.nativeElement as HTMLElement;
const righe = compiled.querySelectorAll('.lista-editor__tabella tbody tr');
expect(righe.length).toBe(2);
});
it('rimuove una voce dalla lista', () => {
component.rimuoviVoce('mat-1');
fixture.detectChanges();
expect(component.voci()).toEqual([]);
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-editor__tabella tbody')?.textContent).toContain(
'Nessun materiale in questa lista.'
);
});
it('modifica la quantità di una voce esistente', () => {
component.modificaQuantita('mat-1', '5');
expect(component.voci()[0].quantita).toBe(5);
});
it('ignora una quantità non valida e mantiene almeno 1', () => {
component.modificaQuantita('mat-1', 'abc');
expect(component.voci()[0].quantita).toBe(1);
});
it('mostra il link per proporre un nuovo materiale se la ricerca non trova risultati', () => {
component.ricerca.setValue('materiale inesistente');
fixture.detectChanges();
expect(component.nessunRisultato()).toBe(true);
const compiled = fixture.nativeElement as HTMLElement;
const link = compiled.querySelector('.lista-editor__nessun-risultato a') as HTMLAnchorElement;
expect(link).toBeTruthy();
expect(link.getAttribute('href')).toBe('/proponi-materiale?nome=materiale%20inesistente');
});
});
describe('salvataggio delle modifiche', () => {
beforeEach(async () => {
await setup();
});
it('invia le voci correnti a aggiornaLista e mostra un messaggio di successo', async () => {
component.rimuoviVoce('mat-1');
component.aggiungiMateriale(materialiCatalogo[2]);
const listaAggiornata: Lista = {
...listaIniziale,
voci: [{ materialeId: 'mat-3', nome: 'Torcia', unitaMisura: 'pz', quantita: 1 }]
};
listeApi.aggiornaLista.mockReturnValue(of(listaAggiornata));
await component.salva();
expect(listeApi.aggiornaLista).toHaveBeenCalledWith('lista-1', {
voci: [{ materialeId: 'mat-3', quantita: 1 }]
});
expect(component.salvataggioOk()).toBe(true);
expect(component.voci()).toEqual(listaAggiornata.voci);
});
it('mostra un messaggio di errore se il salvataggio fallisce', async () => {
listeApi.aggiornaLista.mockReturnValue(throwError(() => new Error('server error')));
await component.salva();
expect(component.salvataggioErrore()).toBe('Impossibile salvare le modifiche. Riprova più tardi.');
expect(component.salvataggioOk()).toBe(false);
});
});
});
@@ -1,133 +0,0 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../../catalogo/materiali-api.service';
import { Lista, ListeApiService } from '../liste-api.service';
interface VoceEditor {
materialeId: string;
nome: string;
unitaMisura: string;
quantita: number;
}
@Component({
selector: 'app-lista-editor',
imports: [ReactiveFormsModule, RouterLink, MatButtonModule, MatFormFieldModule, MatInputModule],
templateUrl: './lista-editor.html',
styleUrl: './lista-editor.css'
})
export class ListaEditor implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly listeApi = inject(ListeApiService);
private readonly materialiApi = inject(MaterialiApiService);
private readonly listaId = this.route.snapshot.paramMap.get('id') ?? '';
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly lista = signal<Lista | null>(null);
readonly voci = signal<VoceEditor[]>([]);
readonly materialiCatalogo = signal<MaterialePubblico[]>([]);
readonly ricerca = new FormControl('', { nonNullable: true });
private readonly ricercaValue = toSignal(this.ricerca.valueChanges, { initialValue: '' });
readonly salvataggioInCorso = signal(false);
readonly salvataggioErrore = signal<string | null>(null);
readonly salvataggioOk = signal(false);
readonly risultatiRicerca = computed<MaterialePubblico[]>(() => {
const termine = this.ricercaValue().trim().toLowerCase();
if (termine.length === 0) {
return [];
}
const idGiaPresenti = new Set(this.voci().map((v) => v.materialeId));
return this.materialiCatalogo().filter(
(materiale) => !idGiaPresenti.has(materiale.id) && materiale.nome.toLowerCase().includes(termine)
);
});
readonly nessunRisultato = computed(
() => this.ricercaValue().trim().length > 0 && this.risultatiRicerca().length === 0
);
async ngOnInit(): Promise<void> {
await this.carica();
}
aggiungiMateriale(materiale: MaterialePubblico): void {
if (this.voci().some((v) => v.materialeId === materiale.id)) {
return;
}
this.voci.update((voci) => [
...voci,
{ materialeId: materiale.id, nome: materiale.nome, unitaMisura: materiale.unitaMisura, quantita: 1 }
]);
this.ricerca.setValue('');
}
rimuoviVoce(materialeId: string): void {
this.voci.update((voci) => voci.filter((v) => v.materialeId !== materialeId));
}
modificaQuantita(materialeId: string, valore: string): void {
const parsed = Number.parseInt(valore, 10);
const quantita = Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
this.voci.update((voci) => voci.map((v) => (v.materialeId === materialeId ? { ...v, quantita } : v)));
}
async salva(): Promise<void> {
if (this.salvataggioInCorso()) {
return;
}
this.salvataggioErrore.set(null);
this.salvataggioOk.set(false);
this.salvataggioInCorso.set(true);
try {
const input = { voci: this.voci().map((v) => ({ materialeId: v.materialeId, quantita: v.quantita })) };
const listaAggiornata = await firstValueFrom(this.listeApi.aggiornaLista(this.listaId, input));
this.lista.set(listaAggiornata);
this.voci.set(listaAggiornata.voci);
this.salvataggioOk.set(true);
} catch {
this.salvataggioErrore.set('Impossibile salvare le modifiche. Riprova più tardi.');
} finally {
this.salvataggioInCorso.set(false);
}
}
private async carica(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const liste = await firstValueFrom(this.listeApi.getListe());
const lista = liste.find((l) => l.id === this.listaId) ?? null;
if (!lista) {
this.loadError.set('Lista non trovata.');
return;
}
this.lista.set(lista);
this.voci.set(lista.voci);
const materiali = await firstValueFrom(this.materialiApi.getMateriali());
this.materialiCatalogo.set(materiali);
} catch {
this.loadError.set('Impossibile caricare la lista. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,34 @@
export interface StatoLista {
id: string;
nome: string;
}
export const STATO_BOZZA: StatoLista = { id: 'bozza', nome: 'Bozza' };
export const STATO_PRIVATO: StatoLista = { id: 'privato', nome: 'Privato' };
export const STATO_GRUPPO: StatoLista = { id: 'gruppo', nome: 'Gruppo' };
export const STATO_PUBBLICO: StatoLista = { id: 'pubblico', nome: 'Pubblico' };
export const STATI_LISTA: StatoLista[] = [STATO_BOZZA, STATO_PRIVATO, STATO_GRUPPO, STATO_PUBBLICO];
export interface StatoListaStyle {
bg: string;
color: string;
border: string;
}
export function statoListaStyle(idStato: string): StatoListaStyle {
if (idStato === 'gruppo') {
return { bg: 'var(--color-accent-100)', color: 'var(--color-accent-800)', border: 'var(--color-accent-300)' };
}
if (idStato === 'pubblico') {
return {
bg: 'var(--color-accent-2-100)',
color: 'var(--color-accent-2-800)',
border: 'var(--color-accent-2-300)'
};
}
if (idStato === 'privato') {
return { bg: 'var(--color-neutral-300)', color: 'var(--color-neutral-900)', border: 'var(--color-neutral-400)' };
}
return { bg: 'var(--color-neutral-100)', color: 'var(--color-neutral-700)', border: 'var(--color-neutral-300)' };
}
@@ -9,14 +9,38 @@ export interface ListaVoce {
nome: string;
unitaMisura: string;
quantita: number;
// Presente solo se il chiamante può vederlo (creatore della lista o admin/moderatore):
// il materiale è stato proposto da poco e non è ancora stato approvato.
inAttesaConferma?: boolean;
}
export interface SottoLista {
id: string;
nome: string;
voci: ListaVoce[];
}
export type StatoModerazioneLista = 'proposto' | 'approvato' | 'rifiutato';
export interface Lista {
id: string;
nome: string;
orgId: string;
// Valorizzato solo per le liste in stato 'gruppo': le liste bozza/privato/pubblico
// sono personali e non hanno alcuna organizzazione.
orgId: string | null;
stato: string;
// Nullo per bozza/privato/gruppo: la moderazione si applica solo quando stato =
// pubblico.
statoModerazione: StatoModerazioneLista | null;
tipoEventoId: string | null;
// Da quale lista pubblica approvata è stata forkata questa (fork = "usa come base").
parentId: string | null;
creataIl: string;
// Il chiamante è l'autore: per le liste 'gruppo' (visibili a tutta l'org, non solo a
// chi le ha create) serve a distinguere "le mie liste di gruppo" dalle altrui.
creataDaMe: boolean;
voci: ListaVoce[];
sottoListe: SottoLista[];
}
export interface ListaVoceInput {
@@ -24,9 +48,28 @@ export interface ListaVoceInput {
quantita: number;
}
export interface CreaListaInput {
nome: string;
stato: string;
voci?: ListaVoceInput[];
// Liste personali/di gruppo già esistenti, agganciate direttamente come sotto-lista.
sottoListeIds?: string[];
// Liste pubbliche approvate del catalogo: vengono forkate al volo dal backend in
// nuove liste bozza personali, poi agganciate come le altre.
sottoListeModelloIds?: string[];
tipoEventoId?: string;
}
export interface AggiornaListaInput {
nome?: string;
// Permette di promuovere una lista bozza/privata a pubblico (o viceversa) anche in
// edit, non solo alla creazione.
stato?: string;
voci?: ListaVoceInput[];
sottoListeIds?: string[];
sottoListeModelloIds?: string[];
// undefined = non toccare il campo, null = rimuovi il tipo evento associato.
tipoEventoId?: string | null;
}
@Injectable({ providedIn: 'root' })
@@ -37,8 +80,26 @@ export class ListeApiService {
return this.http.get<Lista[]>(`${environment.magazzinoApiBaseUrl}/liste`);
}
creaListaVuota(nome: string): Observable<Lista> {
return this.http.post<Lista>(`${environment.magazzinoApiBaseUrl}/liste`, { nome });
// Catalogo pubblico: liste già approvate dalla moderazione, usabili come base
// ("usa come base") per crearne di nuove. Filtro opzionale per tipo evento.
getListePubbliche(tipoEventoId?: string): Observable<Lista[]> {
const params = tipoEventoId ? { tipoEventoId } : undefined;
return this.http.get<Lista[]>(`${environment.magazzinoApiBaseUrl}/liste/pubbliche`, { params });
}
// Dettaglio pubblico per singolo id (deep-link diretto al catalogo).
getListaPubblica(id: string): Observable<Lista> {
return this.http.get<Lista>(`${environment.magazzinoApiBaseUrl}/liste/pubbliche/${id}`);
}
creaLista(input: CreaListaInput): Observable<Lista> {
return this.http.post<Lista>(`${environment.magazzinoApiBaseUrl}/liste`, input);
}
// Fork: crea una nuova lista bozza personale a partire da una lista pubblica
// approvata ("usa come base").
creaListaDaFork(listaOrigineId: string): Observable<Lista> {
return this.http.post<Lista>(`${environment.magazzinoApiBaseUrl}/liste/da-fork/${listaOrigineId}`, {});
}
aggiornaLista(id: string, input: AggiornaListaInput): Observable<Lista> {
@@ -1,18 +1,28 @@
.liste-list__form {
/* Header replicato 1:1 da scouthub-attivita-fe (pagina "Le mie attività"):
stesso font, stessa dimensione testo, stessa spaziatura. */
.page {
max-width: 1080px;
margin: 0 auto;
padding: 32px 24px 64px;
}
.page-header {
display: flex;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-2);
align-items: baseline;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.liste-list__field {
flex: 1;
.page-title {
font-size: 30px;
margin: 0;
font-weight: 700;
}
.liste-list__campo-errore {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-accent-800);
.page-title--sm {
font-size: 28px;
}
.liste-list__error {
@@ -20,21 +30,95 @@
margin-bottom: var(--space-3);
}
.liste-list__elenco {
margin-top: var(--space-5);
.lista {
display: flex;
flex-direction: column;
gap: 14px;
}
.liste-list__voce {
text-decoration: none;
transition: box-shadow 0.15s ease, transform 0.15s ease;
.riga {
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: 14px;
padding: 18px 20px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
.liste-list__voce:hover {
box-shadow: var(--shadow-sm);
.riga-info {
flex: 1;
min-width: 220px;
}
.liste-list__voci-count {
.riga-titolo {
cursor: pointer;
font-weight: 700;
font-size: 17px;
color: var(--color-text);
opacity: 0.6;
text-decoration: none;
}
.riga-data {
font-size: 13px;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
margin-top: 2px;
}
.riga-badge {
display: inline-block;
margin-top: 6px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid;
}
.riga-nota {
display: block;
margin-top: 6px;
font-size: 13px;
font-weight: 600;
color: var(--color-accent-800);
}
.stato-options {
display: flex;
gap: 6px;
}
.stato-option {
cursor: pointer;
padding: 7px 13px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
border: 1px solid var(--color-divider);
}
.riga-modifica {
cursor: pointer;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-divider);
font-weight: 600;
font-size: 14px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.empty-icon {
font-size: 40px;
margin-bottom: 12px;
}
.empty-title {
font-size: 18px;
font-weight: 600;
}
@@ -1,43 +1,71 @@
<section class="liste-list om-page">
<h1 class="om-section-title">Le tue liste</h1>
<p class="om-section-sub">Liste materiale della tua organizzazione.</p>
<form class="liste-list__form" (submit)="$event.preventDefault(); creaLista()" novalidate>
<div class="field liste-list__field">
<label for="ll-nome">Nome della nuova lista</label>
<input class="input" id="ll-nome" [formControl]="nome" placeholder="Es. Campo estivo 2026" />
@if (nome.hasError('required')) {
<p class="liste-list__campo-errore">Il nome della lista è obbligatorio.</p>
} @else if (nome.hasError('minlength')) {
<p class="liste-list__campo-errore">Il nome deve avere almeno 3 caratteri.</p>
} @else if (nome.hasError('maxlength')) {
<p class="liste-list__campo-errore">Il nome non può superare i 100 caratteri.</p>
}
</div>
<button type="submit" class="btn btn-primary" [disabled]="creazioneInCorso()">
{{ creazioneInCorso() ? 'Creazione in corso…' : 'Crea lista vuota' }}
</button>
</form>
@if (creazioneErrore(); as message) {
<p class="liste-list__error" role="alert">{{ message }}</p>
}
<div class="page">
<div class="page-header">
<h1 class="page-title page-title--sm">{{ titolo() }}</h1>
<a class="btn btn-primary" routerLink="/liste/nuova" [queryParams]="nuovaListaQueryParams">+ Nuova lista</a>
</div>
@if (loading()) {
<p class="om-empty">Caricamento liste…</p>
} @else if (loadError(); as message) {
<p class="liste-list__error" role="alert">{{ message }}</p>
} @else if (liste().length === 0) {
<p class="om-empty">Non hai ancora nessuna lista. Creane una qui sopra.</p>
} @else {
<div class="om-grid liste-list__elenco">
} @else if (liste().length > 0) {
<div class="lista">
@for (lista of liste(); track lista.id) {
<a class="card liste-list__voce" [routerLink]="['/liste', lista.id]">
<div class="card-title">{{ lista.nome }}</div>
<span class="liste-list__voci-count">{{ lista.voci.length }} voci</span>
</a>
<div class="riga">
<div class="riga-info">
<a class="riga-titolo" [routerLink]="dettaglioLink(lista)">{{ lista.nome }}</a>
<div class="riga-data">Creata il {{ dataCreazioneFmt(lista) }} · {{ lista.voci.length }} voci</div>
<div
class="riga-badge"
[style.color]="statoStile(lista.stato).color"
[style.background]="statoStile(lista.stato).bg"
[style.border-color]="statoStile(lista.stato).border"
>
{{ statoNome(lista.stato) }}
</div>
@if (haVociInAttesa(lista)) {
<div class="riga-nota" title="Contiene materiali in attesa di conferma da un moderatore">
⚠️ materiali in attesa di conferma
</div>
}
@if (inAttesaApprovazione(lista)) {
<div class="riga-nota" title="In attesa di approvazione da parte di un moderatore">
⏳ in attesa di approvazione
</div>
} @else if (rifiutata(lista)) {
<div class="riga-nota" title="Proposta rifiutata da un moderatore">
❌ proposta rifiutata
</div>
}
</div>
@if (puoCambiareStato(lista)) {
<div class="stato-options">
@for (stato of stati; track stato.id) {
<div
class="stato-option"
[class.stato-option--attivo]="isStatoAttivo(lista, stato)"
[style.background]="isStatoAttivo(lista, stato) ? statoStile(stato.id).bg : 'transparent'"
[style.color]="isStatoAttivo(lista, stato) ? statoStile(stato.id).color : 'color-mix(in srgb, var(--color-text) 55%, transparent)'"
[style.border-color]="isStatoAttivo(lista, stato) ? statoStile(stato.id).border : 'var(--color-divider)'"
(click)="cambiaStato(lista, stato)"
>
{{ stato.nome }}
</div>
}
</div>
} @else {
<div class="riga-nota" title="Solo chi ha creato la lista può cambiarne lo stato">
🔒 solo il creatore può cambiare lo stato
</div>
}
<div class="riga-modifica" (click)="modifica(lista)">Modifica</div>
</div>
}
</div>
} @else {
<div class="empty-state">
<div class="empty-icon">📋</div>
<div class="empty-title">{{ titoloVuoto() }}</div>
</div>
}
</section>
</div>
@@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { Lista, ListeApiService } from '../liste-api.service';
@@ -8,29 +8,73 @@ import { ListeList } from './liste-list';
describe('ListeList', () => {
let component: ListeList;
let fixture: ComponentFixture<ListeList>;
let listeApi: { getListe: ReturnType<typeof vi.fn>; creaListaVuota: ReturnType<typeof vi.fn> };
let router: Router;
let listeApi: { getListe: ReturnType<typeof vi.fn> };
const liste: Lista[] = [
{ id: 'lista-1', nome: 'Campo estivo 2026', orgId: 'org-1', creataIl: '2026-01-01', voci: [] },
{
id: 'lista-1',
nome: 'Campo estivo 2026',
orgId: 'org-1',
stato: 'bozza',
statoModerazione: null,
tipoEventoId: null,
parentId: null,
creataIl: '2026-01-01',
creataDaMe: true,
voci: [],
sottoListe: []
},
{
id: 'lista-2',
nome: 'Uscita di un giorno',
orgId: 'org-1',
stato: 'pubblico',
statoModerazione: 'approvato',
tipoEventoId: null,
parentId: null,
creataIl: '2026-01-02',
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }]
creataDaMe: true,
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }],
sottoListe: []
},
{
id: 'lista-3',
nome: 'Materiale sede',
orgId: 'org-1',
stato: 'gruppo',
statoModerazione: null,
tipoEventoId: null,
parentId: null,
creataIl: '2026-01-03',
creataDaMe: false,
voci: [],
sottoListe: []
},
{
id: 'lista-4',
nome: 'Materiale creato da me per il gruppo',
orgId: 'org-1',
stato: 'gruppo',
statoModerazione: null,
tipoEventoId: null,
parentId: null,
creataIl: '2026-01-04',
creataDaMe: true,
voci: [],
sottoListe: []
}
];
async function setup(): Promise<void> {
async function setup(scope: 'mie' | 'gruppo' = 'mie'): Promise<void> {
await TestBed.configureTestingModule({
imports: [ListeList],
providers: [provideRouter([]), { provide: ListeApiService, useValue: listeApi }]
providers: [
provideRouter([]),
{ provide: ListeApiService, useValue: listeApi },
{ provide: ActivatedRoute, useValue: { snapshot: { data: { scope } } } }
]
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(ListeList);
component = fixture.componentInstance;
fixture.detectChanges();
@@ -38,73 +82,98 @@ describe('ListeList', () => {
fixture.detectChanges();
}
it('mostra l\'elenco delle liste dopo il caricamento', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)), creaListaVuota: vi.fn() };
it('mostra le liste personali più le proprie liste di gruppo, escludendo quelle di gruppo altrui', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) };
await setup();
expect(component.loading()).toBe(false);
expect(component.liste()).toEqual(liste);
expect(component.liste()).toEqual(liste.filter((l) => l.stato !== 'gruppo' || l.creataDaMe));
const compiled = fixture.nativeElement as HTMLElement;
const links = compiled.querySelectorAll('.liste-list__elenco a');
expect(links.length).toBe(2);
const links = compiled.querySelectorAll('.riga-titolo');
expect(links.length).toBe(3);
expect(links[0].getAttribute('href')).toBe('/liste/lista-1');
expect(links[2].getAttribute('href')).toBe('/liste/lista-4');
});
it('con scope "gruppo" mostra tutte le liste di gruppo dell\'org, comprese quelle create da altri', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) };
await setup('gruppo');
expect(component.liste()).toEqual(liste.filter((l) => l.stato === 'gruppo'));
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Le nostre liste');
const links = compiled.querySelectorAll('.riga-titolo');
expect(links.length).toBe(2);
expect(links[0].getAttribute('href')).toBe('/liste/lista-3');
expect(links[1].getAttribute('href')).toBe('/liste/lista-4');
});
it('con scope "gruppo" nasconde il selettore di stato per le liste altrui e lo mostra per le proprie', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)) };
await setup('gruppo');
expect(component.puoCambiareStato(liste[2])).toBe(false); // lista-3, altrui
expect(component.puoCambiareStato(liste[3])).toBe(true); // lista-4, mia
const compiled = fixture.nativeElement as HTMLElement;
const righe = compiled.querySelectorAll('.riga');
expect(righe[0].querySelector('.stato-options')).toBeNull();
expect(righe[0].textContent).toContain('solo il creatore può cambiare lo stato');
expect(righe[1].querySelector('.stato-options')).not.toBeNull();
});
it('non invia la richiesta di cambio stato se non si è proprietari della lista di gruppo', async () => {
listeApi = {
getListe: vi.fn().mockReturnValue(of(liste)),
aggiornaLista: vi.fn()
} as unknown as typeof listeApi;
await setup('gruppo');
component.cambiaStato(liste[2], { id: 'privato', nome: 'Privato' });
expect((listeApi as unknown as { aggiornaLista: ReturnType<typeof vi.fn> }).aggiornaLista).not.toHaveBeenCalled();
});
it('mostra un bottone che porta alla pagina di creazione di una nuova lista', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of([])) };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
const link = compiled.querySelector('.page-header a');
expect(link?.getAttribute('href')).toBe('/liste/nuova');
});
it('con scope "gruppo" il bottone "+ Nuova lista" forza lo stato di partenza a gruppo', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of([])) };
await setup('gruppo');
const compiled = fixture.nativeElement as HTMLElement;
const link = compiled.querySelector('.page-header a');
expect(link?.getAttribute('href')).toBe('/liste/nuova?stato=gruppo');
});
it('mostra un messaggio se non ci sono liste', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of([])), creaListaVuota: vi.fn() };
listeApi = { getListe: vi.fn().mockReturnValue(of([])) };
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Non hai ancora nessuna lista');
expect(compiled.textContent).toContain('Non hai ancora creato nessuna lista');
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(throwError(() => new Error('network error'))), creaListaVuota: vi.fn() };
listeApi = { getListe: vi.fn().mockReturnValue(throwError(() => new Error('network error'))) };
await setup();
expect(component.loadError()).toBe('Impossibile caricare le tue liste. Riprova più tardi.');
});
describe('creazione di una nuova lista vuota', () => {
beforeEach(async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of([])), creaListaVuota: vi.fn() };
await setup();
});
it('non chiama l\'API se il nome non è valido e marca il controllo come touched', async () => {
component.nome.setValue('');
await component.creaLista();
expect(listeApi.creaListaVuota).not.toHaveBeenCalled();
expect(component.nome.touched).toBe(true);
});
it('crea la lista e reindirizza al suo editor', async () => {
component.nome.setValue('Campo estivo 2026');
listeApi.creaListaVuota.mockReturnValue(
of({ id: 'lista-nuova', nome: 'Campo estivo 2026', orgId: 'org-1', creataIl: '2026-01-01', voci: [] })
);
await component.creaLista();
expect(listeApi.creaListaVuota).toHaveBeenCalledWith('Campo estivo 2026');
expect(router.navigate).toHaveBeenCalledWith(['/liste', 'lista-nuova']);
expect(component.creazioneInCorso()).toBe(false);
});
it('mostra un messaggio di errore se la creazione fallisce', async () => {
component.nome.setValue('Campo estivo 2026');
listeApi.creaListaVuota.mockReturnValue(throwError(() => new Error('server error')));
await component.creaLista();
expect(component.creazioneErrore()).toBe('Impossibile creare la lista. Riprova più tardi.');
expect(router.navigate).not.toHaveBeenCalled();
});
expect(component.loadError()).toBe('Impossibile caricare le liste. Riprova più tardi.');
});
});
@@ -1,81 +1,125 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormControl, 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 { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Router, RouterLink } from '@angular/router';
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { Lista, ListeApiService } from '../liste-api.service';
import { STATI_LISTA, StatoLista, statoListaStyle } from '../lista.model';
// Route data['scope']: 'mie' (default, /liste) mostra le liste personali dell'utente
// più le proprie liste 'gruppo' (create da lui, anche se visibili a tutta l'org);
// 'gruppo' (/liste/gruppo) mostra tutte le liste 'gruppo' della sua organizzazione,
// comprese quelle create da altri membri, modificabili da chiunque ne faccia parte.
// Una lista 'gruppo' creata dall'utente compare quindi in entrambe le viste. GET
// /liste restituisce già l'unione di tutto (vedi liste.service.ts::listMieListe), qui
// si filtra solo client-side.
type Scope = 'mie' | 'gruppo';
@Component({
selector: 'app-liste-list',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule
],
imports: [RouterLink],
templateUrl: './liste-list.html',
styleUrl: './liste-list.css'
})
export class ListeList implements OnInit {
private readonly listeApi = inject(ListeApiService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly scope: Scope = (this.route.snapshot.data['scope'] as Scope | undefined) ?? 'mie';
readonly titolo = computed(() => (this.scope === 'gruppo' ? 'Le nostre liste' : 'Le mie liste'));
readonly titoloVuoto = computed(() =>
this.scope === 'gruppo'
? 'La tua organizzazione non ha ancora creato nessuna lista di gruppo'
: 'Non hai ancora creato nessuna lista'
);
// Da "Le nostre liste" la nuova lista nasce già 'gruppo' (vedi nuova-lista.ts, che
// legge questo query param per bloccare la scelta dello stato in creazione).
readonly nuovaListaQueryParams: Record<string, string> = this.scope === 'gruppo' ? { stato: 'gruppo' } : {};
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly liste = signal<Lista[]>([]);
readonly nome = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3), Validators.maxLength(100)]
});
readonly creazioneInCorso = signal(false);
readonly creazioneErrore = signal<string | null>(null);
readonly stati = STATI_LISTA;
async ngOnInit(): Promise<void> {
await this.caricaListe();
}
async creaLista(): Promise<void> {
if (this.creazioneInCorso()) {
return;
}
if (this.nome.invalid) {
this.nome.markAsTouched();
return;
}
this.creazioneErrore.set(null);
this.creazioneInCorso.set(true);
try {
const lista = await firstValueFrom(this.listeApi.creaListaVuota(this.nome.value.trim()));
this.creazioneInCorso.set(false);
await this.router.navigate(['/liste', lista.id]);
} catch {
this.creazioneInCorso.set(false);
this.creazioneErrore.set('Impossibile creare la lista. Riprova più tardi.');
}
}
private async caricaListe(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const liste = await firstValueFrom(this.listeApi.getListe());
this.liste.set(liste);
this.liste.set(
liste.filter((l) => (this.scope === 'gruppo' ? l.stato === 'gruppo' : l.stato !== 'gruppo' || l.creataDaMe))
);
} catch {
this.loadError.set('Impossibile caricare le tue liste. Riprova più tardi.');
this.loadError.set('Impossibile caricare le liste. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
dataCreazioneFmt(lista: Lista): string {
return new Intl.DateTimeFormat('it-IT', { day: '2-digit', month: '2-digit', year: 'numeric' }).format(
new Date(lista.creataIl),
);
}
modifica(lista: Lista): void {
this.router.navigate(['/liste', lista.id]);
}
// Le liste pubbliche approvate hanno una vista dedicata (stessa usata dal catalogo,
// GET /liste/pubbliche/:id); le altre non sono raggiungibili da quella route (404),
// quindi il click sul nome apre comunque l'editor.
dettaglioLink(lista: Lista): string[] {
return lista.stato === 'pubblico' && lista.statoModerazione === 'approvato'
? ['/lista-modello', lista.id]
: ['/liste', lista.id];
}
statoStile(idStato: string) {
return statoListaStyle(idStato);
}
statoNome(idStato: string): string {
return STATI_LISTA.find((s) => s.id === idStato)?.nome ?? idStato;
}
haVociInAttesa(lista: Lista): boolean {
return lista.voci.some((v) => v.inAttesaConferma);
}
inAttesaApprovazione(lista: Lista): boolean {
return lista.stato === 'pubblico' && lista.statoModerazione === 'proposto';
}
rifiutata(lista: Lista): boolean {
return lista.stato === 'pubblico' && lista.statoModerazione === 'rifiutato';
}
isStatoAttivo(lista: Lista, stato: StatoLista): boolean {
return lista.stato === stato.id;
}
// Il contenuto di una lista 'gruppo' è collaborativo, ma solo chi l'ha creata può
// cambiarne lo stato (es. toglierla dal gruppo): altrimenti un membro qualsiasi
// potrebbe far sparire ad altri una lista condivisa. Vedi liste.service.ts::aggiornaLista.
puoCambiareStato(lista: Lista): boolean {
return lista.stato !== 'gruppo' || lista.creataDaMe;
}
cambiaStato(lista: Lista, stato: StatoLista): void {
if (lista.stato === stato.id || !this.puoCambiareStato(lista)) {
return;
}
this.listeApi.aggiornaLista(lista.id, { stato: stato.id }).subscribe({
next: () => this.caricaListe(),
error: () => this.loadError.set('Impossibile cambiare stato alla lista. Riprova più tardi.')
});
}
}
@@ -6,11 +6,25 @@ export const LISTE_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./liste-list/liste-list').then((m) => m.ListeList),
canActivate: [requireAuthGuard],
data: { scope: 'mie' }
},
{
// Dichiarata prima di ':id' (come 'nuova' sotto) per non essere intercettata dal
// catch-all di modifica.
path: 'gruppo',
loadComponent: () => import('./liste-list/liste-list').then((m) => m.ListeList),
canActivate: [requireAuthGuard],
data: { scope: 'gruppo' }
},
{
path: 'nuova',
loadComponent: () => import('./nuova-lista/nuova-lista').then((m) => m.NuovaLista),
canActivate: [requireAuthGuard]
},
{
path: ':id',
loadComponent: () => import('./lista-editor/lista-editor').then((m) => m.ListaEditor),
loadComponent: () => import('./nuova-lista/nuova-lista').then((m) => m.NuovaLista),
canActivate: [requireAuthGuard]
}
];
@@ -0,0 +1,328 @@
/* Stile del wizard replicato 1:1 da scouthub-attivita-fe (pagina "Nuova attività"):
stesso font, stessa dimensione testo, stessa spaziatura. */
.page {
max-width: 1080px;
margin: 0 auto;
padding: 32px 24px 64px;
}
.wizard-page {
max-width: 760px;
padding-bottom: 80px;
}
.titolo {
font-size: 26px;
margin: 0 0 6px;
font-weight: 700;
}
.steps {
display: flex;
gap: 6px;
margin: 18px 0 28px;
flex-wrap: wrap;
}
.step-tab {
cursor: pointer;
flex: 1;
min-width: 100px;
text-align: center;
padding: 10px 8px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
background: var(--color-surface);
color: var(--color-text);
}
.step-tab--attivo {
background: var(--color-accent);
color: #fff;
}
.step-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.errore-inline {
color: var(--color-accent-800);
font-size: 13px;
}
.field {
font-weight: 700;
font-size: 14px;
}
.text-input {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 6px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--color-divider);
font-size: 16px;
font-family: inherit;
background: var(--color-surface);
color: var(--color-text);
}
.text-input--con-clear {
padding-right: 34px;
}
.text-input--sm {
margin-top: 0;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
}
.stato-choices {
display: flex;
gap: 8px;
margin-top: 6px;
flex-wrap: wrap;
}
.stato-choice {
cursor: pointer;
padding: 9px 16px;
border-radius: 8px;
font-weight: 700;
font-size: 14px;
border: 1px solid var(--color-divider);
}
.stato-choices--disabilitato .stato-choice {
cursor: not-allowed;
opacity: 0.5;
}
.search-field {
position: relative;
}
.search-input-wrap {
position: relative;
}
.input-clear-btn {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
cursor: pointer;
padding: 2px 6px;
color: var(--color-accent-800);
}
.suggestions {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: 10px;
box-shadow: var(--shadow-md);
z-index: 5;
max-height: 360px;
overflow: auto;
}
.suggestion-group-label {
padding: 10px 16px 4px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.suggestion-item {
padding: 10px 16px;
cursor: pointer;
font-size: 15px;
font-weight: 400;
}
.suggestion-item:hover {
background: var(--color-accent-100);
}
.nessun-risultato {
color: var(--color-text);
opacity: 0.7;
font-size: 14px;
margin: 0;
}
.link-proponi {
cursor: pointer;
color: var(--color-accent);
opacity: 1;
font-weight: 600;
margin-left: 4px;
}
.proponi-form {
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: 10px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.proponi-form__titolo {
font-weight: 700;
font-size: 14px;
}
.field--sm {
font-size: 13px;
font-weight: 600;
color: color-mix(in srgb, var(--color-text) 70%, transparent);
}
.proponi-form__azioni {
display: flex;
gap: 8px;
}
.voce-attesa {
margin-left: 6px;
cursor: help;
}
.voci-tabella {
display: flex;
flex-direction: column;
gap: 8px;
}
.voce-riga {
display: flex;
align-items: center;
gap: var(--space-3);
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: 10px;
padding: 10px 14px;
}
.voce-nome {
flex: 1;
font-size: 14px;
}
.voce-quantita {
width: 4.5rem;
flex: none;
}
.voce-unita {
font-size: 13px;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.azione {
cursor: pointer;
padding: 2px 8px;
}
.azione--danger {
color: var(--color-accent-800);
}
.riepilogo {
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: 14px;
padding: 20px;
}
.riepilogo-nome {
font-weight: 700;
font-size: 19px;
margin-bottom: 10px;
}
.riepilogo-stato {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid;
margin-bottom: 14px;
}
.riepilogo-voci {
margin: 16px 0 0;
padding-left: 0;
list-style: none;
font-size: 15px;
display: flex;
flex-direction: column;
gap: 8px;
}
.riepilogo-voce {
display: flex;
align-items: center;
gap: 10px;
cursor: default;
}
.riepilogo-voce input[type='checkbox'] {
cursor: default;
}
.riepilogo-voci-vuoto {
font-size: 14px;
opacity: 0.7;
}
.wizard-nav {
display: flex;
justify-content: space-between;
margin-top: 28px;
}
.btn-primary {
cursor: pointer;
display: inline-block;
padding: 11px 18px;
border-radius: 10px;
background: var(--color-accent);
color: #fff;
font-weight: 700;
border: none;
font-family: inherit;
font-size: 15px;
}
.btn-primary:hover {
background: var(--color-accent-600);
}
.btn-secondary {
cursor: pointer;
display: inline-block;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-divider);
font-weight: 600;
font-size: 14px;
background: transparent;
color: var(--color-text);
font-family: inherit;
}
@@ -0,0 +1,306 @@
<div class="page wizard-page">
<h1 class="titolo">{{ titolo() }}</h1>
@if (caricamentoModifica()) {
<p class="om-empty">Caricamento lista…</p>
} @else if (caricamentoErrore(); as message) {
<p class="errore-inline" role="alert">{{ message }}</p>
} @else {
<div class="steps">
@for (label of stepLabels; track $index) {
<div class="step-tab" [class.step-tab--attivo]="step() === $index" (click)="setStep($index)">
{{ $index + 1 }}. {{ label }}
</div>
}
</div>
@if (step() === 0) {
<div class="step-content">
<label class="field">
Nome lista
<input
class="text-input"
type="text"
[value]="nome()"
(input)="nome.set($any($event.target).value)"
placeholder="Es. Campo estivo 2026"
/>
@if (nomeErrore()) {
<div class="errore-inline">{{ nomeErrore() }}</div>
}
</label>
<label class="field">
{{ editingId() ? 'Stato' : 'Stato iniziale' }}
<div class="stato-choices" [class.stato-choices--disabilitato]="!puoModificareStato()">
@for (s of stati; track s.id) {
<div
class="stato-choice"
[class.stato-choice--attivo]="stato().id === s.id"
[style.background]="stato().id === s.id ? statoStile(s.id).bg : 'transparent'"
[style.color]="stato().id === s.id ? statoStile(s.id).color : 'var(--color-text)'"
[style.border-color]="stato().id === s.id ? statoStile(s.id).border : 'var(--color-divider)'"
(click)="selectStato(s)"
>
{{ s.nome }}
</div>
}
</div>
@if (!puoModificareStato()) {
<div class="errore-inline">
@if (editingId()) {
🔒 solo il creatore può cambiare lo stato
} @else {
🔒 le liste create da "Le nostre liste" sono sempre di gruppo
}
</div>
}
</label>
<label class="field search-field">
Tipo evento (opzionale)
<div class="search-input-wrap">
<input
class="text-input"
[class.text-input--con-clear]="tipoEventoIdSelezionato()"
type="text"
[value]="tipoEventoTesto()"
(input)="onTipoEventoInput($any($event.target).value)"
placeholder="Es. Campo estivo"
/>
@if (tipoEventoIdSelezionato()) {
<span class="input-clear-btn" (click)="rimuoviTipoEvento()"></span>
}
@if (tipoEventoRisultati().length > 0 && !tipoEventoIdSelezionato()) {
<div class="suggestions">
@for (t of tipoEventoRisultati(); track t.id) {
<div class="suggestion-item" (click)="selezionaTipoEvento(t)">
{{ t.nome }}
</div>
}
</div>
}
</div>
</label>
@if (tipoEventoRicercaInCorso()) {
<p class="nessun-risultato">Ricerca in corso…</p>
} @else if (tipoEventoAppenaProposto()) {
<p class="nessun-risultato">Tipo evento "{{ tipoEventoTesto() }}" proposto, in attesa di approvazione.</p>
} @else if (mostraProponiTipoEvento()) {
<p class="nessun-risultato">
Nessun tipo evento esistente corrisponde.
<span class="link-proponi" (click)="proponiNuovoTipoEvento()">
{{ tipoEventoProponiInCorso() ? 'Invio in corso…' : '+ Proponi "' + tipoEventoTesto() + '" come nuovo tipo evento' }}
</span>
</p>
}
@if (tipoEventoProponiErrore()) {
<div class="errore-inline">{{ tipoEventoProponiErrore() }}</div>
}
</div>
}
@if (step() === 1) {
<div class="step-content">
<label class="field search-field">
Cerca materiale o lista da aggiungere
<div class="search-input-wrap">
<input
class="text-input"
type="text"
[value]="ricerca()"
(input)="ricerca.set($any($event.target).value)"
placeholder="Es. corda, Kit pronto soccorso..."
/>
@if (suggestionGroups().length > 0) {
<div class="suggestions">
@for (group of suggestionGroups(); track group.label) {
<div class="suggestion-group-label">{{ group.label }}</div>
@for (item of group.items; track item.id) {
<div class="suggestion-item" (click)="selectSuggestion(group.tipo, item.id)">
{{ item.nome }}
</div>
}
}
</div>
}
</div>
</label>
@if (ricercaInCorso()) {
<p class="nessun-risultato">Ricerca in corso…</p>
} @else if (nessunRisultato() && !mostraFormProposta()) {
<p class="nessun-risultato">
Nessun risultato per "{{ ricerca() }}".
<span class="link-proponi" (click)="apriFormProposta()">+ Proponi "{{ ricerca() }}" come nuovo materiale</span>
</p>
}
@if (mostraFormProposta()) {
<div class="proponi-form">
<div class="proponi-form__titolo">Proponi "{{ ricerca() }}" come nuovo materiale</div>
<label class="field field--sm search-field">
Categoria
<div class="search-input-wrap">
<input
class="text-input text-input--sm"
type="text"
[value]="proponiCategoria()"
(input)="proponiCategoria.set($any($event.target).value)"
placeholder="Es. Attrezzatura"
/>
@if (categorieRicerca().length > 0 && !categoriaCorrispondeEsistente()) {
<div class="suggestions">
@for (cat of categorieRicerca(); track cat.id) {
<div class="suggestion-item" (click)="selezionaCategoria(cat)">
{{ cat.nome }}
</div>
}
</div>
}
</div>
</label>
@if (categoriaRicercaInCorso()) {
<p class="nessun-risultato">Ricerca categoria in corso…</p>
} @else if (categoriaAppenaProposta()) {
<p class="nessun-risultato">Categoria "{{ proponiCategoria() }}" proposta, in attesa di approvazione.</p>
} @else if (mostraProponiCategoria()) {
<p class="nessun-risultato">
Nessuna categoria esistente corrisponde.
<span class="link-proponi" (click)="proponiNuovaCategoria()">
{{ proponiCategoriaInCorso() ? 'Invio in corso…' : '+ Proponi "' + proponiCategoria() + '" come nuova categoria' }}
</span>
</p>
}
@if (proponiCategoriaErrore()) {
<div class="errore-inline">{{ proponiCategoriaErrore() }}</div>
}
<label class="field field--sm">
Unità di misura
<input
class="text-input text-input--sm"
type="text"
[value]="proponiUnitaMisura()"
(input)="proponiUnitaMisura.set($any($event.target).value)"
placeholder="Es. pz"
/>
</label>
@if (proponiErrore()) {
<div class="errore-inline">{{ proponiErrore() }}</div>
}
<div class="proponi-form__azioni">
<div class="btn-secondary" (click)="annullaProposta()">Annulla</div>
<div class="btn-primary" (click)="confermaProposta()">
{{ proponiInCorso() ? 'Invio in corso…' : 'Proponi materiale' }}
</div>
</div>
</div>
}
@if (voci().length > 0) {
<div class="voci-tabella">
@for (voce of voci(); track voce.materialeId) {
<div class="voce-riga">
<span class="voce-nome">
{{ voce.nome }}
@if (voce.inAttesaConferma) {
<span class="voce-attesa" title="In attesa di conferma da parte di un moderatore">⚠️</span>
}
</span>
<input
type="number"
min="1"
step="1"
class="text-input text-input--sm voce-quantita"
[value]="voce.quantita"
(change)="modificaQuantita(voce.materialeId, $any($event.target).value)"
/>
<span class="voce-unita">{{ voce.unitaMisura }}</span>
<span class="azione azione--danger" (click)="rimuoviVoce(voce.materialeId)"></span>
</div>
}
</div>
}
@if (sottoListeSelezionate().length > 0) {
<div class="voci-tabella">
@for (sottoLista of sottoListeSelezionate(); track sottoLista.id) {
<div class="voce-riga">
<span class="voce-nome">
{{ sottoLista.nome }}
<span class="voce-unita">{{ sottoLista.origine === 'modello' ? '(dal catalogo)' : '(tua lista)' }}</span>
</span>
<span class="azione azione--danger" (click)="rimuoviSottoLista(sottoLista.id)"></span>
</div>
}
</div>
}
</div>
}
@if (step() === 2) {
<div class="riepilogo">
<div class="riepilogo-nome">{{ nome() }}</div>
<div
class="riepilogo-stato"
[style.color]="statoStile(stato().id).color"
[style.background]="statoStile(stato().id).bg"
[style.border-color]="statoStile(stato().id).border"
>
{{ stato().nome }}
</div>
<ul class="riepilogo-voci">
@for (voce of voci(); track voce.materialeId) {
<li>
<label class="riepilogo-voce">
<input type="checkbox" disabled />
<span>
{{ voce.nome }} — {{ voce.quantita }} {{ voce.unitaMisura }}
@if (voce.inAttesaConferma) {
<span class="voce-attesa" title="In attesa di conferma da parte di un moderatore">⚠️</span>
}
</span>
</label>
</li>
} @empty {
<li class="riepilogo-voci-vuoto">Nessun materiale in lista.</li>
}
</ul>
@if (sottoListeSelezionate().length > 0) {
<ul class="riepilogo-voci">
@for (sottoLista of sottoListeSelezionate(); track sottoLista.id) {
<li>
<label class="riepilogo-voce">
<input type="checkbox" disabled />
<span>
{{ sottoLista.nome }}
<span class="voce-unita">{{ sottoLista.origine === 'modello' ? '(dal catalogo)' : '(tua lista)' }}</span>
</span>
</label>
</li>
}
</ul>
}
@if (salvataggioErrore()) {
<div class="errore-inline">{{ salvataggioErrore() }}</div>
}
</div>
}
<div class="wizard-nav">
<div class="btn-secondary" [style.visibility]="step() === 0 ? 'hidden' : 'visible'" (click)="prevStep()">
Indietro
</div>
@if (isLastStep()) {
<div class="btn-primary" (click)="salva()">
{{ salvataggioInCorso() ? (editingId() ? 'Salvataggio in corso…' : 'Creazione in corso…') : (editingId() ? 'Salva modifiche' : 'Salva lista') }}
</div>
} @else {
<div class="btn-primary" (click)="nextStep()">Avanti</div>
}
</div>
}
</div>
@@ -0,0 +1,422 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { MaterialiApiService } from '../../catalogo/materiali-api.service';
import { Lista, ListeApiService } from '../liste-api.service';
import { NuovaLista } from './nuova-lista';
describe('NuovaLista', () => {
let component: NuovaLista;
let fixture: ComponentFixture<NuovaLista>;
let listeApi: {
creaLista: ReturnType<typeof vi.fn>;
aggiornaLista: ReturnType<typeof vi.fn>;
getListe: ReturnType<typeof vi.fn>;
getListePubbliche: ReturnType<typeof vi.fn>;
};
let materialiApi: { getMateriali: ReturnType<typeof vi.fn>; proponiMateriale: ReturnType<typeof vi.fn> };
let router: Router;
async function attendiDebounce(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 350));
fixture.detectChanges();
}
async function setup(idParam: string | null = null, queryParam: string | null = null): Promise<void> {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [NuovaLista],
providers: [
provideRouter([]),
{ provide: ListeApiService, useValue: listeApi },
{ provide: MaterialiApiService, useValue: materialiApi },
{
provide: ActivatedRoute,
useValue: {
snapshot: { paramMap: { get: () => idParam }, queryParamMap: { get: () => queryParam } }
}
}
]
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(NuovaLista);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(async () => {
listeApi = {
creaLista: vi.fn(),
aggiornaLista: vi.fn(),
getListe: vi.fn().mockReturnValue(of([])),
getListePubbliche: vi.fn().mockReturnValue(of([]))
};
materialiApi = { getMateriali: vi.fn().mockReturnValue(of([])), proponiMateriale: vi.fn() };
await setup();
});
it('parte dallo step 0 con stato iniziale Bozza', () => {
expect(component.step()).toBe(0);
expect(component.stato().id).toBe('bozza');
});
it('non avanza dallo step 0 se il nome è troppo corto', () => {
component.nome.set('ab');
component.nextStep();
expect(component.step()).toBe(0);
expect(component.nomeErrore()).toBe('Il nome della lista deve avere almeno 3 caratteri.');
});
it('avanza tra gli step e permette di cambiare stato', () => {
component.nome.set('Campo estivo 2026');
component.nextStep();
expect(component.step()).toBe(1);
component.nextStep();
expect(component.step()).toBe(2);
component.prevStep();
expect(component.step()).toBe(1);
});
it('aggiunge e rimuove materiali dalla lista in costruzione', () => {
component.aggiungiMateriale({ id: 'm-1', nome: 'Corda', categoria: 'x', unitaMisura: 'pz' });
expect(component.voci()).toEqual([{ materialeId: 'm-1', nome: 'Corda', unitaMisura: 'pz', quantita: 1 }]);
component.rimuoviVoce('m-1');
expect(component.voci()).toEqual([]);
});
it('crea la lista con nome, stato e voci selezionate, poi reindirizza a "Le mie liste"', async () => {
component.nome.set('Campo estivo 2026');
component.selectStato({ id: 'pubblico', nome: 'Pubblico' });
component.aggiungiMateriale({ id: 'm-1', nome: 'Corda', categoria: 'x', unitaMisura: 'pz' });
listeApi.creaLista.mockReturnValue(
of({ id: 'lista-nuova', nome: 'Campo estivo 2026', orgId: 'org-1', stato: 'pubblico', creataIl: '2026-01-01', voci: [] })
);
await component.salva();
expect(listeApi.creaLista).toHaveBeenCalledWith({
nome: 'Campo estivo 2026',
stato: 'pubblico',
voci: [{ materialeId: 'm-1', quantita: 1 }],
sottoListeIds: [],
sottoListeModelloIds: []
});
expect(router.navigate).toHaveBeenCalledWith(['/liste']);
});
it('crea una lista di gruppo e reindirizza a "Le nostre liste"', async () => {
component.nome.set('Materiale sede');
component.selectStato({ id: 'gruppo', nome: 'Gruppo' });
listeApi.creaLista.mockReturnValue(
of({ id: 'lista-nuova', nome: 'Materiale sede', orgId: 'org-1', stato: 'gruppo', creataIl: '2026-01-01', voci: [] })
);
await component.salva();
expect(router.navigate).toHaveBeenCalledWith(['/liste/gruppo']);
});
it('mostra un messaggio di errore se la creazione fallisce', async () => {
component.nome.set('Campo estivo 2026');
listeApi.creaLista.mockReturnValue(throwError(() => new Error('server error')));
await component.salva();
expect(component.salvataggioErrore()).toBe('Impossibile creare la lista. Riprova più tardi.');
expect(router.navigate).not.toHaveBeenCalled();
});
describe('ricerca materiale via backend (debounced)', () => {
it('chiama il backend con la query dopo il debounce, non ad ogni digitazione', async () => {
materialiApi.getMateriali.mockReturnValue(of([{ id: 'm-1', nome: 'Corda', categoria: 'x', unitaMisura: 'pz' }]));
component.ricerca.set('cor');
component.ricerca.set('cord');
component.ricerca.set('corda');
expect(materialiApi.getMateriali).not.toHaveBeenCalled();
await attendiDebounce();
expect(materialiApi.getMateriali).toHaveBeenCalledTimes(1);
expect(materialiApi.getMateriali).toHaveBeenCalledWith('corda');
expect(component.risultatiRicerca().map((m) => m.id)).toEqual(['m-1']);
});
it('esclude dai risultati i materiali già aggiunti alla lista', async () => {
component.aggiungiMateriale({ id: 'm-1', nome: 'Corda', categoria: 'x', unitaMisura: 'pz' });
materialiApi.getMateriali.mockReturnValue(
of([
{ id: 'm-1', nome: 'Corda', categoria: 'x', unitaMisura: 'pz' },
{ id: 'm-2', nome: 'Corda sottile', categoria: 'x', unitaMisura: 'pz' }
])
);
component.ricerca.set('corda');
await attendiDebounce();
expect(component.risultatiRicerca().map((m) => m.id)).toEqual(['m-2']);
});
it('propone un nuovo materiale se non esiste, e lo aggiunge come "in attesa di conferma"', async () => {
materialiApi.getMateriali.mockReturnValue(of([]));
component.ricerca.set('bussola');
await attendiDebounce();
expect(component.nessunRisultato()).toBe(true);
component.apriFormProposta();
component.proponiCategoria.set('Orientamento');
component.proponiUnitaMisura.set('pz');
materialiApi.proponiMateriale.mockReturnValue(
of({
id: 'm-nuovo',
nome: 'bussola',
categoria: 'Orientamento',
unitaMisura: 'pz',
stato: 'proposto',
propostoDaOrgId: 'org-1',
creatoIl: '2026-01-01'
})
);
await component.confermaProposta();
expect(materialiApi.proponiMateriale).toHaveBeenCalledWith({
nome: 'bussola',
categoria: 'Orientamento',
unitaMisura: 'pz'
});
expect(component.voci()).toEqual([
{ materialeId: 'm-nuovo', nome: 'bussola', unitaMisura: 'pz', quantita: 1, inAttesaConferma: true }
]);
expect(component.mostraFormProposta()).toBe(false);
});
it('non propone il materiale se categoria o unità di misura mancano', async () => {
component.ricerca.set('bussola');
component.apriFormProposta();
await component.confermaProposta();
expect(materialiApi.proponiMateriale).not.toHaveBeenCalled();
expect(component.proponiErrore()).toBe('Nome, categoria e unità di misura sono obbligatori.');
});
});
describe('ricerca unificata di materiale e liste (un solo campo, come "Cerca lista")', () => {
beforeEach(async () => {
listeApi.getListePubbliche.mockReturnValue(
of([{ id: 'lm-1', nome: 'Kit pronto soccorso', orgId: null, stato: 'pubblico', statoModerazione: 'approvato', tipoEventoId: null, parentId: null, creataIl: '2026-01-01', voci: [], sottoListe: [] }])
);
listeApi.getListe.mockReturnValue(
of([{ id: 'l-1', nome: 'Campo estivo 2025', orgId: null, stato: 'privato', statoModerazione: null, tipoEventoId: null, parentId: null, creataIl: '2025-01-01', voci: [], sottoListe: [] }])
);
await setup();
materialiApi.getMateriali.mockReturnValue(of([{ id: 'm-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' }]));
});
it('raggruppa i suggerimenti per Materiale, Liste dal catalogo e Le tue liste', async () => {
component.ricerca.set('c');
await attendiDebounce();
const gruppi = component.suggestionGroups();
expect(gruppi.map((g) => g.label)).toEqual(['Materiale', 'Liste dal catalogo', 'Le tue liste']);
expect(gruppi[0].items).toEqual([{ id: 'm-1', nome: 'Corda (Attrezzatura)' }]);
expect(gruppi[1].items).toEqual([{ id: 'lm-1', nome: 'Kit pronto soccorso' }]);
expect(gruppi[2].items).toEqual([{ id: 'l-1', nome: 'Campo estivo 2025' }]);
});
it('selezionando un suggerimento di tipo materiale lo aggiunge alla lista', async () => {
component.ricerca.set('corda');
await attendiDebounce();
component.selectSuggestion('materiale', 'm-1');
expect(component.voci().map((v) => v.materialeId)).toEqual(['m-1']);
expect(component.ricerca()).toBe('');
});
it('selezionando un suggerimento di tipo lista dal catalogo lo aggiunge come sotto-lista', async () => {
component.ricerca.set('kit');
await attendiDebounce();
component.selectSuggestion('listaModello', 'lm-1');
expect(component.sottoListeSelezionate()).toEqual([{ id: 'lm-1', nome: 'Kit pronto soccorso', origine: 'modello' }]);
});
it('selezionando una tua lista la aggiunge come sotto-lista', async () => {
component.ricerca.set('campo');
await attendiDebounce();
component.selectSuggestion('listaPersonale', 'l-1');
expect(component.sottoListeSelezionate()).toEqual([{ id: 'l-1', nome: 'Campo estivo 2025', origine: 'personale' }]);
});
});
describe('modalità modifica (route /liste/:id)', () => {
const listaEsistente: Lista = {
id: 'lista-1',
nome: 'Campo estivo 2026',
orgId: null,
stato: 'bozza',
statoModerazione: null,
tipoEventoId: null,
parentId: null,
creataIl: '2026-01-01',
creataDaMe: true,
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }],
sottoListe: []
};
beforeEach(async () => {
listeApi.getListe.mockReturnValue(of([listaEsistente]));
await setup('lista-1');
});
it('precompila nome, stato e voci della lista esistente', () => {
expect(component.titolo()).toBe('Modifica lista');
expect(component.nome()).toBe('Campo estivo 2026');
expect(component.stato().id).toBe('bozza');
expect(component.voci()).toEqual([{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }]);
expect(component.caricamentoErrore()).toBeNull();
});
it('mostra un errore se la lista non viene trovata tra le mie liste', async () => {
listeApi.getListe.mockReturnValue(of([]));
await setup('lista-inesistente');
expect(component.caricamentoErrore()).toBe('Lista non trovata.');
});
it('al salvataggio chiama aggiornaLista e torna a "Le mie liste"', async () => {
component.nome.set('Campo estivo 2026 - aggiornato');
listeApi.aggiornaLista.mockReturnValue(of({ ...listaEsistente, nome: 'Campo estivo 2026 - aggiornato' }));
await component.salva();
expect(listeApi.aggiornaLista).toHaveBeenCalledWith('lista-1', {
nome: 'Campo estivo 2026 - aggiornato',
stato: 'bozza',
voci: [{ materialeId: 'mat-1', quantita: 2 }],
sottoListeIds: [],
sottoListeModelloIds: [],
tipoEventoId: null
});
expect(listeApi.creaLista).not.toHaveBeenCalled();
expect(router.navigate).toHaveBeenCalledWith(['/liste']);
});
it('mostra un messaggio di errore se il salvataggio fallisce', async () => {
listeApi.aggiornaLista.mockReturnValue(throwError(() => new Error('server error')));
await component.salva();
expect(component.salvataggioErrore()).toBe('Impossibile salvare le modifiche. Riprova più tardi.');
});
});
describe('modalità modifica di una lista di gruppo non propria', () => {
const listaGruppoAltrui: Lista = {
id: 'lista-2',
nome: 'Materiale sede',
orgId: 'org-1',
stato: 'gruppo',
statoModerazione: null,
tipoEventoId: null,
parentId: null,
creataIl: '2026-01-01',
creataDaMe: false,
voci: [],
sottoListe: []
};
beforeEach(async () => {
listeApi.getListe.mockReturnValue(of([listaGruppoAltrui]));
await setup('lista-2');
});
it('non permette di cambiare lo stato', () => {
expect(component.puoModificareStato()).toBe(false);
component.selectStato(component.stati.find((s) => s.id === 'privato')!);
expect(component.stato().id).toBe('gruppo');
});
it('nasconde il selettore di stato nel template mostrando una nota', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.stato-choices--disabilitato')).not.toBeNull();
expect(compiled.textContent).toContain('solo il creatore può cambiare lo stato');
});
});
describe('creazione da "Le nostre liste" (?stato=gruppo)', () => {
beforeEach(async () => {
await setup(null, 'gruppo');
});
it('precompila lo stato a "gruppo" e non permette di cambiarlo', () => {
expect(component.stato().id).toBe('gruppo');
expect(component.puoModificareStato()).toBe(false);
component.selectStato(component.stati.find((s) => s.id === 'bozza')!);
expect(component.stato().id).toBe('gruppo');
});
it('nasconde il selettore di stato nel template mostrando la nota di creazione da gruppo', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.stato-choices--disabilitato')).not.toBeNull();
expect(compiled.textContent).toContain('le liste create da "Le nostre liste" sono sempre di gruppo');
});
it('al salvataggio crea la lista con stato "gruppo"', async () => {
component.nome.set('Materiale sede');
listeApi.creaLista.mockReturnValue(
of({
id: 'lista-nuova',
nome: 'Materiale sede',
orgId: 'org-1',
stato: 'gruppo',
statoModerazione: null,
tipoEventoId: null,
parentId: null,
creataIl: '2026-01-01',
creataDaMe: true,
voci: [],
sottoListe: []
})
);
await component.salva();
expect(listeApi.creaLista).toHaveBeenCalledWith(
expect.objectContaining({ nome: 'Materiale sede', stato: 'gruppo' })
);
});
});
describe('creazione normale (senza query param)', () => {
it('non forza alcuno stato: la scelta resta libera', () => {
expect(component.puoModificareStato()).toBe(true);
expect(component.stato().id).toBe('bozza');
});
});
});
@@ -0,0 +1,578 @@
import { Component, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router } from '@angular/router';
import { catchError, debounceTime, distinctUntilChanged, firstValueFrom, of, switchMap } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../../catalogo/materiali-api.service';
import { TipoEvento, TipiEventoApiService } from '../../catalogo/tipi-evento-api.service';
import { Categoria, CategorieApiService } from '../../tassonomie/categorie-api.service';
import { Lista, ListaVoceInput, ListeApiService } from '../liste-api.service';
import { STATI_LISTA, STATO_BOZZA, StatoLista, statoListaStyle } from '../lista.model';
const STEP_LABELS = ['Info base', 'Materiale', 'Riepilogo'];
const AUTOCOMPLETE_DEBOUNCE_MS = 300;
interface VoceForm {
materialeId: string;
nome: string;
unitaMisura: string;
quantita: number;
inAttesaConferma?: boolean;
}
interface SottoListaForm {
id: string;
nome: string;
origine: 'modello' | 'personale';
}
@Component({
selector: 'app-nuova-lista',
imports: [],
templateUrl: './nuova-lista.html',
styleUrl: './nuova-lista.css'
})
export class NuovaLista {
private readonly route = inject(ActivatedRoute);
private readonly listeApi = inject(ListeApiService);
private readonly materialiApi = inject(MaterialiApiService);
private readonly categorieApi = inject(CategorieApiService);
private readonly tipiEventoApi = inject(TipiEventoApiService);
private readonly router = inject(Router);
// Presente solo su /liste/:id: stesso wizard di creazione, ma precompilato e con
// salva() che aggiorna la lista esistente invece di crearne una nuova.
readonly editingId = signal<string | null>(null);
readonly caricamentoModifica = signal(false);
readonly caricamentoErrore = signal<string | null>(null);
readonly step = signal(0);
readonly stepLabels = STEP_LABELS;
readonly stati = STATI_LISTA;
readonly titolo = computed(() => (this.editingId() ? 'Modifica lista' : 'Nuova lista'));
readonly nome = signal('');
readonly stato = signal<StatoLista>(STATO_BOZZA);
readonly voci = signal<VoceForm[]>([]);
// Tipo evento (opzionale, step 0): stesso pattern di autocomplete + proposta usato
// per categoria nel form materiale, ma qui la selezione è a valore singolo (un id) e
// non un semplice testo libero, perché tipoEventoId è una vera FK su Lista.
readonly tipoEventoTesto = signal('');
readonly tipoEventoIdSelezionato = signal<string | null>(null);
readonly tipoEventoRisultati = signal<TipoEvento[]>([]);
readonly tipoEventoRicercaInCorso = signal(false);
readonly tipoEventoAppenaProposto = signal(false);
readonly tipoEventoProponiInCorso = signal(false);
readonly tipoEventoProponiErrore = signal<string | null>(null);
readonly tipoEventoCorrispondeEsistente = computed(() =>
this.tipoEventoRisultati().some((t) => t.nome.toLowerCase() === this.tipoEventoTesto().trim().toLowerCase())
);
readonly mostraProponiTipoEvento = computed(
() =>
!this.tipoEventoRicercaInCorso() &&
!this.tipoEventoAppenaProposto() &&
!this.tipoEventoIdSelezionato() &&
!this.tipoEventoCorrispondeEsistente() &&
this.tipoEventoTesto().trim().length > 0
);
// Solo in modifica di una lista 'gruppo' non propria: il contenuto è collaborativo,
// ma cambiarne lo stato resta una decisione di chi l'ha creata (vedi
// liste.service.ts::aggiornaLista e liste-list.ts::puoCambiareStato). Sempre true in
// creazione o per qualunque altra lista.
readonly puoModificareStato = signal(true);
readonly ricerca = signal('');
readonly risultatiRicerca = signal<MaterialePubblico[]>([]);
readonly ricercaInCorso = signal(false);
readonly mostraFormProposta = signal(false);
readonly proponiCategoria = signal('');
readonly proponiUnitaMisura = signal('');
readonly proponiInCorso = signal(false);
readonly proponiErrore = signal<string | null>(null);
// Autocomplete categoria nel form di proposta materiale: cerca tra le categorie
// già confermate; se il testo digitato non corrisponde a nessuna, offre un tasto
// per proporne una nuova (workflow separato da quello di proposta materiale, vedi
// categorie.service.ts::proponiCategoria — resta comunque una proposta 'da_approvare',
// il materiale può essere proposto subito con quel testo come categoria).
readonly categorieRicerca = signal<Categoria[]>([]);
readonly categoriaRicercaInCorso = signal(false);
readonly categoriaAppenaProposta = signal(false);
readonly proponiCategoriaInCorso = signal(false);
readonly proponiCategoriaErrore = signal<string | null>(null);
readonly categoriaCorrispondeEsistente = computed(() =>
this.categorieRicerca().some((c) => c.nome.toLowerCase() === this.proponiCategoria().trim().toLowerCase())
);
readonly mostraProponiCategoria = computed(
() =>
!this.categoriaRicercaInCorso() &&
!this.categoriaAppenaProposta() &&
!this.categoriaCorrispondeEsistente() &&
this.proponiCategoria().trim().length > 0
);
readonly nomeErrore = signal<string | null>(null);
readonly salvataggioInCorso = signal(false);
readonly salvataggioErrore = signal<string | null>(null);
// Candidate sotto-lista: caricate una volta sola (nessuna ricerca lato server per
// liste modello/liste personali) e filtrate client-side in base al testo digitato.
readonly catalogoListe = signal<Lista[]>([]);
readonly mieListe = signal<Lista[]>([]);
readonly sottoListeSelezionate = signal<SottoListaForm[]>([]);
readonly isLastStep = computed(() => this.step() === 2);
// Un'unica ricerca (come in "Cerca lista"): il testo digitato filtra in parallelo
// materiali (via backend, debounced) e liste (già in memoria, filtro client-side
// istantaneo), mostrati come gruppi distinti nello stesso dropdown di suggerimenti.
readonly risultatiSottoListaModello = computed(() => {
const termine = this.ricerca().trim().toLowerCase();
if (!termine) {
return [];
}
const selezionatiIds = new Set(this.sottoListeSelezionate().map((s) => s.id));
return this.catalogoListe().filter(
(l) => l.sottoListe.length === 0 && !selezionatiIds.has(l.id) && l.nome.toLowerCase().includes(termine)
);
});
readonly risultatiSottoListaPersonale = computed(() => {
const termine = this.ricerca().trim().toLowerCase();
if (!termine) {
return [];
}
// Una lista personale (non di gruppo) può agganciare solo proprie liste
// personali: le liste di gruppo restano escluse dal picker in quel caso.
const soloPersonali = this.stato().id !== 'gruppo';
const selezionatiIds = new Set(this.sottoListeSelezionate().map((s) => s.id));
return this.mieListe().filter(
(l) =>
l.id !== this.editingId() &&
l.sottoListe.length === 0 &&
!selezionatiIds.has(l.id) &&
(!soloPersonali || l.stato !== 'gruppo') &&
l.nome.toLowerCase().includes(termine)
);
});
readonly suggestionGroups = computed(() => {
const gruppi: { label: string; tipo: 'materiale' | 'listaModello' | 'listaPersonale'; items: { id: string; nome: string }[] }[] = [];
if (this.risultatiRicerca().length > 0) {
gruppi.push({
label: 'Materiale',
tipo: 'materiale',
items: this.risultatiRicerca().map((m) => ({ id: m.id, nome: `${m.nome} (${m.categoria})` }))
});
}
if (this.risultatiSottoListaModello().length > 0) {
gruppi.push({
label: 'Liste dal catalogo',
tipo: 'listaModello',
items: this.risultatiSottoListaModello().map((l) => ({ id: l.id, nome: l.nome }))
});
}
if (this.risultatiSottoListaPersonale().length > 0) {
gruppi.push({
label: 'Le tue liste',
tipo: 'listaPersonale',
items: this.risultatiSottoListaPersonale().map((l) => ({ id: l.id, nome: l.nome }))
});
}
return gruppi;
});
readonly nessunRisultato = computed(
() =>
!this.ricercaInCorso() &&
this.ricerca().trim().length > 0 &&
this.risultatiRicerca().length === 0 &&
this.risultatiSottoListaModello().length === 0 &&
this.risultatiSottoListaPersonale().length === 0
);
constructor() {
firstValueFrom(this.listeApi.getListePubbliche().pipe(catchError(() => of([])))).then((liste) =>
this.catalogoListe.set(liste)
);
// Usata solo per risolvere il nome del tipo evento già assegnato quando si apre
// una lista esistente in modifica (vedi caricaPerModifica): l'API restituisce solo
// tipoEventoId, non il nome.
const tipiEventoConosciuti = firstValueFrom(this.tipiEventoApi.getTipiEvento().pipe(catchError(() => of([]))));
const idParam = this.route.snapshot.paramMap.get('id');
if (idParam) {
this.editingId.set(idParam);
this.caricamentoModifica.set(true);
} else {
// Arrivando da "Le nostre liste" (?stato=gruppo, vedi liste-list.ts), la nuova
// lista nasce già di gruppo e lo stato non è scelta dell'utente: stessa nota di
// sola lettura mostrata in modifica per una lista di gruppo non propria.
const statoParam = this.route.snapshot.queryParamMap.get('stato');
const statoForzato = this.stati.find((s) => s.id === statoParam);
if (statoForzato) {
this.stato.set(statoForzato);
this.puoModificareStato.set(false);
}
}
firstValueFrom(this.listeApi.getListe().pipe(catchError(() => of([])))).then(async (liste) => {
this.mieListe.set(liste);
if (idParam) {
this.caricaPerModifica(idParam, liste, await tipiEventoConosciuti);
}
});
toObservable(this.ricerca)
.pipe(
debounceTime(AUTOCOMPLETE_DEBOUNCE_MS),
distinctUntilChanged(),
switchMap((query) => {
const termine = query.trim();
if (!termine) {
this.ricercaInCorso.set(false);
return of([]);
}
this.ricercaInCorso.set(true);
return this.materialiApi.getMateriali(termine).pipe(catchError(() => of([])));
}),
takeUntilDestroyed()
)
.subscribe((risultati) => {
this.ricercaInCorso.set(false);
const idGiaPresenti = new Set(this.voci().map((v) => v.materialeId));
this.risultatiRicerca.set(risultati.filter((m) => !idGiaPresenti.has(m.id)));
});
toObservable(this.proponiCategoria)
.pipe(
debounceTime(AUTOCOMPLETE_DEBOUNCE_MS),
distinctUntilChanged(),
switchMap((query) => {
const termine = query.trim();
if (!termine) {
this.categoriaRicercaInCorso.set(false);
return of([]);
}
this.categoriaRicercaInCorso.set(true);
return this.categorieApi.getCategoriePubbliche(termine).pipe(catchError(() => of([])));
}),
takeUntilDestroyed()
)
.subscribe((risultati) => {
this.categoriaRicercaInCorso.set(false);
this.categorieRicerca.set(risultati);
this.categoriaAppenaProposta.set(false);
});
toObservable(this.tipoEventoTesto)
.pipe(
debounceTime(AUTOCOMPLETE_DEBOUNCE_MS),
distinctUntilChanged(),
switchMap((query) => {
const termine = query.trim();
if (!termine) {
this.tipoEventoRicercaInCorso.set(false);
return of([]);
}
this.tipoEventoRicercaInCorso.set(true);
return this.tipiEventoApi.getTipiEvento(termine).pipe(catchError(() => of([])));
}),
takeUntilDestroyed()
)
.subscribe((risultati) => {
this.tipoEventoRicercaInCorso.set(false);
this.tipoEventoRisultati.set(risultati);
this.tipoEventoAppenaProposto.set(false);
});
}
private caricaPerModifica(id: string, mieListe: Lista[], tipiEventoConosciuti: TipoEvento[]): void {
const lista = mieListe.find((l) => l.id === id);
if (!lista) {
this.caricamentoErrore.set('Lista non trovata.');
this.caricamentoModifica.set(false);
return;
}
this.nome.set(lista.nome);
this.stato.set(this.stati.find((s) => s.id === lista.stato) ?? this.stati[0]);
this.puoModificareStato.set(lista.stato !== 'gruppo' || lista.creataDaMe);
this.voci.set(lista.voci.map((v) => ({ ...v })));
this.sottoListeSelezionate.set(
lista.sottoListe.map((sl) => ({ id: sl.id, nome: sl.nome, origine: 'personale' as const }))
);
if (lista.tipoEventoId) {
this.tipoEventoIdSelezionato.set(lista.tipoEventoId);
this.tipoEventoTesto.set(tipiEventoConosciuti.find((t) => t.id === lista.tipoEventoId)?.nome ?? '');
}
this.caricamentoModifica.set(false);
}
statoStile(idStato: string) {
return statoListaStyle(idStato);
}
selectStato(stato: StatoLista): void {
if (!this.puoModificareStato()) {
return;
}
this.stato.set(stato);
}
aggiungiMateriale(materiale: MaterialePubblico): void {
if (this.voci().some((v) => v.materialeId === materiale.id)) {
return;
}
this.voci.update((voci) => [
...voci,
{ materialeId: materiale.id, nome: materiale.nome, unitaMisura: materiale.unitaMisura, quantita: 1 }
]);
this.ricerca.set('');
this.mostraFormProposta.set(false);
}
rimuoviVoce(materialeId: string): void {
this.voci.update((voci) => voci.filter((v) => v.materialeId !== materialeId));
}
modificaQuantita(materialeId: string, valore: string): void {
const parsed = Number.parseInt(valore, 10);
const quantita = Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
this.voci.update((voci) => voci.map((v) => (v.materialeId === materialeId ? { ...v, quantita } : v)));
}
aggiungiSottoListaModello(lista: Lista): void {
if (this.sottoListeSelezionate().some((s) => s.id === lista.id)) {
return;
}
this.sottoListeSelezionate.update((s) => [...s, { id: lista.id, nome: lista.nome, origine: 'modello' }]);
this.ricerca.set('');
}
aggiungiSottoListaPersonale(lista: Lista): void {
if (this.sottoListeSelezionate().some((s) => s.id === lista.id)) {
return;
}
this.sottoListeSelezionate.update((s) => [...s, { id: lista.id, nome: lista.nome, origine: 'personale' }]);
this.ricerca.set('');
}
selectSuggestion(tipo: 'materiale' | 'listaModello' | 'listaPersonale', id: string): void {
if (tipo === 'materiale') {
const materiale = this.risultatiRicerca().find((m) => m.id === id);
if (materiale) {
this.aggiungiMateriale(materiale);
}
return;
}
if (tipo === 'listaModello') {
const lista = this.risultatiSottoListaModello().find((l) => l.id === id);
if (lista) {
this.aggiungiSottoListaModello(lista);
}
return;
}
const lista = this.risultatiSottoListaPersonale().find((l) => l.id === id);
if (lista) {
this.aggiungiSottoListaPersonale(lista);
}
}
rimuoviSottoLista(id: string): void {
this.sottoListeSelezionate.update((s) => s.filter((sl) => sl.id !== id));
}
apriFormProposta(): void {
this.proponiErrore.set(null);
this.proponiCategoria.set('');
this.proponiUnitaMisura.set('');
this.categorieRicerca.set([]);
this.categoriaAppenaProposta.set(false);
this.proponiCategoriaErrore.set(null);
this.mostraFormProposta.set(true);
}
annullaProposta(): void {
this.mostraFormProposta.set(false);
}
selezionaCategoria(categoria: Categoria): void {
this.proponiCategoria.set(categoria.nome);
this.categorieRicerca.set([categoria]);
}
async proponiNuovaCategoria(): Promise<void> {
const nome = this.proponiCategoria().trim();
if (nome.length === 0 || this.proponiCategoriaInCorso()) {
return;
}
this.proponiCategoriaErrore.set(null);
this.proponiCategoriaInCorso.set(true);
try {
await firstValueFrom(this.categorieApi.proponiCategoria(nome));
this.proponiCategoriaInCorso.set(false);
this.categoriaAppenaProposta.set(true);
} catch {
this.proponiCategoriaInCorso.set(false);
this.proponiCategoriaErrore.set('Impossibile proporre la categoria. Riprova più tardi.');
}
}
async confermaProposta(): Promise<void> {
const nome = this.ricerca().trim();
const categoria = this.proponiCategoria().trim();
const unitaMisura = this.proponiUnitaMisura().trim();
if (nome.length === 0 || categoria.length === 0 || unitaMisura.length === 0) {
this.proponiErrore.set('Nome, categoria e unità di misura sono obbligatori.');
return;
}
this.proponiErrore.set(null);
this.proponiInCorso.set(true);
try {
const proposta = await firstValueFrom(this.materialiApi.proponiMateriale({ nome, categoria, unitaMisura }));
this.voci.update((voci) => [
...voci,
{
materialeId: proposta.id,
nome: proposta.nome,
unitaMisura: proposta.unitaMisura,
quantita: 1,
inAttesaConferma: true
}
]);
this.proponiInCorso.set(false);
this.mostraFormProposta.set(false);
this.ricerca.set('');
} catch {
this.proponiInCorso.set(false);
this.proponiErrore.set('Impossibile proporre il materiale. Riprova più tardi.');
}
}
onTipoEventoInput(valore: string): void {
this.tipoEventoTesto.set(valore);
this.tipoEventoIdSelezionato.set(null);
}
selezionaTipoEvento(tipoEvento: TipoEvento): void {
this.tipoEventoTesto.set(tipoEvento.nome);
this.tipoEventoIdSelezionato.set(tipoEvento.id);
this.tipoEventoRisultati.set([tipoEvento]);
}
rimuoviTipoEvento(): void {
this.tipoEventoTesto.set('');
this.tipoEventoIdSelezionato.set(null);
this.tipoEventoRisultati.set([]);
this.tipoEventoAppenaProposto.set(false);
}
async proponiNuovoTipoEvento(): Promise<void> {
const nome = this.tipoEventoTesto().trim();
if (nome.length === 0 || this.tipoEventoProponiInCorso()) {
return;
}
this.tipoEventoProponiErrore.set(null);
this.tipoEventoProponiInCorso.set(true);
try {
const proposto = await firstValueFrom(this.tipiEventoApi.proponiTipoEvento(nome));
this.tipoEventoIdSelezionato.set(proposto.id);
this.tipoEventoProponiInCorso.set(false);
this.tipoEventoAppenaProposto.set(true);
} catch {
this.tipoEventoProponiInCorso.set(false);
this.tipoEventoProponiErrore.set('Impossibile proporre il tipo evento. Riprova più tardi.');
}
}
nextStep(): void {
if (this.step() === 0) {
if (this.nome().trim().length < 3) {
this.nomeErrore.set('Il nome della lista deve avere almeno 3 caratteri.');
return;
}
this.nomeErrore.set(null);
}
this.step.update((s) => Math.min(2, s + 1));
}
prevStep(): void {
this.step.update((s) => Math.max(0, s - 1));
}
setStep(n: number): void {
if (n === 0 || this.nome().trim().length >= 3) {
this.step.set(n);
}
}
async salva(): Promise<void> {
if (this.salvataggioInCorso()) {
return;
}
this.salvataggioErrore.set(null);
this.salvataggioInCorso.set(true);
const voci: ListaVoceInput[] = this.voci().map((v) => ({ materialeId: v.materialeId, quantita: v.quantita }));
const sottoListeIds = this.sottoListeSelezionate()
.filter((s) => s.origine === 'personale')
.map((s) => s.id);
const sottoListeModelloIds = this.sottoListeSelezionate()
.filter((s) => s.origine === 'modello')
.map((s) => s.id);
const editingId = this.editingId();
const tipoEventoId = this.tipoEventoIdSelezionato();
try {
if (editingId) {
await firstValueFrom(
this.listeApi.aggiornaLista(editingId, {
nome: this.nome().trim(),
stato: this.stato().id,
voci,
sottoListeIds,
sottoListeModelloIds,
tipoEventoId
})
);
this.salvataggioInCorso.set(false);
await this.router.navigate(this.stato().id === 'gruppo' ? ['/liste/gruppo'] : ['/liste']);
} else {
const lista = await firstValueFrom(
this.listeApi.creaLista({
nome: this.nome().trim(),
stato: this.stato().id,
voci,
sottoListeIds,
sottoListeModelloIds,
...(tipoEventoId ? { tipoEventoId } : {})
})
);
this.salvataggioInCorso.set(false);
await this.router.navigate(lista.stato === 'gruppo' ? ['/liste/gruppo'] : ['/liste']);
}
} catch {
this.salvataggioInCorso.set(false);
this.salvataggioErrore.set(
editingId ? 'Impossibile salvare le modifiche. Riprova più tardi.' : 'Impossibile creare la lista. Riprova più tardi.'
);
}
}
}
@@ -1,11 +1,123 @@
.magazzino__header {
margin-bottom: var(--space-2);
/* Header replicato 1:1 dalla pagina "Le mie liste" (liste-list): stesso font,
stessa dimensione testo, stesso pulsante di azione primaria. */
.page-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.page-title {
font-size: 30px;
margin: 0;
font-weight: 700;
}
.page-title--sm {
font-size: 28px;
}
.magazzino__tabs {
display: flex;
width: 100%;
margin-bottom: var(--space-4);
}
.magazzino__tabs .seg-opt {
flex: 1;
justify-content: center;
}
.magazzino__error {
color: var(--color-accent-800);
}
.magazzino__alfabeto {
display: flex;
flex-wrap: wrap;
gap: 4px;
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;
}
.magazzino__contenuto--con-form {
/* padding, non margin: i margini verticali tra fratelli si "collassano" in
CSS (max dei due, non la somma), quindi lo spazio non aumentava nonostante
il form sotto avesse già margin-bottom. Il padding non collassa mai. */
padding-top: var(--space-6);
}
.magazzino__posizione-card {
gap: var(--space-3);
}
.magazzino__posizione-lista {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.magazzino__posizione-voce {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) 0;
border-top: 1px solid var(--color-divider);
cursor: pointer;
}
.magazzino__posizione-lista li:first-child {
border-top: none;
}
.magazzino__posizione-voce-info {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.magazzino__posizione-voce-nome {
font-weight: 600;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.magazzino__posizione-voce-qta {
font-size: 12px;
opacity: 0.6;
flex-shrink: 0;
}
.magazzino__form {
padding: var(--space-4);
margin-bottom: var(--space-5);
@@ -1,14 +1,25 @@
<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>
<div class="page-header">
<h1 class="page-title page-title--sm">Giacenze di magazzino</h1>
@if (!formAperto()) {
<button type="button" class="btn btn-primary" (click)="apriNuovaVoce()">Aggiungi voce</button>
<button type="button" class="btn btn-primary" (click)="apriNuovaVoce()">+ Aggiungi voce</button>
}
</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')">
Per lista
</button>
<button
type="button"
class="seg-opt"
[class.seg-opt--attiva]="vista() === 'posizione'"
(click)="vista.set('posizione')"
>
Per posizione
</button>
</div>
@if (loading()) {
<p class="om-empty">Caricamento magazzino…</p>
} @else if (loadError(); as message) {
@@ -44,9 +55,6 @@
} @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>
}
}
@@ -103,36 +111,79 @@
@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>
<div class="magazzino__contenuto" [class.magazzino__contenuto--con-form]="formAperto()">
@if (vista() === 'lista') {
<div class="magazzino__alfabeto" role="group" aria-label="Filtra per iniziale">
@for (lettera of lettereDisponibili(); track lettera) {
<button
type="button"
class="magazzino__lettera"
[class.magazzino__lettera--attiva]="letteraSelezionata() === lettera"
(click)="selezionaLettera(lettera)"
>
{{ lettera }}
</button>
}
</div>
@if (vociFiltrate().length === 0) {
<p class="om-empty">Nessun materiale che inizia per "{{ letteraSelezionata() }}".</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 vociFiltrate(); track voce.id) {
<tr>
<td>{{ voce.materialeNome }}</td>
<td>{{ voce.quantitaPosseduta }}</td>
<td>
<span [class]="'tag magazzino-badge magazzino-badge--' + voce.stato">
{{ statoLabel(voce.stato) }}
</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>
}
</tbody>
</table>
} @else {
<div class="om-grid">
@for (gruppo of gruppiPerPosizione(); track gruppo.posizione) {
<div class="card magazzino__posizione-card">
<div class="card-title">{{ gruppo.posizione }}</div>
<div class="card-meta">{{ gruppo.voci.length }} material{{ gruppo.voci.length === 1 ? 'e' : 'i' }}</div>
<ul class="magazzino__posizione-lista">
@for (voce of gruppo.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>
</li>
}
</ul>
</div>
}
</div>
}
</div>
}
}
</section>
@@ -133,16 +133,16 @@ describe('Magazzino', () => {
expect(component.risultatiRicerca().map((m) => m.id)).toEqual(['mat-3']);
});
it('mostra il link per proporre un materiale se la ricerca non trova nulla', () => {
it('mostra il messaggio di nessun risultato 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');
expect(compiled.querySelector('.magazzino__nessun-risultato')?.textContent).toContain(
'materiale inesistente',
);
});
it('seleziona un materiale dal catalogo e lo mostra come scelto', () => {
@@ -6,7 +6,6 @@ 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';
@@ -34,11 +33,19 @@ const STATI: OpzioneStato[] = [
{ value: 'mancante', label: 'Mancante' }
];
const SENZA_POSIZIONE = 'Senza posizione';
type Vista = 'lista' | 'posizione';
interface GruppoPosizione {
posizione: string;
voci: MagazzinoVoce[];
}
@Component({
selector: 'app-magazzino',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
@@ -55,11 +62,60 @@ export class Magazzino implements OnInit {
readonly displayedColumns = ['materiale', 'quantita', 'stato', 'posizione', 'note', 'azioni'];
readonly stati = STATI;
readonly vista = signal<Vista>('lista');
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly voci = signal<MagazzinoVoce[]>([]);
readonly materialiCatalogo = signal<MaterialePubblico[]>([]);
// Vista "per lista": barra sopra la tabella con solo le iniziali dei
// materiali effettivamente presenti, per filtrare in base al nome. Una
// seconda volta sulla stessa lettera rimuove il filtro.
readonly letteraSelezionata = signal<string | null>(null);
readonly lettereDisponibili = computed<string[]>(() => {
const lettere = new Set<string>();
for (const voce of this.voci()) {
const lettera = voce.materialeNome.trim().charAt(0).toUpperCase();
if (lettera) {
lettere.add(lettera);
}
}
return Array.from(lettere).sort((a, b) => a.localeCompare(b, 'it'));
});
readonly vociFiltrate = computed<MagazzinoVoce[]>(() => {
const lettera = this.letteraSelezionata();
if (!lettera) {
return this.voci();
}
return this.voci().filter((voce) => voce.materialeNome.trim().toUpperCase().startsWith(lettera));
});
// Vista "per posizione": stesse voci raggruppate per il campo libero
// `posizione`, con le voci senza posizione raccolte in un gruppo a parte
// e sempre mostrato per ultimo.
readonly gruppiPerPosizione = computed<GruppoPosizione[]>(() => {
const gruppi = new Map<string, MagazzinoVoce[]>();
for (const voce of this.voci()) {
const chiave = voce.posizione?.trim() || SENZA_POSIZIONE;
const vociGruppo = gruppi.get(chiave);
if (vociGruppo) {
vociGruppo.push(voce);
} else {
gruppi.set(chiave, [voce]);
}
}
const chiavi = Array.from(gruppi.keys()).sort((a, b) => {
if (a === SENZA_POSIZIONE) return 1;
if (b === SENZA_POSIZIONE) return -1;
return a.localeCompare(b, 'it');
});
return chiavi.map((posizione) => ({ posizione, voci: gruppi.get(posizione)! }));
});
readonly formAperto = signal(false);
readonly voceInModificaId = signal<string | null>(null);
readonly materialeSelezionato = signal<MaterialeRiferimento | null>(null);
@@ -126,6 +182,14 @@ export class Magazzino implements OnInit {
this.formAperto.set(false);
}
statoLabel(stato: StatoMagazzinoVoce): string {
return this.stati.find((opzione) => opzione.value === stato)?.label ?? stato;
}
selezionaLettera(lettera: string): void {
this.letteraSelezionata.set(this.letteraSelezionata() === lettera ? null : lettera);
}
selezionaMateriale(materiale: MaterialePubblico): void {
this.materialeSelezionato.set({ id: materiale.id, nome: materiale.nome });
this.materialeErrore.set(null);
@@ -1,17 +0,0 @@
.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;
}
@@ -1,44 +0,0 @@
<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>
@@ -1,120 +0,0 @@
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');
});
});
});
@@ -1,63 +0,0 @@
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,218 @@
.header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
padding: 16px 24px;
background: var(--color-header);
border-bottom: 1px solid var(--color-divider);
}
.back-link {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--color-text);
}
.back-arrow {
font-size: 20px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
color: var(--color-text);
text-decoration: none;
}
.brand--right {
margin-left: auto;
}
.logo {
width: 34px;
height: 34px;
border-radius: var(--radius-sm);
background: var(--color-accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
}
.header--back .logo {
width: 32px;
height: 32px;
}
.brand-name {
font-family: var(--font-heading);
font-weight: var(--font-heading-weight);
font-size: 19px;
letter-spacing: 0.2px;
}
.nav-items {
display: flex;
gap: 4px;
margin-left: auto;
flex-wrap: wrap;
}
.nav-item {
cursor: pointer;
padding: 8px 14px;
border-radius: var(--radius-sm);
font-weight: 600;
font-size: 15px;
color: var(--color-text);
background: transparent;
text-decoration: none;
}
.nav-item--active {
color: var(--color-accent-700);
background: var(--color-accent-200);
}
.auth-section {
display: flex;
align-items: center;
gap: 10px;
}
.auth-name {
font-weight: 600;
font-size: 14px;
color: var(--color-text);
}
.btn-secondary--sm {
padding: 6px 12px;
font-size: 13px;
}
.notifiche {
position: relative;
}
.campanellina {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
font-size: 18px;
cursor: pointer;
}
.campanellina:hover {
background: var(--color-accent-200);
}
.campanellina-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--color-error, #d64545);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.notifiche-overlay {
position: fixed;
inset: 0;
z-index: 19;
}
.notifiche-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
width: 320px;
max-height: 420px;
overflow-y: auto;
background: var(--color-header);
border: 1px solid var(--color-divider);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.notifiche-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
font-weight: 700;
font-size: 14px;
color: var(--color-text);
border-bottom: 1px solid var(--color-divider);
}
.notifiche-segna-tutte {
cursor: pointer;
font-weight: 600;
font-size: 12px;
color: var(--color-accent-700);
}
.notifiche-vuoto {
padding: 20px 14px;
text-align: center;
font-size: 13px;
color: var(--color-text);
opacity: 0.7;
}
.notifica-item {
cursor: pointer;
padding: 12px 14px;
border-bottom: 1px solid var(--color-divider);
font-size: 13px;
color: var(--color-text);
}
.notifica-item:last-child {
border-bottom: none;
}
.notifica-item:hover {
background: var(--color-accent-200);
}
.notifica-item--non-letta {
font-weight: 600;
background: var(--color-accent-200);
}
.notifica-item--non-letta::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
margin-right: 8px;
border-radius: 999px;
background: var(--color-accent);
}
@@ -0,0 +1,75 @@
@if (isDetailOrForm()) {
<div class="header header--back">
<div class="back-link" (click)="back()">
<span class="back-arrow"></span>
<span>Indietro</span>
</div>
<div class="brand brand--right">
<div class="logo">S</div>
<span class="brand-name">Scouthub Magazzino</span>
</div>
</div>
} @else {
<div class="header header--nav">
<a class="brand" routerLink="/">
<div class="logo">S</div>
<span class="brand-name">Scouthub Magazzino</span>
</a>
<nav class="nav-items">
@for (item of navItems(); track item.path) {
<a class="nav-item" [class.nav-item--active]="isActive(item.path)" [routerLink]="item.path">
{{ item.label }}
</a>
}
</nav>
<div class="auth-section">
@if (isAuthenticated()) {
@if (isModeratore()) {
<div class="notifiche">
<button
type="button"
class="campanellina"
(click)="toggleCampanellina()"
aria-label="Notifiche"
>
🔔
@if (countNonLette() > 0) {
<span class="campanellina-badge">{{ countBadge() }}</span>
}
</button>
@if (campanellinaAperta()) {
<div class="notifiche-overlay" (click)="chiudiCampanellina()"></div>
<div class="notifiche-dropdown">
<div class="notifiche-header">
<span>Notifiche</span>
@if (countNonLette() > 0) {
<span class="notifiche-segna-tutte" (click)="segnaTutteLette()">
Segna tutte come lette
</span>
}
</div>
@if (notifiche().length === 0) {
<div class="notifiche-vuoto">Nessuna notifica</div>
} @else {
@for (notifica of notifiche(); track notifica.id) {
<div
class="notifica-item"
[class.notifica-item--non-letta]="!notifica.letta"
(click)="apriNotifica(notifica)"
>
<span class="notifica-messaggio">{{ notifica.messaggio }}</span>
</div>
}
}
</div>
}
</div>
}
<span class="auth-name">{{ displayName() }}</span>
<div class="btn btn-secondary btn-secondary--sm" (click)="logout()">Esci</div>
} @else {
<div class="btn btn-secondary btn-secondary--sm" (click)="login()">Accedi</div>
}
</div>
</div>
}
@@ -0,0 +1,180 @@
import { Location } from '@angular/common';
import { Component, DestroyRef, computed, effect, inject, signal } from '@angular/core';
import { NavigationEnd, Router, RouterLink } from '@angular/router';
import { filter } from 'rxjs';
import Keycloak from 'keycloak-js';
import { KEYCLOAK_EVENT_SIGNAL } from 'keycloak-angular';
import { MODERATORE_ROLE, extractRealmRoles } from '../../core/auth/roles';
import { Notifica } from '../../core/models/notifica.model';
import { NotificheService } from '../../core/services/notifiche';
interface NavItem {
path: string;
label: string;
}
const INTERVALLO_POLLING_MS = 30000;
@Component({
selector: 'app-header',
imports: [RouterLink],
templateUrl: './header.html',
styleUrl: './header.css'
})
export class Header {
private readonly router = inject(Router);
private readonly location = inject(Location);
private readonly keycloak = inject(Keycloak);
private readonly keycloakEvent = inject(KEYCLOAK_EVENT_SIGNAL);
private readonly notificheService = inject(NotificheService);
private readonly destroyRef = inject(DestroyRef);
readonly currentUrl = signal(this.router.url);
readonly notifiche = signal<Notifica[]>([]);
readonly countNonLette = signal(0);
readonly campanellinaAperta = signal(false);
readonly countBadge = computed(() => {
const count = this.countNonLette();
return count > 9 ? '9+' : String(count);
});
readonly isAuthenticated = computed(() => {
this.keycloakEvent();
return !!this.keycloak.authenticated;
});
readonly displayName = computed(() => {
this.keycloakEvent();
const token = this.keycloak.tokenParsed;
return (token?.['name'] as string) ?? (token?.['preferred_username'] as string) ?? '';
});
readonly isModeratore = computed(() => {
this.keycloakEvent();
return extractRealmRoles(this.keycloak.tokenParsed).includes(MODERATORE_ROLE);
});
// Pagine di dettaglio/form raggiunte da un elenco (non da un item di nav diretto):
// mostrano l'header con "Indietro" invece della barra di navigazione.
readonly isDetailOrForm = computed(() => {
const url = this.currentUrl();
return url.startsWith('/lista-modello/') || (/^\/liste\/[^/]+$/.test(url) && url !== '/liste/gruppo');
});
readonly navItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{ path: '/', label: 'Home' },
{ path: '/cerca-lista', label: 'Cerca' },
{ path: '/liste', label: 'Le mie liste' }
];
if (this.isAuthenticated()) {
items.push(
{ path: '/liste/gruppo', label: 'Le nostre liste' },
{ path: '/magazzino', label: 'Magazzino' }
);
}
if (this.isModeratore()) {
items.push({ path: '/tassonomie', label: 'Tassonomie' });
}
return items;
});
constructor() {
this.router.events.pipe(filter((event) => event instanceof NavigationEnd)).subscribe(() => {
this.currentUrl.set(this.router.url);
});
// Le notifiche sono solo la coda di moderazione (materiali/categorie/tipi
// evento/liste proposti): il backend le espone solo a chi ha ruolo 'moderatore',
// quindi il polling parte/si ferma seguendo anche quello, non solo il login.
let intervalId: ReturnType<typeof setInterval> | undefined;
effect(() => {
if (this.isAuthenticated() && this.isModeratore()) {
this.aggiornaCountNonLette();
intervalId = setInterval(() => this.aggiornaCountNonLette(), INTERVALLO_POLLING_MS);
} else {
this.notifiche.set([]);
this.countNonLette.set(0);
}
});
this.destroyRef.onDestroy(() => {
if (intervalId !== undefined) {
clearInterval(intervalId);
}
});
}
isActive(path: string): boolean {
if (path === '/') {
return this.currentUrl() === '/';
}
// '/liste' e '/liste/gruppo' condividono prefisso: senza questa eccezione
// risulterebbero entrambe attive sulla pagina "Le nostre liste".
if (path === '/liste') {
return this.currentUrl().startsWith('/liste') && !this.currentUrl().startsWith('/liste/gruppo');
}
return this.currentUrl().startsWith(path);
}
back(): void {
this.location.back();
}
login(): void {
this.keycloak.login({ redirectUri: window.location.href });
}
logout(): void {
this.keycloak.logout({ redirectUri: window.location.origin + '/' });
}
private aggiornaCountNonLette(): void {
this.notificheService.getCountNonLette().subscribe({
next: ({ count }) => this.countNonLette.set(count),
});
}
toggleCampanellina(): void {
const apri = !this.campanellinaAperta();
this.campanellinaAperta.set(apri);
if (apri) {
this.notificheService.getLista().subscribe({
next: (lista) => this.notifiche.set(lista),
});
}
}
chiudiCampanellina(): void {
this.campanellinaAperta.set(false);
}
apriNotifica(notifica: Notifica): void {
if (!notifica.letta) {
this.notificheService.segnaLetta(notifica.id).subscribe({
next: () => {
this.notifiche.update((lista) =>
lista.map((n) => (n.id === notifica.id ? { ...n, letta: true } : n)),
);
this.countNonLette.update((count) => Math.max(0, count - 1));
},
});
}
this.campanellinaAperta.set(false);
if (notifica.link) {
this.router.navigateByUrl(notifica.link);
}
}
segnaTutteLette(): void {
this.notificheService.segnaTutteLette().subscribe({
next: () => {
this.notifiche.update((lista) => lista.map((n) => ({ ...n, letta: true })));
this.countNonLette.set(0);
},
});
}
}
@@ -0,0 +1,53 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type StatoCategoria = 'confermata' | 'da_approvare';
export interface Categoria {
id: string;
nome: string;
stato: StatoCategoria;
creatoDaOrgId: string | null;
creatoIl: string;
}
@Injectable({ providedIn: 'root' })
export class CategorieApiService {
private readonly http = inject(HttpClient);
getCategorie(): Observable<Categoria[]> {
return this.http.get<Categoria[]>(`${environment.magazzinoApiBaseUrl}/categorie`);
}
getCategoriePubbliche(nome?: string): Observable<Categoria[]> {
const params = nome ? new HttpParams().set('nome', nome) : undefined;
return this.http.get<Categoria[]>(`${environment.magazzinoApiBaseUrl}/categorie/pubbliche`, { params });
}
creaCategoria(nome: string): Observable<Categoria> {
return this.http.post<Categoria>(`${environment.magazzinoApiBaseUrl}/categorie`, { nome });
}
aggiornaCategoria(id: string, nome: string): Observable<Categoria> {
return this.http.put<Categoria>(`${environment.magazzinoApiBaseUrl}/categorie/${id}`, { nome });
}
eliminaCategoria(id: string): Observable<void> {
return this.http.delete<void>(`${environment.magazzinoApiBaseUrl}/categorie/${id}`);
}
proponiCategoria(nome: string): Observable<Categoria> {
return this.http.post<Categoria>(`${environment.magazzinoApiBaseUrl}/categorie/proposte`, { nome });
}
approvaCategoria(id: string): Observable<Categoria> {
return this.http.post<Categoria>(`${environment.magazzinoApiBaseUrl}/categorie/${id}/approva`, {});
}
rifiutaCategoria(id: string): Observable<void> {
return this.http.post<void>(`${environment.magazzinoApiBaseUrl}/categorie/${id}/rifiuta`, {});
}
}
@@ -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 { Lista } from '../liste/liste-api.service';
export type DecisioneProposta = 'approvato' | 'rifiutato';
@Injectable({ providedIn: 'root' })
export class ListeModerazioneApiService {
private readonly http = inject(HttpClient);
getProposte(): Observable<Lista[]> {
return this.http.get<Lista[]>(`${environment.magazzinoApiBaseUrl}/liste/proposte`);
}
decidiProposta(id: string, decisione: DecisioneProposta): Observable<Lista> {
return this.http.patch<Lista>(`${environment.magazzinoApiBaseUrl}/liste/proposte/${id}`, {
decisione
});
}
}
@@ -0,0 +1,227 @@
/* Stile replicato 1:1 dalla pagina "Tassonomie" di scouthub-attivita-fe (stessi nomi di
classe, stesso layout tabs/riga/form-card), con i soli token di colore rimappati su
quelli storici di questo progetto (--color-accent al posto di --color-primary, ecc.),
stesso pattern già usato in liste/nuova-lista/nuova-lista.css. */
.page {
max-width: 980px;
margin: 0 auto;
padding: 32px 24px 64px;
}
.page-header {
margin-bottom: 18px;
}
.page-title {
font-size: 26px;
margin: 0 0 6px;
font-weight: 700;
}
.page-title--sm {
font-size: 22px;
}
.tabs {
display: flex;
gap: 6px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.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;
}
.tab-intro {
font-size: 14px;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
margin: 0 0 16px;
}
.form-card {
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: 14px;
padding: 18px 20px;
margin-bottom: 20px;
}
.form-riga {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.field {
font-weight: 700;
font-size: 14px;
flex: 1;
min-width: 160px;
}
.text-input {
display: block;
width: 100%;
box-sizing: border-box;
margin-top: 6px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--color-divider);
font-size: 16px;
font-family: inherit;
background: var(--color-bg);
color: var(--color-text);
}
.text-input--sm {
margin-top: 4px;
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
}
.form-azioni {
display: flex;
gap: 10px;
margin-top: 16px;
}
.errore-inline {
color: var(--color-accent-800);
font-size: 13px;
margin-top: 10px;
}
.lista {
display: flex;
flex-direction: column;
gap: 14px;
}
.riga {
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: 14px;
padding: 18px 20px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
}
.riga-info {
flex: 1;
min-width: 220px;
}
.riga-titolo {
font-weight: 700;
font-size: 17px;
color: var(--color-text);
}
.riga-data {
font-size: 13px;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
margin-top: 2px;
}
.riga-azioni {
display: flex;
gap: 8px;
}
.riga-modifica {
cursor: pointer;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-divider);
font-weight: 600;
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;
}
.proposte-box {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 20px;
}
.proposte-titolo {
font-weight: 700;
font-size: 14px;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
text-transform: uppercase;
}
.badge-attesa {
cursor: default;
font-size: 14px;
margin-left: 4px;
}
.empty-state {
padding: 24px 0;
color: color-mix(in srgb, var(--color-text) 55%, transparent);
font-size: 14px;
}
.empty-title {
font-weight: 700;
color: var(--color-text);
}
.btn-primary {
cursor: pointer;
display: inline-block;
padding: 11px 18px;
border-radius: 10px;
background: var(--color-accent);
color: #fff;
font-weight: 700;
border: none;
font-family: inherit;
font-size: 15px;
}
.btn-primary:hover {
background: var(--color-accent-600);
}
.btn-secondary {
cursor: pointer;
display: inline-block;
padding: 9px 14px;
border-radius: 8px;
border: 1px solid var(--color-divider);
font-weight: 600;
font-size: 14px;
background: transparent;
color: var(--color-text);
font-family: inherit;
}
@@ -0,0 +1,319 @@
<div class="page">
<div class="page-header">
<h1 class="page-title page-title--sm">Tassonomie</h1>
</div>
<div class="tabs">
<div class="tab" [class.tab--attivo]="tab() === 'materiali'" (click)="setTab('materiali')">Materiali</div>
<div class="tab" [class.tab--attivo]="tab() === 'categorie'" (click)="setTab('categorie')">Categorie</div>
<div class="tab" [class.tab--attivo]="tab() === 'liste'" (click)="setTab('liste')">Liste in attesa</div>
<div class="tab" [class.tab--attivo]="tab() === 'tipiEvento'" (click)="setTab('tipiEvento')">Tipi evento</div>
</div>
@if (tab() === 'materiali') {
<p class="tab-intro">Materiali proposti dalle organizzazioni, in attesa di approvazione.</p>
@if (loading()) {
<div class="empty-state">Caricamento...</div>
} @else if (loadError()) {
<div class="empty-state">{{ loadError() }}</div>
} @else if (proposte().length > 0) {
<div class="proposte-box">
<div class="proposte-titolo">Proposte in attesa di approvazione</div>
@for (proposta of proposte(); track proposta.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">{{ proposta.nome }}</div>
<div class="riga-data">
{{ proposta.categoria }} · {{ proposta.unitaMisura }} · proposto il {{ proposta.creatoIl | slice: 0 : 10 }}
</div>
@if (decisioneErroreId() === proposta.id) {
<div class="errore-inline">Impossibile registrare la decisione. Riprova più tardi.</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="decidi(proposta, 'approvato')">Approva</div>
<div class="riga-elimina" (click)="decidi(proposta, 'rifiutato')">Rifiuta</div>
</div>
</div>
}
</div>
} @else {
<div class="empty-state">
<div class="empty-title">Nessuna proposta in attesa di approvazione</div>
</div>
}
@if (materialiLoading()) {
<div class="empty-state">Caricamento...</div>
} @else if (materialiLoadError()) {
<div class="empty-state">{{ materialiLoadError() }}</div>
} @else {
<div class="form-card">
<div class="form-riga">
<label class="field">
Nome
<input
class="text-input text-input--sm"
type="text"
[value]="materialeNome()"
(input)="materialeNome.set($any($event.target).value)"
placeholder="Es. Corda 10m"
/>
</label>
<label class="field">
Categoria
<input
class="text-input text-input--sm"
type="text"
[value]="materialeCategoria()"
(input)="materialeCategoria.set($any($event.target).value)"
placeholder="Es. Attrezzatura"
/>
</label>
<label class="field">
Unità di misura
<input
class="text-input text-input--sm"
type="text"
[value]="materialeUnitaMisura()"
(input)="materialeUnitaMisura.set($any($event.target).value)"
placeholder="Es. pz"
/>
</label>
</div>
@if (materialeSalvataggioErrore()) {
<div class="errore-inline">{{ materialeSalvataggioErrore() }}</div>
}
<div class="form-azioni">
<div class="btn-primary" (click)="salvaMateriale()">
{{ materialeEditId() ? 'Salva modifiche' : '+ Aggiungi materiale' }}
</div>
@if (materialeEditId()) {
<div class="btn-secondary" (click)="annullaMateriale()">Annulla</div>
}
</div>
</div>
@if (materiali().length === 0) {
<div class="empty-state">
<div class="empty-title">Nessun materiale presente</div>
</div>
} @else {
<div class="lista">
@for (materiale of materiali(); track materiale.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">{{ materiale.nome }}</div>
<div class="riga-data">{{ materiale.categoria }} · {{ materiale.unitaMisura }}</div>
@if (materialeEliminaErroreId() === materiale.id) {
<div class="errore-inline">{{ materialeEliminaErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="modificaMateriale(materiale)">Modifica</div>
<div class="riga-elimina" (click)="eliminaMateriale(materiale)">Elimina</div>
</div>
</div>
}
</div>
}
}
}
@if (tab() === 'categorie') {
<p class="tab-intro">Categorie di materiale: gestite direttamente dal moderatore, oppure proposte da un'organizzazione e in attesa di conferma.</p>
@if (categorieLoading()) {
<div class="empty-state">Caricamento...</div>
} @else if (categorieLoadError()) {
<div class="empty-state">{{ categorieLoadError() }}</div>
} @else {
@if (categoriaProposte().length > 0) {
<div class="proposte-box">
<div class="proposte-titolo">Proposte in attesa di approvazione</div>
@for (categoria of categoriaProposte(); track categoria.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">
{{ categoria.nome }}
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
</div>
@if (categoriaApprovazioneErroreId() === categoria.id) {
<div class="errore-inline">{{ categoriaApprovazioneErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="approvaCategoria(categoria)">Approva</div>
<div class="riga-elimina" (click)="rifiutaCategoria(categoria)">Rifiuta</div>
</div>
</div>
}
</div>
}
<div class="form-card">
<div class="form-riga">
<label class="field">
Nome
<input
class="text-input text-input--sm"
type="text"
[value]="categoriaNome()"
(input)="categoriaNome.set($any($event.target).value)"
placeholder="Es. Cucina"
/>
</label>
</div>
@if (categoriaSalvataggioErrore()) {
<div class="errore-inline">{{ categoriaSalvataggioErrore() }}</div>
}
<div class="form-azioni">
<div class="btn-primary" (click)="salvaCategoria()">
{{ categoriaEditId() ? 'Salva modifiche' : '+ Aggiungi categoria' }}
</div>
@if (categoriaEditId()) {
<div class="btn-secondary" (click)="annullaCategoria()">Annulla</div>
}
</div>
</div>
@if (categoriaConfermate().length > 0) {
<div class="lista">
@for (categoria of categoriaConfermate(); track categoria.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">{{ categoria.nome }}</div>
@if (categoriaEliminaErroreId() === categoria.id) {
<div class="errore-inline">{{ categoriaEliminaErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="modificaCategoria(categoria)">Modifica</div>
<div class="riga-elimina" (click)="eliminaCategoria(categoria)">Elimina</div>
</div>
</div>
}
</div>
} @else {
<div class="empty-state">
<div class="empty-title">Nessuna categoria presente</div>
</div>
}
}
}
@if (tab() === 'liste') {
<p class="tab-intro">Liste pubbliche proposte dagli utenti, in attesa di approvazione.</p>
@if (listeLoading()) {
<div class="empty-state">Caricamento...</div>
} @else if (listeLoadError()) {
<div class="empty-state">{{ listeLoadError() }}</div>
} @else if (listeProposte().length === 0) {
<div class="empty-state">
<div class="empty-title">Nessuna lista in attesa di approvazione</div>
</div>
} @else {
<div class="lista">
@for (lista of listeProposte(); track lista.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">{{ lista.nome }}</div>
<div class="riga-data">{{ lista.voci.length }} voci · proposta il {{ lista.creataIl | slice: 0 : 10 }}</div>
@if (listaDecisioneErroreId() === lista.id) {
<div class="errore-inline">Impossibile registrare la decisione. Riprova più tardi.</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="decidiLista(lista, 'approvato')">Approva</div>
<div class="riga-elimina" (click)="decidiLista(lista, 'rifiutato')">Rifiuta</div>
</div>
</div>
}
</div>
}
}
@if (tab() === 'tipiEvento') {
<p class="tab-intro">Tipi evento usati per classificare le liste pubbliche: gestiti direttamente dal moderatore, oppure proposti da un'organizzazione e in attesa di conferma.</p>
@if (tipiEventoLoading()) {
<div class="empty-state">Caricamento...</div>
} @else if (tipiEventoLoadError()) {
<div class="empty-state">{{ tipiEventoLoadError() }}</div>
} @else {
@if (tipoEventoProposti().length > 0) {
<div class="proposte-box">
<div class="proposte-titolo">Proposte in attesa di approvazione</div>
@for (tipoEvento of tipoEventoProposti(); track tipoEvento.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">
{{ tipoEvento.nome }}
<span class="badge-attesa" title="In attesa di conferma">⚠️</span>
</div>
@if (tipoEventoApprovazioneErroreId() === tipoEvento.id) {
<div class="errore-inline">{{ tipoEventoApprovazioneErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="approvaTipoEvento(tipoEvento)">Approva</div>
<div class="riga-elimina" (click)="rifiutaTipoEvento(tipoEvento)">Rifiuta</div>
</div>
</div>
}
</div>
}
<div class="form-card">
<div class="form-riga">
<label class="field">
Nome
<input
class="text-input text-input--sm"
type="text"
[value]="tipoEventoNome()"
(input)="tipoEventoNome.set($any($event.target).value)"
placeholder="Es. Uscita di un giorno"
/>
</label>
</div>
@if (tipoEventoSalvataggioErrore()) {
<div class="errore-inline">{{ tipoEventoSalvataggioErrore() }}</div>
}
<div class="form-azioni">
<div class="btn-primary" (click)="salvaTipoEvento()">
{{ tipoEventoEditId() ? 'Salva modifiche' : '+ Aggiungi tipo evento' }}
</div>
@if (tipoEventoEditId()) {
<div class="btn-secondary" (click)="annullaTipoEvento()">Annulla</div>
}
</div>
</div>
@if (tipoEventoConfermati().length > 0) {
<div class="lista">
@for (tipoEvento of tipoEventoConfermati(); track tipoEvento.id) {
<div class="riga">
<div class="riga-info">
<div class="riga-titolo">{{ tipoEvento.nome }}</div>
@if (tipoEventoEliminaErroreId() === tipoEvento.id) {
<div class="errore-inline">{{ tipoEventoEliminaErroreMsg() }}</div>
}
</div>
<div class="riga-azioni">
<div class="riga-modifica" (click)="modificaTipoEvento(tipoEvento)">Modifica</div>
<div class="riga-elimina" (click)="eliminaTipoEvento(tipoEvento)">Elimina</div>
</div>
</div>
}
</div>
} @else {
<div class="empty-state">
<div class="empty-title">Nessun tipo evento presente</div>
</div>
}
}
}
</div>
@@ -3,10 +3,10 @@ import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
import { requireModeratoreGuard } from './require-moderatore.guard';
export const MODERAZIONE_ROUTES: Routes = [
export const TASSONOMIE_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./moderazione').then((m) => m.Moderazione),
loadComponent: () => import('./tassonomie').then((m) => m.Tassonomie),
canActivate: [requireAuthGuard, requireModeratoreGuard]
}
];
@@ -0,0 +1,400 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { MaterialePubblico, MaterialeProposta, MaterialiApiService } from '../catalogo/materiali-api.service';
import { TipoEvento, TipiEventoApiService } from '../catalogo/tipi-evento-api.service';
import { Lista } from '../liste/liste-api.service';
import { Categoria, CategorieApiService } from './categorie-api.service';
import { MaterialiModerazioneApiService } from './materiali-moderazione-api.service';
import { ListeModerazioneApiService } from './liste-moderazione-api.service';
import { Tassonomie } from './tassonomie';
describe('Tassonomie', () => {
let component: Tassonomie;
let fixture: ComponentFixture<Tassonomie>;
let moderazioneApi: { getProposte: ReturnType<typeof vi.fn>; decidiProposta: ReturnType<typeof vi.fn> };
let materialiApi: {
getMateriali: ReturnType<typeof vi.fn>;
creaMateriale: ReturnType<typeof vi.fn>;
aggiornaMateriale: ReturnType<typeof vi.fn>;
eliminaMateriale: ReturnType<typeof vi.fn>;
};
let listeModerazioneApi: { getProposte: ReturnType<typeof vi.fn>; decidiProposta: ReturnType<typeof vi.fn> };
let categorieApi: {
getCategorie: ReturnType<typeof vi.fn>;
creaCategoria: ReturnType<typeof vi.fn>;
aggiornaCategoria: ReturnType<typeof vi.fn>;
eliminaCategoria: ReturnType<typeof vi.fn>;
approvaCategoria: ReturnType<typeof vi.fn>;
rifiutaCategoria: ReturnType<typeof vi.fn>;
};
let tipiEventoApi: {
getTipiEvento: ReturnType<typeof vi.fn>;
getTipiEventoModerazione: ReturnType<typeof vi.fn>;
createTipoEvento: ReturnType<typeof vi.fn>;
proponiTipoEvento: ReturnType<typeof vi.fn>;
updateTipoEvento: ReturnType<typeof vi.fn>;
deleteTipoEvento: ReturnType<typeof vi.fn>;
approvaTipoEvento: ReturnType<typeof vi.fn>;
rifiutaTipoEvento: 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'
}
];
const listeProposte: Lista[] = [
{
id: 'lista-prop-1',
nome: 'Uscita di un giorno',
orgId: null,
stato: 'pubblico',
statoModerazione: 'proposto',
tipoEventoId: null,
parentId: null,
creataIl: '2026-01-03T00:00:00.000Z',
creataDaMe: false,
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }],
sottoListe: []
}
];
const categorie: Categoria[] = [
{ id: 'cat-1', nome: 'Cucina', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' },
{ id: 'cat-2', nome: 'Escursionismo', stato: 'da_approvare', creatoDaOrgId: 'org-1', creatoIl: '2026-01-02T00:00:00.000Z' }
];
const materiali: MaterialePubblico[] = [{ id: 'mat-esist-1', nome: 'Corda 10m', categoria: 'Campeggio', unitaMisura: 'pz' }];
const tipiEvento: TipoEvento[] = [
{ id: 'te-1', nome: 'Campo estivo', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-01T00:00:00.000Z' },
{ id: 'te-2', nome: 'Bivacco', stato: 'da_approvare', creatoDaOrgId: 'org-1', creatoIl: '2026-01-02T00:00:00.000Z' }
];
function elenco(compiled: HTMLElement): Element | null {
return compiled.querySelector('.lista');
}
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [Tassonomie],
providers: [
provideRouter([]),
{ provide: MaterialiModerazioneApiService, useValue: moderazioneApi },
{ provide: MaterialiApiService, useValue: materialiApi },
{ provide: ListeModerazioneApiService, useValue: listeModerazioneApi },
{ provide: CategorieApiService, useValue: categorieApi },
{ provide: TipiEventoApiService, useValue: tipiEventoApi }
]
}).compileComponents();
fixture = TestBed.createComponent(Tassonomie);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
moderazioneApi = { getProposte: vi.fn().mockReturnValue(of(proposte)), decidiProposta: vi.fn() };
materialiApi = {
getMateriali: vi.fn().mockReturnValue(of(materiali)),
creaMateriale: vi.fn(),
aggiornaMateriale: vi.fn(),
eliminaMateriale: vi.fn()
};
listeModerazioneApi = { getProposte: vi.fn().mockReturnValue(of(listeProposte)), decidiProposta: vi.fn() };
categorieApi = {
getCategorie: vi.fn().mockReturnValue(of(categorie)),
creaCategoria: vi.fn(),
aggiornaCategoria: vi.fn(),
eliminaCategoria: vi.fn(),
approvaCategoria: vi.fn(),
rifiutaCategoria: vi.fn()
};
tipiEventoApi = {
getTipiEvento: vi.fn().mockReturnValue(of(tipiEvento)),
getTipiEventoModerazione: vi.fn().mockReturnValue(of(tipiEvento)),
createTipoEvento: vi.fn(),
proponiTipoEvento: vi.fn(),
updateTipoEvento: vi.fn(),
deleteTipoEvento: vi.fn(),
approvaTipoEvento: vi.fn(),
rifiutaTipoEvento: vi.fn()
};
});
it('mostra la lista delle proposte materiali in attesa nel tab di default', async () => {
await setup();
fixture.detectChanges();
expect(component.loading()).toBe(false);
expect(component.proposte()).toEqual(proposte);
const compiled = fixture.nativeElement as HTMLElement;
const righe = compiled.querySelector('.proposte-box')!.querySelectorAll('.riga');
expect(righe.length).toBe(2);
expect(righe[0].textContent).toContain('Fune da bucato');
});
it('mostra un messaggio se non ci sono proposte materiali 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 delle proposte materiali 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.');
});
it('mostra la lista delle liste in attesa di approvazione nel tab dedicato', async () => {
await setup();
component.setTab('liste');
fixture.detectChanges();
expect(component.listeLoading()).toBe(false);
expect(component.listeProposte()).toEqual(listeProposte);
const compiled = fixture.nativeElement as HTMLElement;
const righe = elenco(compiled)!.querySelectorAll('.riga');
expect(righe.length).toBe(1);
expect(righe[0].textContent).toContain('Uscita di un giorno');
});
it('mostra un messaggio se non ci sono liste in attesa', async () => {
listeModerazioneApi.getProposte.mockReturnValue(of([]));
await setup();
component.setTab('liste');
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Nessuna lista in attesa');
});
it('mostra un messaggio di errore se il caricamento delle liste fallisce', async () => {
listeModerazioneApi.getProposte.mockReturnValue(throwError(() => new Error('network error')));
await setup();
expect(component.listeLoadError()).toBe('Impossibile caricare le liste in attesa. Riprova più tardi.');
});
describe('approvazione e rifiuto materiali', () => {
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');
});
});
describe('approvazione e rifiuto liste', () => {
beforeEach(async () => {
await setup();
component.setTab('liste');
fixture.detectChanges();
});
it('approva una lista e la rimuove dalla lista di attesa', async () => {
const listaApprovata: Lista = { ...listeProposte[0], statoModerazione: 'approvato' };
listeModerazioneApi.decidiProposta.mockReturnValue(of(listaApprovata));
await component.decidiLista(listeProposte[0], 'approvato');
expect(listeModerazioneApi.decidiProposta).toHaveBeenCalledWith('lista-prop-1', 'approvato');
expect(component.listeProposte()).toEqual([]);
});
it('mostra un messaggio di errore sulla lista interessata se la decisione fallisce', async () => {
listeModerazioneApi.decidiProposta.mockReturnValue(throwError(() => new Error('server error')));
await component.decidiLista(listeProposte[0], 'approvato');
expect(component.listaDecisioneErroreId()).toBe('lista-prop-1');
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Impossibile registrare la decisione');
});
});
describe('categorie', () => {
beforeEach(async () => {
await setup();
component.setTab('categorie');
fixture.detectChanges();
});
it('separa le categorie confermate da quelle proposte', () => {
expect(component.categoriaConfermate().map((c) => c.id)).toEqual(['cat-1']);
expect(component.categoriaProposte().map((c) => c.id)).toEqual(['cat-2']);
});
it('crea una nuova categoria confermata', async () => {
const creata: Categoria = { id: 'cat-3', nome: 'Attrezzi', stato: 'confermata', creatoDaOrgId: null, creatoIl: '2026-01-04T00:00:00.000Z' };
categorieApi.creaCategoria.mockReturnValue(of(creata));
component.categoriaNome.set('Attrezzi');
await component.salvaCategoria();
expect(categorieApi.creaCategoria).toHaveBeenCalledWith('Attrezzi');
expect(component.categoriaList().some((c) => c.id === 'cat-3')).toBe(true);
expect(component.categoriaNome()).toBe('');
});
it('approva una categoria proposta e la sposta tra le confermate', async () => {
const approvata: Categoria = { ...categorie[1], stato: 'confermata' };
categorieApi.approvaCategoria.mockReturnValue(of(approvata));
await component.approvaCategoria(categorie[1]);
expect(categorieApi.approvaCategoria).toHaveBeenCalledWith('cat-2');
expect(component.categoriaProposte()).toEqual([]);
expect(component.categoriaConfermate().map((c) => c.id)).toContain('cat-2');
});
it('rifiuta una categoria proposta e la rimuove', async () => {
categorieApi.rifiutaCategoria.mockReturnValue(of(undefined));
await component.rifiutaCategoria(categorie[1]);
expect(categorieApi.rifiutaCategoria).toHaveBeenCalledWith('cat-2');
expect(component.categoriaList().some((c) => c.id === 'cat-2')).toBe(false);
});
it('elimina una categoria confermata', async () => {
categorieApi.eliminaCategoria.mockReturnValue(of(undefined));
await component.eliminaCategoria(categorie[0]);
expect(categorieApi.eliminaCategoria).toHaveBeenCalledWith('cat-1');
expect(component.categoriaList().some((c) => c.id === 'cat-1')).toBe(false);
});
});
describe('tipi evento', () => {
beforeEach(async () => {
await setup();
component.setTab('tipiEvento');
fixture.detectChanges();
});
it('separa i tipi evento confermati da quelli proposti', () => {
expect(component.tipoEventoConfermati().map((t) => t.id)).toEqual(['te-1']);
expect(component.tipoEventoProposti().map((t) => t.id)).toEqual(['te-2']);
});
it('crea un nuovo tipo evento confermato', async () => {
const creato: TipoEvento = {
id: 'te-3',
nome: 'Bivacco',
stato: 'confermata',
creatoDaOrgId: null,
creatoIl: '2026-01-03T00:00:00.000Z'
};
tipiEventoApi.createTipoEvento.mockReturnValue(of(creato));
component.tipoEventoNome.set('Bivacco');
await component.salvaTipoEvento();
expect(tipiEventoApi.createTipoEvento).toHaveBeenCalledWith('Bivacco');
expect(component.tipoEventoList().some((t) => t.id === 'te-3')).toBe(true);
});
it('modifica un tipo evento esistente', async () => {
const aggiornato: TipoEvento = { ...tipiEvento[0], nome: 'Campo estivo modificato' };
tipiEventoApi.updateTipoEvento.mockReturnValue(of(aggiornato));
component.modificaTipoEvento(tipiEvento[0]);
component.tipoEventoNome.set('Campo estivo modificato');
await component.salvaTipoEvento();
expect(tipiEventoApi.updateTipoEvento).toHaveBeenCalledWith('te-1', 'Campo estivo modificato');
});
it('elimina un tipo evento confermato', async () => {
tipiEventoApi.deleteTipoEvento.mockReturnValue(of(undefined));
await component.eliminaTipoEvento(tipiEvento[0]);
expect(tipiEventoApi.deleteTipoEvento).toHaveBeenCalledWith('te-1');
expect(component.tipoEventoList().some((t) => t.id === 'te-1')).toBe(false);
});
it('approva un tipo evento proposto e lo sposta tra i confermati', async () => {
const approvato: TipoEvento = { ...tipiEvento[1], stato: 'confermata' };
tipiEventoApi.approvaTipoEvento.mockReturnValue(of(approvato));
await component.approvaTipoEvento(tipiEvento[1]);
expect(tipiEventoApi.approvaTipoEvento).toHaveBeenCalledWith('te-2');
expect(component.tipoEventoProposti()).toEqual([]);
expect(component.tipoEventoConfermati().map((t) => t.id)).toContain('te-2');
});
it('rifiuta un tipo evento proposto e lo rimuove', async () => {
tipiEventoApi.rifiutaTipoEvento.mockReturnValue(of(undefined));
await component.rifiutaTipoEvento(tipiEvento[1]);
expect(tipiEventoApi.rifiutaTipoEvento).toHaveBeenCalledWith('te-2');
expect(component.tipoEventoList().some((t) => t.id === 'te-2')).toBe(false);
});
});
});
@@ -0,0 +1,443 @@
import { SlicePipe } from '@angular/common';
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { MaterialePubblico, MaterialeProposta, MaterialiApiService } from '../catalogo/materiali-api.service';
import { TipoEvento, TipiEventoApiService } from '../catalogo/tipi-evento-api.service';
import { Lista } from '../liste/liste-api.service';
import { Categoria, CategorieApiService } from './categorie-api.service';
import { DecisioneProposta, MaterialiModerazioneApiService } from './materiali-moderazione-api.service';
import { ListeModerazioneApiService } from './liste-moderazione-api.service';
type Tab = 'materiali' | 'categorie' | 'liste' | 'tipiEvento';
const TAB_VALIDI: readonly Tab[] = ['materiali', 'categorie', 'liste', 'tipiEvento'];
@Component({
selector: 'app-tassonomie',
imports: [SlicePipe],
templateUrl: './tassonomie.html',
styleUrl: './tassonomie.css'
})
export class Tassonomie implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly moderazioneApi = inject(MaterialiModerazioneApiService);
private readonly materialiApi = inject(MaterialiApiService);
private readonly listeModerazioneApi = inject(ListeModerazioneApiService);
private readonly categorieApi = inject(CategorieApiService);
private readonly tipiEventoApi = inject(TipiEventoApiService);
readonly tab = signal<Tab>('materiali');
// — Materiali proposti —
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);
// Materiali già approvati, mostrati sotto le proposte in attesa (stesso pattern
// della tab "Categorie": la moderazione deve vedere anche il catalogo esistente,
// non solo le richieste da decidere).
readonly materialiLoading = signal(true);
readonly materialiLoadError = signal<string | null>(null);
readonly materiali = signal<MaterialePubblico[]>([]);
readonly materialeEditId = signal<string | null>(null);
readonly materialeNome = signal('');
readonly materialeCategoria = signal('');
readonly materialeUnitaMisura = signal('');
readonly materialeSalvataggioErrore = signal<string | null>(null);
readonly materialeEliminaErroreId = signal<string | null>(null);
readonly materialeEliminaErroreMsg = signal<string | null>(null);
// — Liste in attesa —
// Le liste dei moderatori (il vecchio "catalogo liste modello") vengono scritte
// raramente: gli utenti propongono liste pubbliche che passano da qui prima di
// diventare campioni riusabili come base per altre liste.
readonly listeLoading = signal(true);
readonly listeLoadError = signal<string | null>(null);
readonly listeProposte = signal<Lista[]>([]);
readonly listaDecisioneInCorsoId = signal<string | null>(null);
readonly listaDecisioneErroreId = signal<string | null>(null);
// — Categorie —
readonly categorieLoading = signal(true);
readonly categorieLoadError = signal<string | null>(null);
readonly categoriaList = signal<Categoria[]>([]);
readonly categoriaConfermate = computed(() => this.categoriaList().filter((c) => c.stato === 'confermata'));
readonly categoriaProposte = computed(() => this.categoriaList().filter((c) => c.stato === 'da_approvare'));
readonly categoriaEditId = signal<string | null>(null);
readonly categoriaNome = signal('');
readonly categoriaSalvataggioErrore = signal<string | null>(null);
readonly categoriaEliminaErroreId = signal<string | null>(null);
readonly categoriaEliminaErroreMsg = signal<string | null>(null);
readonly categoriaApprovazioneErroreId = signal<string | null>(null);
readonly categoriaApprovazioneErroreMsg = signal<string | null>(null);
// — Tipi evento —
readonly tipiEventoLoading = signal(true);
readonly tipiEventoLoadError = signal<string | null>(null);
readonly tipoEventoList = signal<TipoEvento[]>([]);
readonly tipoEventoConfermati = computed(() => this.tipoEventoList().filter((t) => t.stato === 'confermata'));
readonly tipoEventoProposti = computed(() => this.tipoEventoList().filter((t) => t.stato === 'da_approvare'));
readonly tipoEventoApprovazioneErroreId = signal<string | null>(null);
readonly tipoEventoApprovazioneErroreMsg = signal<string | null>(null);
readonly tipoEventoEditId = signal<string | null>(null);
readonly tipoEventoNome = signal('');
readonly tipoEventoSalvataggioErrore = signal<string | null>(null);
readonly tipoEventoEliminaErroreId = signal<string | null>(null);
readonly tipoEventoEliminaErroreMsg = signal<string | null>(null);
async ngOnInit(): Promise<void> {
// Arrivo diretto da un link esterno (es. la campanellina di notifica), che
// punta a un tab specifico tramite ?tab=... invece che sempre a 'materiali'.
const tabIniziale = this.route.snapshot.queryParamMap.get('tab');
if (tabIniziale && (TAB_VALIDI as string[]).includes(tabIniziale)) {
this.tab.set(tabIniziale as Tab);
}
await Promise.all([
this.caricaProposte(),
this.caricaMateriali(),
this.caricaListeProposte(),
this.caricaCategorie(),
this.caricaTipiEvento()
]);
}
setTab(tab: Tab): void {
this.tab.set(tab);
}
// — Materiali —
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);
}
}
private async caricaMateriali(): Promise<void> {
this.materialiLoading.set(true);
this.materialiLoadError.set(null);
try {
const materiali = await firstValueFrom(this.materialiApi.getMateriali());
this.materiali.set(materiali);
} catch {
this.materialiLoadError.set('Impossibile caricare i materiali. Riprova più tardi.');
} finally {
this.materialiLoading.set(false);
}
}
modificaMateriale(materiale: MaterialePubblico): void {
this.materialeEditId.set(materiale.id);
this.materialeNome.set(materiale.nome);
this.materialeCategoria.set(materiale.categoria);
this.materialeUnitaMisura.set(materiale.unitaMisura);
this.materialeSalvataggioErrore.set(null);
}
annullaMateriale(): void {
this.materialeEditId.set(null);
this.materialeNome.set('');
this.materialeCategoria.set('');
this.materialeUnitaMisura.set('');
this.materialeSalvataggioErrore.set(null);
}
async salvaMateriale(): Promise<void> {
const nome = this.materialeNome().trim();
const categoria = this.materialeCategoria().trim();
const unitaMisura = this.materialeUnitaMisura().trim();
if (!nome || !categoria || !unitaMisura) {
this.materialeSalvataggioErrore.set('Nome, categoria e unità di misura sono obbligatori.');
return;
}
this.materialeSalvataggioErrore.set(null);
try {
const editId = this.materialeEditId();
const input = { nome, categoria, unitaMisura };
const salvato = editId
? await firstValueFrom(this.materialiApi.aggiornaMateriale(editId, input))
: await firstValueFrom(this.materialiApi.creaMateriale(input));
this.materiali.update((lista) => {
const senzaVecchio = lista.filter((m) => m.id !== salvato.id);
return [...senzaVecchio, salvato].sort((a, b) => a.nome.localeCompare(b.nome));
});
this.annullaMateriale();
} catch {
this.materialeSalvataggioErrore.set('Impossibile salvare il materiale.');
}
}
async eliminaMateriale(materiale: MaterialePubblico): Promise<void> {
this.materialeEliminaErroreId.set(null);
this.materialeEliminaErroreMsg.set(null);
try {
await firstValueFrom(this.materialiApi.eliminaMateriale(materiale.id));
this.materiali.update((lista) => lista.filter((m) => m.id !== materiale.id));
} catch (err) {
this.materialeEliminaErroreId.set(materiale.id);
this.materialeEliminaErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
// — Liste —
async decidiLista(lista: Lista, decisione: DecisioneProposta): Promise<void> {
if (this.listaDecisioneInCorsoId()) {
return;
}
this.listaDecisioneErroreId.set(null);
this.listaDecisioneInCorsoId.set(lista.id);
try {
await firstValueFrom(this.listeModerazioneApi.decidiProposta(lista.id, decisione));
this.listeProposte.update((liste) => liste.filter((l) => l.id !== lista.id));
} catch {
this.listaDecisioneErroreId.set(lista.id);
} finally {
this.listaDecisioneInCorsoId.set(null);
}
}
private async caricaListeProposte(): Promise<void> {
this.listeLoading.set(true);
this.listeLoadError.set(null);
try {
const liste = await firstValueFrom(this.listeModerazioneApi.getProposte());
this.listeProposte.set(liste);
} catch {
this.listeLoadError.set('Impossibile caricare le liste in attesa. Riprova più tardi.');
} finally {
this.listeLoading.set(false);
}
}
// — Categorie —
private async caricaCategorie(): Promise<void> {
this.categorieLoading.set(true);
this.categorieLoadError.set(null);
try {
const categorie = await firstValueFrom(this.categorieApi.getCategorie());
this.categoriaList.set(categorie);
} catch {
this.categorieLoadError.set('Impossibile caricare le categorie. Riprova più tardi.');
} finally {
this.categorieLoading.set(false);
}
}
modificaCategoria(categoria: Categoria): void {
this.categoriaEditId.set(categoria.id);
this.categoriaNome.set(categoria.nome);
this.categoriaSalvataggioErrore.set(null);
}
annullaCategoria(): void {
this.categoriaEditId.set(null);
this.categoriaNome.set('');
this.categoriaSalvataggioErrore.set(null);
}
async salvaCategoria(): Promise<void> {
const nome = this.categoriaNome().trim();
if (!nome) {
return;
}
this.categoriaSalvataggioErrore.set(null);
try {
const editId = this.categoriaEditId();
const salvata = editId
? await firstValueFrom(this.categorieApi.aggiornaCategoria(editId, nome))
: await firstValueFrom(this.categorieApi.creaCategoria(nome));
this.categoriaList.update((lista) => {
const senzaVecchia = lista.filter((c) => c.id !== salvata.id);
return [...senzaVecchia, salvata].sort((a, b) => a.nome.localeCompare(b.nome));
});
this.annullaCategoria();
} catch {
this.categoriaSalvataggioErrore.set('Impossibile salvare la categoria.');
}
}
async eliminaCategoria(categoria: Categoria): Promise<void> {
this.categoriaEliminaErroreId.set(null);
this.categoriaEliminaErroreMsg.set(null);
try {
await firstValueFrom(this.categorieApi.eliminaCategoria(categoria.id));
this.categoriaList.update((lista) => lista.filter((c) => c.id !== categoria.id));
} catch (err) {
this.categoriaEliminaErroreId.set(categoria.id);
this.categoriaEliminaErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async approvaCategoria(categoria: Categoria): Promise<void> {
this.categoriaApprovazioneErroreId.set(null);
this.categoriaApprovazioneErroreMsg.set(null);
try {
const approvata = await firstValueFrom(this.categorieApi.approvaCategoria(categoria.id));
this.categoriaList.update((lista) => lista.map((c) => (c.id === approvata.id ? approvata : c)));
} catch (err) {
this.categoriaApprovazioneErroreId.set(categoria.id);
this.categoriaApprovazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async rifiutaCategoria(categoria: Categoria): Promise<void> {
this.categoriaApprovazioneErroreId.set(null);
this.categoriaApprovazioneErroreMsg.set(null);
try {
await firstValueFrom(this.categorieApi.rifiutaCategoria(categoria.id));
this.categoriaList.update((lista) => lista.filter((c) => c.id !== categoria.id));
} catch (err) {
this.categoriaApprovazioneErroreId.set(categoria.id);
this.categoriaApprovazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
// — Tipi evento —
private async caricaTipiEvento(): Promise<void> {
this.tipiEventoLoading.set(true);
this.tipiEventoLoadError.set(null);
try {
const tipiEvento = await firstValueFrom(this.tipiEventoApi.getTipiEventoModerazione());
this.tipoEventoList.set(tipiEvento);
} catch {
this.tipiEventoLoadError.set('Impossibile caricare i tipi evento. Riprova più tardi.');
} finally {
this.tipiEventoLoading.set(false);
}
}
modificaTipoEvento(tipoEvento: TipoEvento): void {
this.tipoEventoEditId.set(tipoEvento.id);
this.tipoEventoNome.set(tipoEvento.nome);
this.tipoEventoSalvataggioErrore.set(null);
}
annullaTipoEvento(): void {
this.tipoEventoEditId.set(null);
this.tipoEventoNome.set('');
this.tipoEventoSalvataggioErrore.set(null);
}
async salvaTipoEvento(): Promise<void> {
const nome = this.tipoEventoNome().trim();
if (!nome) {
return;
}
this.tipoEventoSalvataggioErrore.set(null);
try {
const editId = this.tipoEventoEditId();
const salvato = editId
? await firstValueFrom(this.tipiEventoApi.updateTipoEvento(editId, nome))
: await firstValueFrom(this.tipiEventoApi.createTipoEvento(nome));
this.tipoEventoList.update((lista) => {
const senzaVecchio = lista.filter((t) => t.id !== salvato.id);
return [...senzaVecchio, salvato].sort((a, b) => a.nome.localeCompare(b.nome));
});
this.annullaTipoEvento();
} catch {
this.tipoEventoSalvataggioErrore.set('Impossibile salvare il tipo evento.');
}
}
async eliminaTipoEvento(tipoEvento: TipoEvento): Promise<void> {
this.tipoEventoEliminaErroreId.set(null);
this.tipoEventoEliminaErroreMsg.set(null);
try {
await firstValueFrom(this.tipiEventoApi.deleteTipoEvento(tipoEvento.id));
this.tipoEventoList.update((lista) => lista.filter((t) => t.id !== tipoEvento.id));
} catch (err) {
this.tipoEventoEliminaErroreId.set(tipoEvento.id);
this.tipoEventoEliminaErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async approvaTipoEvento(tipoEvento: TipoEvento): Promise<void> {
this.tipoEventoApprovazioneErroreId.set(null);
this.tipoEventoApprovazioneErroreMsg.set(null);
try {
const approvato = await firstValueFrom(this.tipiEventoApi.approvaTipoEvento(tipoEvento.id));
this.tipoEventoList.update((lista) => lista.map((t) => (t.id === approvato.id ? approvato : t)));
} catch (err) {
this.tipoEventoApprovazioneErroreId.set(tipoEvento.id);
this.tipoEventoApprovazioneErroreMsg.set(this.estraiMessaggioErrore(err));
}
}
async rifiutaTipoEvento(tipoEvento: TipoEvento): Promise<void> {
this.tipoEventoApprovazioneErroreId.set(null);
this.tipoEventoApprovazioneErroreMsg.set(null);
try {
await firstValueFrom(this.tipiEventoApi.rifiutaTipoEvento(tipoEvento.id));
this.tipoEventoList.update((lista) => lista.filter((t) => t.id !== tipoEvento.id));
} catch (err) {
this.tipoEventoApprovazioneErroreId.set(tipoEvento.id);
this.tipoEventoApprovazioneErroreMsg.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.";
}
}
-7
View File
@@ -6,13 +6,6 @@
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Caprasimo&family=Figtree:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
</head>
<body>
<app-root></app-root>
@@ -1,38 +0,0 @@
// Include theming for Angular Material with `mat.theme()`.
// This Sass mixin will define CSS variables that are used for styling Angular Material
// components according to the Material 3 design spec.
// Learn more about theming and how to use it for your application's
// custom components at https://material.angular.dev/guide/theming
@use '@angular/material' as mat;
html {
height: 100%;
@include mat.theme(
(
color: (
primary: mat.$azure-palette,
tertiary: mat.$blue-palette,
),
typography: Roboto,
density: 0,
)
);
}
body {
// Default the application to a light color theme. This can be changed to
// `dark` to enable the dark color theme, or to `light dark` to defer to the
// user's system settings.
color-scheme: light;
// Set a default background, font and text colors for the application using
// Angular Material's system-level CSS variables. Learn more about these
// variables at https://material.angular.dev/guide/system-variables
background-color: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
font: var(--mat-sys-body-medium);
// Reset the user agent margin.
margin: 0;
height: 100%;
}
+56 -55
View File
@@ -1,48 +1,53 @@
/* Organic — design-system tokens and component classes (importato da Claude Design). */
@import url('https://fonts.cdnfonts.com/css/opendyslexic');
/* Design system allineato a scouthub-attivita-fe: stessa palette oklch, stesso font,
stessi raggi/ombre "piatti" — solo i nomi dei token restano quelli storici di questo
progetto (--color-accent, --om-*, ecc.) per non dover riscrivere ogni componente. */
:root {
--color-bg: #f5ead8;
--color-surface: #ebddc5;
--color-text: #201e1d;
--color-accent: #c67139;
--color-accent-2: #7a8a5e;
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
--color-bg: oklch(96% 0.018 75);
--color-surface: oklch(99% 0.008 75);
--color-header: oklch(94% 0.02 75);
--color-text: oklch(24% 0.02 55);
--color-accent: oklch(63% 0.19 40);
--color-accent-2: oklch(63% 0.09 135);
--color-divider: oklch(87% 0.02 70);
/* Rampe tonali — generate in OKLCH su una scala di luminosità condivisa,
così lo stesso step di ogni ruolo ha lo stesso peso visivo. */
--color-neutral-100: #f9f4ed;
--color-neutral-200: #eee7db;
--color-neutral-300: #dcd3c4;
--color-neutral-400: #c0b6a5;
--color-neutral-500: #a19786;
--color-neutral-600: #82796a;
--color-neutral-700: #645c50;
--color-neutral-800: #474238;
--color-neutral-900: #2e2b25;
/* Rampe tonali in oklch, stesso hue/chroma della coppia bg/accent qui sopra, così ogni
step ha lo stesso peso visivo cromatico del design system di scouthub-attivita-fe. */
--color-neutral-100: oklch(97% 0.008 55);
--color-neutral-200: oklch(93% 0.012 55);
--color-neutral-300: oklch(87% 0.016 55);
--color-neutral-400: oklch(78% 0.02 55);
--color-neutral-500: oklch(68% 0.02 55);
--color-neutral-600: oklch(56% 0.02 55);
--color-neutral-700: oklch(45% 0.02 55);
--color-neutral-800: oklch(33% 0.02 55);
--color-neutral-900: oklch(22% 0.02 55);
--color-accent-100: #fff2eb;
--color-accent-200: #ffe1d0;
--color-accent-300: #ffc6a5;
--color-accent-400: #f6a06b;
--color-accent-500: #d67f48;
--color-accent-600: #b2622d;
--color-accent-700: #8c491a;
--color-accent-800: #643312;
--color-accent-900: #402310;
--color-accent-100: oklch(95% 0.03 40);
--color-accent-200: oklch(90% 0.06 40);
--color-accent-300: oklch(83% 0.1 40);
--color-accent-400: oklch(74% 0.14 40);
--color-accent-500: oklch(63% 0.19 40);
--color-accent-600: oklch(56% 0.19 40);
--color-accent-700: oklch(48% 0.17 40);
--color-accent-800: oklch(40% 0.15 40);
--color-accent-900: oklch(30% 0.12 40);
--color-accent-2-100: #f0fae1;
--color-accent-2-200: #e1eecc;
--color-accent-2-300: #ccdbb2;
--color-accent-2-400: #aebf92;
--color-accent-2-500: #8fa073;
--color-accent-2-600: #728157;
--color-accent-2-700: #56633f;
--color-accent-2-800: #3d472b;
--color-accent-2-900: #272e1b;
--color-accent-2-100: oklch(95% 0.02 135);
--color-accent-2-200: oklch(90% 0.035 135);
--color-accent-2-300: oklch(83% 0.05 135);
--color-accent-2-400: oklch(74% 0.07 135);
--color-accent-2-500: oklch(63% 0.09 135);
--color-accent-2-600: oklch(56% 0.09 135);
--color-accent-2-700: oklch(48% 0.08 135);
--color-accent-2-800: oklch(40% 0.07 135);
--color-accent-2-900: oklch(30% 0.06 135);
--font-heading: "Caprasimo", system-ui, sans-serif;
--font-heading-weight: 400;
--font-body: "Figtree", system-ui, sans-serif;
--font-heading: 'Open Dyslexic', 'Segoe UI', sans-serif;
--font-heading-weight: 700;
--font-body: 'Open Dyslexic', 'Segoe UI', sans-serif;
--space-1: 4.4px;
--space-2: 8.8px;
@@ -52,12 +57,12 @@
--space-8: 35.2px;
--radius-sm: 8px;
--radius-md: 16px;
--radius-lg: 28px;
--radius-md: 10px;
--radius-lg: 14px;
--shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
--shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
--shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
--shadow-sm: 0 1px 2px color-mix(in srgb, var(--color-neutral-900) 14%, transparent);
--shadow-md: 0 3px 10px color-mix(in srgb, var(--color-neutral-900) 16%, transparent);
--shadow-lg: 0 12px 32px color-mix(in srgb, var(--color-neutral-900) 22%, transparent);
}
html, body {
@@ -123,7 +128,7 @@ figcaption {
}
.btn svg { display: block; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--color-accent); color: var(--color-bg); }
.btn-primary { background: var(--color-accent); color: #fff; }
.btn-primary:hover { background: var(--color-accent-600); }
.btn-primary:active { background: var(--color-accent-700); }
.btn-secondary { border-color: var(--color-divider); }
@@ -172,14 +177,15 @@ textarea.input { min-height: 90px; resize: vertical; }
padding: 7px 12px; font-size: 13px; cursor: pointer;
}
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); }
.seg-opt:has(input:checked) { background: var(--color-accent); color: #fff; }
.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }
/* — cards — */
.card {
display: flex; flex-direction: column; gap: var(--space-2);
padding: var(--space-3); border-radius: var(--radius-md); background: var(--color-surface);
padding: var(--space-3); border-radius: var(--radius-lg); background: var(--color-surface);
border: 1px solid var(--color-divider);
}
.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
.card-title {
@@ -199,7 +205,7 @@ textarea.input { min-height: 90px; resize: vertical; }
.tag {
display: inline-flex; align-items: center; font-size: 11px;
letter-spacing: 0.02em; padding: 3px 10px;
border-radius: calc(var(--radius-md) * 0.75);
border-radius: 999px;
}
.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); }
.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
@@ -250,12 +256,7 @@ textarea.input { min-height: 90px; resize: vertical; }
.dialog-body { font-size: 14px; opacity: 0.85; }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }
/* — rounded frame: tutto si ammorbidisce, i controlli piccoli diventano pillole — */
.card, .dialog { border-radius: calc(var(--radius-lg) * 1.15); }
.btn, .tag, .seg, .input { border-radius: 999px; }
.input { padding-inline: 14px; }
/* — layout di pagina condiviso tra le sezioni (da Claude Design) — */
/* — layout di pagina condiviso tra le sezioni — */
.om-page { max-width: 980px; margin: 0 auto; padding: var(--space-6) var(--space-4) var(--space-8); }
.om-section-title { margin: 0 0 var(--space-1); }
.om-section-sub { margin: 0 0 var(--space-5); opacity: 0.7; font-size: 14px; }
@@ -266,4 +267,4 @@ textarea.input { min-height: 90px; resize: vertical; }
.om-empty { opacity: 0.6; font-size: 14px; padding: var(--space-4) 0; }
/* — variante attiva per i .seg-opt pilotati via classe (filtri a click, non radio) — */
.seg-opt--attiva { background: var(--color-accent); color: var(--color-bg); }
.seg-opt--attiva { background: var(--color-accent); color: #fff; }