Sistemato magazzino
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": ["src/material-theme.scss", "src/styles.css"]
|
||||
"styles": ["src/styles.css"]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
<router-outlet />
|
||||
<div class="app-shell">
|
||||
<app-header />
|
||||
<router-outlet />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+77
-6
@@ -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 {
|
||||
|
||||
+67
-36
@@ -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>
|
||||
|
||||
+183
-33
@@ -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');
|
||||
|
||||
|
||||
+67
-17
@@ -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
-49
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
+80
@@ -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>
|
||||
}
|
||||
+212
@@ -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');
|
||||
});
|
||||
});
|
||||
+116
@@ -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>
|
||||
+2
-2
@@ -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.";
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user