Add scouthub-magazzino-fe

This commit is contained in:
Lorenzo Sanesi
2026-07-25 11:51:35 +02:00
parent c34bca869f
commit 7f1d0d5ce3
80 changed files with 12851 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+45
View File
@@ -0,0 +1,45 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
/.vscode/
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+23
View File
@@ -0,0 +1,23 @@
FROM node:22-bookworm-slim AS build
ARG KEYCLOAK_BASE_URL
ARG MAGAZZINO_API_BASE_URL
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN sed -i \
-e "s#__KEYCLOAK_BASE_URL__#${KEYCLOAK_BASE_URL}#" \
-e "s#__MAGAZZINO_API_BASE_URL__#${MAGAZZINO_API_BASE_URL}#" \
src/environments/environment.ts
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist/scouthub-magazzino-fe/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+169
View File
@@ -0,0 +1,169 @@
# scouthub-magazzino-fe
Frontend Angular per la gestione del **magazzino** Scouthub (catalogo materiali, liste per
uscite/eventi, giacenze, checklist evento), affianca `scouthub-attivita-fe` e `scouthub-home-fe`
nello stesso ecosistema. Parla con `scouthub-magazzino-be` per le API e con Keycloak per
l'autenticazione.
Componenti standalone (nessun NgModule), routing con lazy loading per feature, Angular Material
per la UI.
## Stato del progetto
Tutte le feature principali sono implementate: catalogo materiali pubblico con proposta di nuovi
materiali, liste modello, liste per organizzazione (anche a partire da un modello), gestione
giacenze di magazzino, creazione/dettaglio evento con checklist, e un'area di moderazione riservata
al ruolo `moderatore` per approvare/rifiutare i materiali proposti.
## Struttura cartelle
```
src/app/
core/
auth/
keycloak.provider.ts provideKeycloakAngular() — login OIDC Authorization Code + PKCE
roles.ts MODERATORE_ROLE + extractRealmRoles()
require-auth.guard.ts requireAuthGuard — richiede login, altrimenti avvia keycloak.login()
catalogo/ feature "catalogo materiali" (/, lazy-loaded, in gran parte pubblica)
catalogo-materiali/ elenco materiali del catalogo pubblico
catalogo-liste-modello/ elenco liste modello (standard per tipo evento)
proponi-materiale/ form di proposta di un nuovo materiale (richiede login)
*-api.service.ts client HTTP verso /materiali, /liste-modello, /tipi-evento
liste/ feature "liste materiale per org" (/liste, lazy-loaded, richiede login)
liste-list/ elenco liste dell'organizzazione
lista-editor/ creazione/modifica di una lista (anche da modello)
magazzino/ feature "giacenze di magazzino" (/magazzino, lazy-loaded, richiede login)
eventi/ feature "eventi e checklist" (/eventi, lazy-loaded, richiede login)
crea-evento/ creazione di un evento a partire da una lista
evento-dettaglio/ dettaglio evento con checklist portato/non portato
moderazione/ feature "moderazione catalogo" (/moderazione, lazy-loaded, richiede login
+ ruolo realm moderatore)
require-moderatore.guard.ts guard di routing basata sul claim realm_access.roles del token
src/environments/ environment.ts / environment.development.ts
public/
silent-check-sso.html richiesto dal flusso check-sso silenzioso di Keycloak
```
### Modulo di autenticazione
- **Login**: `provideKeycloak` (in `keycloak.provider.ts`) usa il client pubblico
`scouthub-frontend`**condiviso con tutti gli altri frontend** (`scouthub-attivita-fe`,
`scouthub-home-fe`, `scouthub-eventi-fe`) — con Authorization Code
Flow + PKCE, `onLoad: 'check-sso'` e refresh automatico del token via `withAutoRefreshToken`
(logout automatico dopo 5 minuti di inattività).
- **requireAuthGuard** (`core/require-auth.guard.ts`): se l'utente non è autenticato avvia
`keycloak.login()` con redirect di ritorno sulla stessa rotta richiesta; altrimenti lascia
proseguire. Applicato a tutte le rotte tranne il catalogo pubblico e le liste modello.
- **requireModeratoreGuard** (`moderazione/require-moderatore.guard.ts`): legge i ruoli
realm dal token (`extractRealmRoles`) e consente l'accesso solo se è presente
`moderatore`, altrimenti reindirizza a `/`. Va sempre applicato **dopo** `requireAuthGuard`
(presuppone un utente già autenticato).
- Le chiamate verso `magazzinoApiBaseUrl` ricevono automaticamente l'header
`Authorization: Bearer <token>` tramite l'interceptor `includeBearerTokenInterceptor` di
`keycloak-angular`, condizionato via regex sull'URL base dell'API in `app.config.ts`.
### Feature `catalogo` (`/`)
Catalogo materiali e liste modello pubblici: consultabili anche senza autenticazione, in linea con
l'idea di un catalogo condiviso tra tutte le organizzazioni. Solo `proponi-materiale`
(`POST /materiali/proposte`) richiede login, perché la proposta viene associata all'organizzazione
dell'utente (`propostoDaOrgId`) e finisce nella coda di moderazione.
### Feature `liste` (`/liste`)
Elenco e editor delle liste materiale della propria organizzazione. Una lista può essere creata da
zero o a partire da una lista modello del catalogo pubblico. Richiede login e un'organizzazione
attiva sul token (il backend filtra sempre per `orgId`, mai da input client).
### Feature `magazzino` (`/magazzino`)
Gestione delle giacenze di magazzino dell'organizzazione (quantità, stato buono/da
riparare/mancante, posizione). Richiede login.
### Feature `eventi` (`/eventi`)
Creazione di un evento a partire da una lista e, nel dettaglio evento, gestione della checklist di
cosa è stato effettivamente portato/non portato. Richiede login.
### Feature `moderazione` (`/moderazione`)
Coda di revisione dei materiali proposti dalle organizzazioni (approvazione/rifiuto), riservata a
chi ha il ruolo realm `moderatore`.
## Prerequisiti
- Node.js 20+
- I servizi dell'ecosistema Scouthub in esecuzione: Keycloak e `scouthub-magazzino-be`
(vedi `docker-compose.yml` e `scouthub-magazzino-be/README.md` nella root del repo)
## Configurazione ambiente
`src/environments/environment.ts` (produzione) e `environment.development.ts` (dev, usato
automaticamente da `ng serve`/`ng build --configuration development`) espongono:
- `keycloakBaseUrl` — URL base dell'istanza Keycloak (default `http://localhost:8081`, coerente
con `KEYCLOAK_PORT` in `.env` alla root del repo)
- `keycloakRealm` — realm Keycloak (`scouthub`)
- `keycloakClientId` — client pubblico condiviso già definito in `keycloak/realm-export.json`
(`scouthub-frontend`)
- `magazzinoApiBaseUrl` — URL base di `scouthub-magazzino-be` (default `http://localhost:8083`)
## Collegare Keycloak in locale
1. Avviare Keycloak (e Postgres) dalla root del repo:
```bash
docker compose up -d keycloak-db keycloak
```
Il realm `scouthub` viene importato automaticamente da `keycloak/realm-export.json` al primo
avvio (`--import-realm`).
2. Il client pubblico `scouthub-frontend` ha `redirectUris`/`webOrigins` che includono anche
`http://localhost:4203` (porta di default di questo progetto, vedi sotto) — se si cambia porta,
aggiornare `keycloak/realm-export.json` di conseguenza e reimportare il realm (o aggiornarlo
da console admin Keycloak).
3. `provideKeycloakAngular()` (`src/app/core/auth/keycloak.provider.ts`) inizializza il client con
`onLoad: 'check-sso'`: all'avvio l'app verifica in un iframe nascosto se esiste già una sessione
Keycloak, senza forzare un redirect immediato per le pagine pubbliche.
## Collegare scouthub-magazzino-be in locale
1. Avviare `scouthub-magazzino-be` seguendo il suo README (`npm run dev`, porta di default `8083`).
2. Le richieste verso `magazzinoApiBaseUrl` ricevono automaticamente l'header
`Authorization: Bearer <token>` tramite l'interceptor `includeBearerTokenInterceptor` di
`keycloak-angular`, configurato in `app.config.ts`.
## Development server
Questo progetto usa la porta `4203` (per non collidere con `scouthub-attivita-fe` sulla `4200` e
`scouthub-home-fe` sulla `4201`):
```bash
ng serve
```
Apri il browser su `http://localhost:4203/`.
## Build
```bash
ng build
```
Artefatti di build in `dist/scouthub-magazzino-fe`.
## Test
```bash
ng test
```
Esegue gli unit test con Vitest.
## Versione Angular CLI usata per lo scaffold
```
Angular CLI : 22.0.7
Angular : 22.0.8
Node.js : 24.16.0
Package Manager : npm 11.12.0
Operating System : win32 x64
```
+80
View File
@@ -0,0 +1,80 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"scouthub-magazzino-fe": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/material-theme.scss", "src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.development.ts"
}
]
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"port": 4202
},
"configurations": {
"production": {
"buildTarget": "scouthub-magazzino-fe:build:production"
},
"development": {
"buildTarget": "scouthub-magazzino-fe:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+10
View File
@@ -0,0 +1,10 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "scouthub-magazzino-fe",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"packageManager": "npm@11.12.0",
"dependencies": {
"@angular/cdk": "^22.0.6",
"@angular/common": "^22.0.0",
"@angular/compiler": "^22.0.0",
"@angular/core": "^22.0.0",
"@angular/forms": "^22.0.0",
"@angular/material": "^22.0.6",
"@angular/platform-browser": "^22.0.0",
"@angular/router": "^22.0.0",
"keycloak-angular": "^22.0.0",
"keycloak-js": "^26.2.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
},
"devDependencies": {
"@angular/build": "^22.0.7",
"@angular/cli": "^22.0.7",
"@angular/compiler-cli": "^22.0.0",
"jsdom": "^28.0.0",
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^4.0.8"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,7 @@
<html>
<body>
<script>
parent.postMessage(location.href, location.origin);
</script>
</body>
</html>
@@ -0,0 +1,33 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {
createInterceptorCondition,
IncludeBearerTokenCondition,
includeBearerTokenInterceptor,
INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG
} from 'keycloak-angular';
import { routes } from './app.routes';
import { provideKeycloakAngular } from './core/auth/keycloak.provider';
import { environment } from '../environments/environment';
const escapedMagazzinoApiBaseUrl = environment.magazzinoApiBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const magazzinoApiBearerCondition = createInterceptorCondition<IncludeBearerTokenCondition>({
urlPattern: new RegExp(`^${escapedMagazzinoApiBaseUrl}(/.*)?$`, 'i'),
bearerPrefix: 'Bearer'
});
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideKeycloakAngular(),
{
provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
useValue: [magazzinoApiBearerCondition]
},
provideHttpClient(withInterceptors([includeBearerTokenInterceptor]))
]
};
+1
View File
@@ -0,0 +1 @@
<router-outlet />
@@ -0,0 +1,25 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
// Pubblica: il catalogo del materiale è consultabile anche da chi non è autenticato.
path: '',
loadChildren: () => import('./catalogo/catalogo.routes').then((m) => m.CATALOGO_ROUTES)
},
{
path: 'liste',
loadChildren: () => import('./liste/liste.routes').then((m) => m.LISTE_ROUTES)
},
{
path: 'magazzino',
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)
}
];
+16
View File
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App {}
@@ -0,0 +1,33 @@
.catalogo-liste-modello__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: 0;
}
.catalogo-liste-modello__error {
color: var(--color-accent-800);
}
.tipo-evento-gruppo {
margin-bottom: var(--space-8);
}
.lista-modello-card__voci {
margin: 0;
padding-left: 1.1rem;
font-size: 13px;
opacity: 0.8;
}
.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,53 @@
<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>
</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) {
<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>
}
@if (creazioneErroreId() === lista.id) {
<p class="lista-modello-card__errore" role="alert">
Impossibile creare la lista. Riprova più tardi.
</p>
}
<button
type="button"
class="btn btn-secondary btn-block"
[disabled]="creazioneInCorsoId() === lista.id"
(click)="usaComeBase(lista.id)"
>
Usa come base
</button>
</div>
}
</div>
</section>
}
}
</section>
@@ -0,0 +1,167 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
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 { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
import { CatalogoListeModello } from './catalogo-liste-modello';
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 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' }
];
const listeModello: ListaModello[] = [
{
id: 'lm-1',
nome: 'Uscita di un giorno',
tipoEventoId: 'te-1',
voci: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }]
},
{
id: 'lm-2',
nome: 'Campo estivo standard',
tipoEventoId: 'te-2',
voci: [{ materialeId: 'mat-2', nome: 'Telo cerato', unitaMisura: 'pz', quantita: 5 }]
}
];
// Un secondo giro di detectChanges dopo whenStable() rende visibile in DOM
// l'aggiornamento dei signal impostato dai due firstValueFrom in sequenza in carica().
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [CatalogoListeModello],
providers: [
provideRouter([]),
{ provide: TipiEventoApiService, useValue: tipiEventoApi },
{ provide: ListeModelloApiService, useValue: listeModelloApi },
{ provide: ListeApiService, useValue: listeApi },
{ provide: Keycloak, useValue: keycloak }
]
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true);
fixture = TestBed.createComponent(CatalogoListeModello);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
tipiEventoApi = { getTipiEvento: vi.fn().mockReturnValue(of(tipiEvento)) };
listeModelloApi = { getListeModello: vi.fn().mockReturnValue(of(listeModello)) };
listeApi = { creaListaDaModello: vi.fn() };
});
it('mostra le liste modello raggruppate per tipo evento con voci e quantità', async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
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);
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
keycloak = { authenticated: false, login: vi.fn() };
tipiEventoApi = { getTipiEvento: vi.fn().mockReturnValue(throwError(() => new Error('network error'))) };
await setup();
expect(component.loading()).toBe(false);
expect(component.loadError()).toBe('Impossibile caricare le liste modello. Riprova più tardi.');
});
describe('pulsante "Usa come base" — utente non autenticato', () => {
beforeEach(async () => {
keycloak = { authenticated: false, login: vi.fn() };
await setup();
fixture.detectChanges();
});
it('mostra l\'avviso di accesso su ogni lista modello', () => {
const compiled = fixture.nativeElement as HTMLElement;
const avvisi = compiled.querySelectorAll('.lista-modello-card__avviso');
expect(avvisi.length).toBe(2);
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 () => {
await component.usaComeBase('lm-1');
expect(keycloak.login).toHaveBeenCalledWith({ redirectUri: window.location.href });
expect(listeApi.creaListaDaModello).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', () => {
beforeEach(async () => {
keycloak = { authenticated: true, login: vi.fn() };
await setup();
fixture.detectChanges();
});
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));
await component.usaComeBase('lm-1');
expect(keycloak.login).not.toHaveBeenCalled();
expect(listeApi.creaListaDaModello).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')));
await component.usaComeBase('lm-1');
expect(component.creazioneErroreId()).toBe('lm-1');
expect(router.navigateByUrl).not.toHaveBeenCalled();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.lista-modello-card__errore')?.textContent).toContain(
'Impossibile creare la lista'
);
});
});
});
@@ -0,0 +1,89 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
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 { TipoEvento, TipiEventoApiService } from '../tipi-evento-api.service';
interface GruppoTipoEvento {
tipoEvento: TipoEvento;
liste: ListaModello[];
}
@Component({
selector: 'app-catalogo-liste-modello',
imports: [MatButtonModule, MatCardModule, RouterLink],
templateUrl: './catalogo-liste-modello.html',
styleUrl: './catalogo-liste-modello.css'
})
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);
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly tipiEvento = signal<TipoEvento[]>([]);
readonly listeModello = signal<ListaModello[]>([]);
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);
});
get isAuthenticated(): boolean {
return this.keycloak.authenticated ?? false;
}
async ngOnInit(): Promise<void> {
await this.carica();
}
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.creaListaDaModello(listaModelloId));
this.creazioneInCorsoId.set(null);
await this.router.navigateByUrl(`/liste/${lista.id}`);
} catch {
this.creazioneInCorsoId.set(null);
this.creazioneErroreId.set(listaModelloId);
}
}
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.listeModelloApi.getListeModello());
this.tipiEvento.set(tipiEvento);
this.listeModello.set(listeModello);
} catch {
this.loadError.set('Impossibile caricare le liste modello. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,29 @@
.catalogo-materiali__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
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);
width: fit-content;
}
.catalogo-materiali__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: var(--space-4);
}
.catalogo-materiali__error {
color: var(--color-accent-800);
}
@@ -0,0 +1,62 @@
<section class="catalogo-materiali om-page">
<div class="catalogo-materiali__header">
<div>
<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>
</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) {
<p class="catalogo-materiali__error" role="alert">{{ message }}</p>
} @else {
<div class="seg catalogo-materiali__filtri" role="group" aria-label="Filtra per categoria">
<button
type="button"
class="seg-opt"
[class.seg-opt--attiva]="categoriaSelezionata() === null"
(click)="selezionaCategoria(null)"
>
Tutte
</button>
@for (categoria of categorie(); track categoria) {
<button
type="button"
class="seg-opt"
[class.seg-opt--attiva]="categoriaSelezionata() === categoria"
(click)="selezionaCategoria(categoria)"
>
{{ categoria }}
</button>
}
</div>
@if (materialiFiltrati().length === 0) {
<p class="om-empty">Nessun materiale trovato.</p>
} @else {
<div class="catalogo-materiali__grid">
@for (materiale of materialiFiltrati(); track materiale.id) {
<div class="card materiale-card">
<div class="card-kicker">{{ materiale.categoria }}</div>
<div class="card-title">{{ materiale.nome }}</div>
<p class="card-body">Unità di misura: {{ materiale.unitaMisura }}</p>
</div>
}
</div>
}
}
</section>
@@ -0,0 +1,127 @@
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';
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' },
{ id: 'mat-2', nome: 'Telo cerato', categoria: 'Campeggio', unitaMisura: 'pz' },
{ id: 'mat-3', nome: 'Moschettone', categoria: 'Attrezzatura', unitaMisura: 'pz' }
];
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [CatalogoMateriali],
providers: [
provideRouter([]),
{ provide: MaterialiApiService, useValue: materialiApi },
{ provide: Keycloak, useValue: keycloak }
]
}).compileComponents();
fixture = TestBed.createComponent(CatalogoMateriali);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
keycloak = { authenticated: false };
});
it('mostra tutti i materiali del catalogo dopo il caricamento', async () => {
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
await setup();
expect(component.loading()).toBe(false);
expect(component.materiali()).toEqual(materiali);
const compiled = fixture.nativeElement as HTMLElement;
const cards = compiled.querySelectorAll('.materiale-card');
expect(cards.length).toBe(3);
});
it('filtra i materiali per categoria quando si seleziona un filtro', async () => {
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
await setup();
component.selezionaCategoria('Attrezzatura');
fixture.detectChanges();
expect(component.materialiFiltrati()).toEqual([materiali[0], materiali[2]]);
const compiled = fixture.nativeElement as HTMLElement;
const cards = compiled.querySelectorAll('.materiale-card');
expect(cards.length).toBe(2);
});
it('torna a mostrare tutti i materiali selezionando di nuovo "Tutte"', async () => {
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materiali)) };
await setup();
component.selezionaCategoria('Campeggio');
component.selezionaCategoria(null);
expect(component.materialiFiltrati()).toEqual(materiali);
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
materialiApi = { getMateriali: vi.fn().mockReturnValue(throwError(() => new Error('network error'))) };
await setup();
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();
});
});
});
@@ -0,0 +1,66 @@
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({
selector: 'app-catalogo-materiali',
imports: [MatButtonModule, MatCardModule, RouterLink],
templateUrl: './catalogo-materiali.html',
styleUrl: './catalogo-materiali.css'
})
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);
readonly materiali = signal<MaterialePubblico[]>([]);
readonly categoriaSelezionata = signal<string | null>(null);
readonly categorie = computed(() => {
const insieme = new Set(this.materiali().map((materiale) => materiale.categoria));
return Array.from(insieme).sort((a, b) => a.localeCompare(b));
});
readonly materialiFiltrati = computed(() => {
const categoria = this.categoriaSelezionata();
const materiali = this.materiali();
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();
}
selezionaCategoria(categoria: string | null): void {
this.categoriaSelezionata.set(categoria);
}
private async caricaMateriali(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const materiali = await firstValueFrom(this.materialiApi.getMateriali());
this.materiali.set(materiali);
} catch {
this.loadError.set('Impossibile caricare il catalogo dei materiali. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,24 @@
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',
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]
}
];
@@ -0,0 +1,29 @@
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}`, {});
}
}
@@ -0,0 +1,28 @@
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`);
}
}
@@ -0,0 +1,41 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface MaterialePubblico {
id: string;
nome: string;
categoria: string;
unitaMisura: string;
}
export interface ProponiMaterialeInput {
nome: string;
categoria: string;
unitaMisura: string;
}
export interface MaterialeProposta {
id: string;
nome: string;
categoria: string;
unitaMisura: string;
stato: 'proposto' | 'approvato' | 'rifiutato';
propostoDaOrgId: string;
creatoIl: string;
}
@Injectable({ providedIn: 'root' })
export class MaterialiApiService {
private readonly http = inject(HttpClient);
getMateriali(): Observable<MaterialePubblico[]> {
return this.http.get<MaterialePubblico[]>(`${environment.magazzinoApiBaseUrl}/materiali`);
}
proponiMateriale(input: ProponiMaterialeInput): Observable<MaterialeProposta> {
return this.http.post<MaterialeProposta>(`${environment.magazzinoApiBaseUrl}/materiali/proposte`, input);
}
}
@@ -0,0 +1,21 @@
.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);
}
@@ -0,0 +1,58 @@
<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>
@@ -0,0 +1,96 @@
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);
});
});
@@ -0,0 +1,72 @@
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.');
}
}
}
@@ -0,0 +1,19 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export interface TipoEvento {
id: string;
nome: string;
}
@Injectable({ providedIn: 'root' })
export class TipiEventoApiService {
private readonly http = inject(HttpClient);
getTipiEvento(): Observable<TipoEvento[]> {
return this.http.get<TipoEvento[]>(`${environment.magazzinoApiBaseUrl}/tipi-evento`);
}
}
@@ -0,0 +1,26 @@
import { EnvironmentProviders } from '@angular/core';
import { provideKeycloak, withAutoRefreshToken, AutoRefreshTokenService, UserActivityService } from 'keycloak-angular';
import { environment } from '../../../environments/environment';
export function provideKeycloakAngular(): EnvironmentProviders {
return provideKeycloak({
config: {
url: environment.keycloakBaseUrl,
realm: environment.keycloakRealm,
clientId: environment.keycloakClientId
},
initOptions: {
onLoad: 'check-sso',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
redirectUri: window.location.origin + '/'
},
features: [
withAutoRefreshToken({
onInactivityTimeout: 'logout',
sessionTimeout: 300000
})
],
providers: [AutoRefreshTokenService, UserActivityService]
});
}
@@ -0,0 +1,7 @@
import { KeycloakTokenParsed } from 'keycloak-js';
export const MODERATORE_ROLE = 'moderatore';
export function extractRealmRoles(tokenParsed: KeycloakTokenParsed | undefined): string[] {
return tokenParsed?.realm_access?.roles ?? [];
}
@@ -0,0 +1,19 @@
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot, UrlTree } from '@angular/router';
import { AuthGuardData, createAuthGuard } from 'keycloak-angular';
export async function isAuthenticated(
_route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
authData: AuthGuardData
): Promise<boolean | UrlTree> {
const { authenticated, keycloak } = authData;
if (!authenticated) {
await keycloak.login({ redirectUri: window.location.origin + state.url });
return false;
}
return true;
}
export const requireAuthGuard: CanActivateFn = createAuthGuard<CanActivateFn>(isAuthenticated);
@@ -0,0 +1,17 @@
.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);
}
@@ -0,0 +1,57 @@
<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>
@@ -0,0 +1,122 @@
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();
});
});
});
@@ -0,0 +1,86 @@
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);
}
}
}
@@ -0,0 +1,53 @@
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 });
}
}
@@ -0,0 +1,16 @@
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]
}
];
@@ -0,0 +1,52 @@
.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);
}
@@ -0,0 +1,74 @@
<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>
@@ -0,0 +1,148 @@
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);
});
});
});
@@ -0,0 +1,102 @@
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);
}
}
}
@@ -0,0 +1,59 @@
.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);
}
@@ -0,0 +1,92 @@
<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>
@@ -0,0 +1,181 @@
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);
});
});
});
@@ -0,0 +1,133 @@
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,47 @@
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[];
}
export interface ListaVoceInput {
materialeId: string;
quantita: number;
}
export interface AggiornaListaInput {
nome?: string;
voci?: ListaVoceInput[];
}
@Injectable({ providedIn: 'root' })
export class ListeApiService {
private readonly http = inject(HttpClient);
getListe(): Observable<Lista[]> {
return this.http.get<Lista[]>(`${environment.magazzinoApiBaseUrl}/liste`);
}
creaListaVuota(nome: string): Observable<Lista> {
return this.http.post<Lista>(`${environment.magazzinoApiBaseUrl}/liste`, { nome });
}
aggiornaLista(id: string, input: AggiornaListaInput): Observable<Lista> {
return this.http.put<Lista>(`${environment.magazzinoApiBaseUrl}/liste/${id}`, input);
}
}
@@ -0,0 +1,40 @@
.liste-list__form {
display: flex;
align-items: flex-start;
gap: var(--space-4);
margin-bottom: var(--space-2);
}
.liste-list__field {
flex: 1;
}
.liste-list__campo-errore {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-accent-800);
}
.liste-list__error {
color: var(--color-accent-800);
margin-bottom: var(--space-3);
}
.liste-list__elenco {
margin-top: var(--space-5);
}
.liste-list__voce {
text-decoration: none;
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.liste-list__voce:hover {
box-shadow: var(--shadow-sm);
}
.liste-list__voci-count {
color: var(--color-text);
opacity: 0.6;
font-size: 12px;
}
@@ -0,0 +1,43 @@
<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>
}
@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">
@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>
}
</section>
@@ -0,0 +1,110 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { Lista, ListeApiService } from '../liste-api.service';
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;
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: [{ materialeId: 'mat-1', nome: 'Corda', unitaMisura: 'pz', quantita: 2 }]
}
];
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [ListeList],
providers: [provideRouter([]), { provide: ListeApiService, useValue: listeApi }]
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(ListeList);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
it('mostra l\'elenco delle liste dopo il caricamento', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of(liste)), creaListaVuota: vi.fn() };
await setup();
expect(component.loading()).toBe(false);
expect(component.liste()).toEqual(liste);
const compiled = fixture.nativeElement as HTMLElement;
const links = compiled.querySelectorAll('.liste-list__elenco a');
expect(links.length).toBe(2);
expect(links[0].getAttribute('href')).toBe('/liste/lista-1');
});
it('mostra un messaggio se non ci sono liste', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(of([])), creaListaVuota: vi.fn() };
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 fallisce', async () => {
listeApi = { getListe: vi.fn().mockReturnValue(throwError(() => new Error('network error'))), creaListaVuota: vi.fn() };
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();
});
});
});
@@ -0,0 +1,81 @@
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 { firstValueFrom } from 'rxjs';
import { Lista, ListeApiService } from '../liste-api.service';
@Component({
selector: 'app-liste-list',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule
],
templateUrl: './liste-list.html',
styleUrl: './liste-list.css'
})
export class ListeList implements OnInit {
private readonly listeApi = inject(ListeApiService);
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 creazioneInCorso = signal(false);
readonly creazioneErrore = signal<string | null>(null);
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);
} catch {
this.loadError.set('Impossibile caricare le tue liste. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,16 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
export const LISTE_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./liste-list/liste-list').then((m) => m.ListeList),
canActivate: [requireAuthGuard]
},
{
path: ':id',
loadComponent: () => import('./lista-editor/lista-editor').then((m) => m.ListaEditor),
canActivate: [requireAuthGuard]
}
];
@@ -0,0 +1,52 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
export type StatoMagazzinoVoce = 'buono' | 'da_riparare' | 'mancante';
export interface MagazzinoVoce {
id: string;
orgId: string;
materialeId: string;
materialeNome: string;
materialeCategoria: string;
quantitaPosseduta: number;
stato: StatoMagazzinoVoce;
posizione: string | null;
note: string | null;
}
export interface AggiungiMagazzinoVoceInput {
materialeId: string;
quantitaPosseduta: number;
stato: StatoMagazzinoVoce;
posizione?: string;
note?: string;
}
export interface AggiornaMagazzinoVoceInput {
materialeId?: string;
quantitaPosseduta?: number;
stato?: StatoMagazzinoVoce;
posizione?: string | null;
note?: string | null;
}
@Injectable({ providedIn: 'root' })
export class MagazzinoApiService {
private readonly http = inject(HttpClient);
getMagazzino(): Observable<MagazzinoVoce[]> {
return this.http.get<MagazzinoVoce[]>(`${environment.magazzinoApiBaseUrl}/magazzino`);
}
aggiungiVoce(input: AggiungiMagazzinoVoceInput): Observable<MagazzinoVoce> {
return this.http.post<MagazzinoVoce>(`${environment.magazzinoApiBaseUrl}/magazzino`, input);
}
aggiornaVoce(id: string, input: AggiornaMagazzinoVoceInput): Observable<MagazzinoVoce> {
return this.http.put<MagazzinoVoce>(`${environment.magazzinoApiBaseUrl}/magazzino/${id}`, input);
}
}
@@ -0,0 +1,83 @@
.magazzino__header {
margin-bottom: var(--space-2);
}
.magazzino__error {
color: var(--color-accent-800);
}
.magazzino__form {
padding: var(--space-4);
margin-bottom: var(--space-5);
}
.magazzino__materiale {
margin-bottom: var(--space-3);
}
.magazzino__materiale-scelto {
display: flex;
align-items: center;
gap: var(--space-3);
}
.magazzino__ricerca-field {
width: 100%;
}
.magazzino__risultati {
list-style: none;
margin: var(--space-2) 0 0;
padding: 0;
}
.magazzino__risultati li {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) 0;
}
.magazzino__nessun-risultato {
color: var(--color-text);
opacity: 0.7;
font-size: 14px;
}
.magazzino__campo-errore {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-accent-800);
}
.magazzino__campi {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--space-3) var(--space-4);
}
.magazzino__form-azioni {
display: flex;
gap: var(--space-2);
margin-top: var(--space-2);
}
.magazzino__tabella {
width: 100%;
}
.magazzino-badge--buono {
background: var(--color-accent-2-100);
color: var(--color-accent-2-800);
}
.magazzino-badge--da_riparare {
background: var(--color-accent-100);
color: var(--color-accent-800);
}
.magazzino-badge--mancante {
background: var(--color-neutral-200);
color: var(--color-neutral-800);
}
@@ -0,0 +1,138 @@
<section class="magazzino om-page">
<div class="magazzino__header om-toolbar">
<div>
<h1 class="om-section-title">Giacenze di magazzino</h1>
<p class="om-section-sub" style="margin-bottom:0">Quantità, stato e posizione dei materiali della tua organizzazione.</p>
</div>
@if (!formAperto()) {
<button type="button" class="btn btn-primary" (click)="apriNuovaVoce()">Aggiungi voce</button>
}
</div>
@if (loading()) {
<p class="om-empty">Caricamento magazzino…</p>
} @else if (loadError(); as message) {
<p class="magazzino__error" role="alert">{{ message }}</p>
} @else {
@if (formAperto()) {
<form class="magazzino__form card elev-sm" (submit)="$event.preventDefault(); submit()" novalidate>
<h2>{{ voceInModificaId() ? 'Modifica voce' : 'Nuova voce di magazzino' }}</h2>
<div class="magazzino__materiale">
@if (materialeSelezionato(); as materiale) {
<p class="magazzino__materiale-scelto">
Materiale: <strong>{{ materiale.nome }}</strong>
<button type="button" class="btn btn-ghost" (click)="materialeSelezionato.set(null)">Cambia</button>
</p>
} @else {
<div class="field magazzino__ricerca-field">
<label for="mg-ricerca">Cerca materiale nel catalogo</label>
<input class="input" id="mg-ricerca" [formControl]="ricerca" placeholder="Es. corda" />
</div>
@if (risultatiRicerca().length > 0) {
<ul class="magazzino__risultati">
@for (materiale of risultatiRicerca(); track materiale.id) {
<li>
<span>{{ materiale.nome }} ({{ materiale.categoria }})</span>
<button type="button" class="btn btn-secondary" (click)="selezionaMateriale(materiale)">
Seleziona
</button>
</li>
}
</ul>
} @else if (nessunRisultato()) {
<p class="magazzino__nessun-risultato">
Nessun materiale trovato per "{{ ricerca.value }}".
<a [routerLink]="['/proponi-materiale']" [queryParams]="{ nome: ricerca.value }">
Proponi un nuovo materiale
</a>
</p>
}
}
@if (materialeErrore(); as message) {
<p class="magazzino__error" role="alert">{{ message }}</p>
}
</div>
<div class="magazzino__campi" [formGroup]="form">
<div class="field">
<label for="mg-quantita">Quantità posseduta</label>
<input class="input" id="mg-quantita" type="number" min="0" step="1" formControlName="quantitaPosseduta" />
@if (form.controls.quantitaPosseduta.hasError('required')) {
<p class="magazzino__campo-errore">La quantità è obbligatoria.</p>
} @else if (form.controls.quantitaPosseduta.hasError('min')) {
<p class="magazzino__campo-errore">La quantità non può essere negativa.</p>
}
</div>
<div class="field">
<label for="mg-stato">Stato</label>
<select class="input" id="mg-stato" formControlName="stato">
@for (opzione of stati; track opzione.value) {
<option [value]="opzione.value">{{ opzione.label }}</option>
}
</select>
</div>
<div class="field">
<label for="mg-posizione">Posizione</label>
<input class="input" id="mg-posizione" formControlName="posizione" placeholder="Es. Sede, ripostiglio A" />
</div>
<div class="field">
<label for="mg-note">Note</label>
<input class="input" id="mg-note" formControlName="note" />
</div>
</div>
@if (salvataggioErrore(); as message) {
<p class="magazzino__error" role="alert">{{ message }}</p>
}
<div class="magazzino__form-azioni">
<button type="submit" class="btn btn-primary" [disabled]="salvataggioInCorso()">
{{ salvataggioInCorso() ? 'Salvataggio in corso…' : 'Salva voce' }}
</button>
<button type="button" class="btn btn-ghost" (click)="annulla()">Annulla</button>
</div>
</form>
}
@if (voci().length === 0) {
<p class="om-empty">Nessuna voce in magazzino.</p>
} @else {
<table class="table magazzino__tabella">
<thead>
<tr>
<th>Materiale</th>
<th>Quantità</th>
<th>Stato</th>
<th>Posizione</th>
<th>Note</th>
<th></th>
</tr>
</thead>
<tbody>
@for (voce of voci(); track voce.id) {
<tr>
<td>{{ voce.materialeNome }}</td>
<td>{{ voce.quantitaPosseduta }}</td>
<td>
<span [class]="'tag magazzino-badge magazzino-badge--' + voce.stato">
{{ voce.stato === 'buono' ? 'Buono' : voce.stato === 'da_riparare' ? 'Da riparare' : 'Mancante' }}
</span>
</td>
<td>{{ voce.posizione ?? '—' }}</td>
<td>{{ voce.note ?? '—' }}</td>
<td>
<button type="button" class="btn btn-ghost" (click)="apriModificaVoce(voce)">Modifica</button>
</td>
</tr>
}
</tbody>
</table>
}
}
</section>
@@ -0,0 +1,11 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
export const MAGAZZINO_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./magazzino').then((m) => m.Magazzino),
canActivate: [requireAuthGuard]
}
];
@@ -0,0 +1,248 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../catalogo/materiali-api.service';
import { MagazzinoApiService, MagazzinoVoce } from './magazzino-api.service';
import { Magazzino } from './magazzino';
describe('Magazzino', () => {
let component: Magazzino;
let fixture: ComponentFixture<Magazzino>;
let magazzinoApi: {
getMagazzino: ReturnType<typeof vi.fn>;
aggiungiVoce: ReturnType<typeof vi.fn>;
aggiornaVoce: ReturnType<typeof vi.fn>;
};
let materialiApi: { getMateriali: ReturnType<typeof vi.fn> };
const vociIniziali: MagazzinoVoce[] = [
{
id: 'voce-1',
orgId: 'org-1',
materialeId: 'mat-1',
materialeNome: 'Corda',
materialeCategoria: 'Attrezzatura',
quantitaPosseduta: 10,
stato: 'buono',
posizione: 'Sede',
note: null
},
{
id: 'voce-2',
orgId: 'org-1',
materialeId: 'mat-2',
materialeNome: 'Telo cerato',
materialeCategoria: 'Campeggio',
quantitaPosseduta: 2,
stato: 'mancante',
posizione: null,
note: 'Da riordinare'
}
];
const materialiCatalogo: MaterialePubblico[] = [
{ id: 'mat-1', nome: 'Corda', categoria: 'Attrezzatura', unitaMisura: 'pz' },
{ id: 'mat-3', nome: 'Torcia', categoria: 'Attrezzatura', unitaMisura: 'pz' }
];
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [Magazzino],
providers: [
provideRouter([]),
{ provide: MagazzinoApiService, useValue: magazzinoApi },
{ provide: MaterialiApiService, useValue: materialiApi }
]
}).compileComponents();
fixture = TestBed.createComponent(Magazzino);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
magazzinoApi = {
getMagazzino: vi.fn().mockReturnValue(of(vociIniziali)),
aggiungiVoce: vi.fn(),
aggiornaVoce: vi.fn()
};
materialiApi = { getMateriali: vi.fn().mockReturnValue(of(materialiCatalogo)) };
});
it('mostra la tabella con materiale, quantità, stato, posizione e note', async () => {
await setup();
fixture.detectChanges();
expect(component.loading()).toBe(false);
const compiled = fixture.nativeElement as HTMLElement;
const righe = compiled.querySelectorAll('.magazzino__tabella tbody tr');
expect(righe.length).toBe(2);
expect(righe[0].textContent).toContain('Corda');
expect(righe[0].textContent).toContain('10');
expect(righe[0].textContent).toContain('Sede');
expect(righe[1].textContent).toContain('Da riordinare');
});
it('mostra il badge colorato corretto per ogni stato', async () => {
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
const badge0 = compiled.querySelectorAll('.magazzino-badge')[0];
const badge1 = compiled.querySelectorAll('.magazzino-badge')[1];
expect(badge0.classList.contains('magazzino-badge--buono')).toBe(true);
expect(badge0.textContent).toContain('Buono');
expect(badge1.classList.contains('magazzino-badge--mancante')).toBe(true);
expect(badge1.textContent).toContain('Mancante');
});
it('mostra un messaggio se il magazzino è vuoto', async () => {
magazzinoApi.getMagazzino.mockReturnValue(of([]));
await setup();
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Nessuna voce in magazzino.');
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
magazzinoApi.getMagazzino.mockReturnValue(throwError(() => new Error('network error')));
await setup();
expect(component.loadError()).toBe('Impossibile caricare il magazzino. Riprova più tardi.');
});
describe('ricerca materiale nel form', () => {
beforeEach(async () => {
await setup();
component.apriNuovaVoce();
fixture.detectChanges();
});
it('filtra i risultati per nome', () => {
component.ricerca.setValue('torcia');
fixture.detectChanges();
expect(component.risultatiRicerca().map((m) => m.id)).toEqual(['mat-3']);
});
it('mostra il link per proporre un materiale se la ricerca non trova nulla', () => {
component.ricerca.setValue('materiale inesistente');
fixture.detectChanges();
expect(component.nessunRisultato()).toBe(true);
const compiled = fixture.nativeElement as HTMLElement;
const link = compiled.querySelector('.magazzino__nessun-risultato a') as HTMLAnchorElement;
expect(link).toBeTruthy();
expect(link.getAttribute('href')).toBe('/proponi-materiale?nome=materiale%20inesistente');
});
it('seleziona un materiale dal catalogo e lo mostra come scelto', () => {
component.selezionaMateriale(materialiCatalogo[1]);
fixture.detectChanges();
expect(component.materialeSelezionato()).toEqual({ id: 'mat-3', nome: 'Torcia' });
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.magazzino__materiale-scelto')?.textContent).toContain('Torcia');
});
});
describe('aggiunta di una nuova voce', () => {
beforeEach(async () => {
await setup();
component.apriNuovaVoce();
fixture.detectChanges();
});
it('richiede la selezione di un materiale prima di salvare', async () => {
await component.submit();
expect(component.materialeErrore()).toBe('Seleziona un materiale dal catalogo.');
expect(magazzinoApi.aggiungiVoce).not.toHaveBeenCalled();
});
it('chiama aggiungiVoce con i dati del form e chiude il form al successo', async () => {
component.selezionaMateriale(materialiCatalogo[1]);
component.form.setValue({ quantitaPosseduta: 5, stato: 'da_riparare', posizione: 'Garage', note: '' });
const nuovaVoce: MagazzinoVoce = {
id: 'voce-3',
orgId: 'org-1',
materialeId: 'mat-3',
materialeNome: 'Torcia',
materialeCategoria: 'Attrezzatura',
quantitaPosseduta: 5,
stato: 'da_riparare',
posizione: 'Garage',
note: null
};
magazzinoApi.aggiungiVoce.mockReturnValue(of(nuovaVoce));
await component.submit();
expect(magazzinoApi.aggiungiVoce).toHaveBeenCalledWith({
materialeId: 'mat-3',
quantitaPosseduta: 5,
stato: 'da_riparare',
posizione: 'Garage',
note: undefined
});
expect(component.voci()[0]).toEqual(nuovaVoce);
expect(component.formAperto()).toBe(false);
});
it('mostra un messaggio di errore se il salvataggio fallisce', async () => {
component.selezionaMateriale(materialiCatalogo[1]);
magazzinoApi.aggiungiVoce.mockReturnValue(throwError(() => new Error('server error')));
await component.submit();
expect(component.salvataggioErrore()).toBe('Impossibile salvare la voce di magazzino. Riprova più tardi.');
expect(component.formAperto()).toBe(true);
});
});
describe('modifica di una voce esistente', () => {
beforeEach(async () => {
await setup();
});
it('precompila il form con i dati della voce selezionata', () => {
component.apriModificaVoce(vociIniziali[1]);
fixture.detectChanges();
expect(component.materialeSelezionato()).toEqual({ id: 'mat-2', nome: 'Telo cerato' });
expect(component.form.value.quantitaPosseduta).toBe(2);
expect(component.form.value.stato).toBe('mancante');
expect(component.form.value.note).toBe('Da riordinare');
});
it('chiama aggiornaVoce con l\'id corretto e azzera i campi vuoti a null', async () => {
component.apriModificaVoce(vociIniziali[1]);
component.form.patchValue({ quantitaPosseduta: 4, stato: 'buono', posizione: '' });
const voceAggiornata: MagazzinoVoce = { ...vociIniziali[1], quantitaPosseduta: 4, stato: 'buono', posizione: null };
magazzinoApi.aggiornaVoce.mockReturnValue(of(voceAggiornata));
await component.submit();
expect(magazzinoApi.aggiornaVoce).toHaveBeenCalledWith('voce-2', {
materialeId: 'mat-2',
quantitaPosseduta: 4,
stato: 'buono',
posizione: null,
note: 'Da riordinare'
});
expect(component.voci().find((v) => v.id === 'voce-2')).toEqual(voceAggiornata);
});
});
});
@@ -0,0 +1,208 @@
import { Component, OnInit, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { MaterialePubblico, MaterialiApiService } from '../catalogo/materiali-api.service';
import {
AggiornaMagazzinoVoceInput,
AggiungiMagazzinoVoceInput,
MagazzinoApiService,
MagazzinoVoce,
StatoMagazzinoVoce
} from './magazzino-api.service';
interface MaterialeRiferimento {
id: string;
nome: string;
}
interface OpzioneStato {
value: StatoMagazzinoVoce;
label: string;
}
const STATI: OpzioneStato[] = [
{ value: 'buono', label: 'Buono' },
{ value: 'da_riparare', label: 'Da riparare' },
{ value: 'mancante', label: 'Mancante' }
];
@Component({
selector: 'app-magazzino',
imports: [
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatTableModule
],
templateUrl: './magazzino.html',
styleUrl: './magazzino.css'
})
export class Magazzino implements OnInit {
private readonly magazzinoApi = inject(MagazzinoApiService);
private readonly materialiApi = inject(MaterialiApiService);
readonly displayedColumns = ['materiale', 'quantita', 'stato', 'posizione', 'note', 'azioni'];
readonly stati = STATI;
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly voci = signal<MagazzinoVoce[]>([]);
readonly materialiCatalogo = signal<MaterialePubblico[]>([]);
readonly formAperto = signal(false);
readonly voceInModificaId = signal<string | null>(null);
readonly materialeSelezionato = signal<MaterialeRiferimento | null>(null);
readonly materialeErrore = signal<string | null>(null);
readonly salvataggioInCorso = signal(false);
readonly salvataggioErrore = signal<string | null>(null);
readonly ricerca = new FormControl('', { nonNullable: true });
private readonly ricercaValue = toSignal(this.ricerca.valueChanges, { initialValue: '' });
readonly form = new FormGroup({
quantitaPosseduta: new FormControl(0, {
nonNullable: true,
validators: [Validators.required, Validators.min(0)]
}),
stato: new FormControl<StatoMagazzinoVoce>('buono', { nonNullable: true, validators: [Validators.required] }),
posizione: new FormControl('', { nonNullable: true }),
note: new FormControl('', { nonNullable: true })
});
readonly risultatiRicerca = computed<MaterialePubblico[]>(() => {
const termine = this.ricercaValue().trim().toLowerCase();
if (termine.length === 0) {
return [];
}
return this.materialiCatalogo().filter((materiale) => materiale.nome.toLowerCase().includes(termine));
});
readonly nessunRisultato = computed(
() => this.ricercaValue().trim().length > 0 && this.risultatiRicerca().length === 0
);
async ngOnInit(): Promise<void> {
await this.carica();
}
apriNuovaVoce(): void {
this.voceInModificaId.set(null);
this.materialeSelezionato.set(null);
this.materialeErrore.set(null);
this.salvataggioErrore.set(null);
this.ricerca.setValue('');
this.form.reset({ quantitaPosseduta: 0, stato: 'buono', posizione: '', note: '' });
this.formAperto.set(true);
}
apriModificaVoce(voce: MagazzinoVoce): void {
this.voceInModificaId.set(voce.id);
this.materialeSelezionato.set({ id: voce.materialeId, nome: voce.materialeNome });
this.materialeErrore.set(null);
this.salvataggioErrore.set(null);
this.ricerca.setValue('');
this.form.reset({
quantitaPosseduta: voce.quantitaPosseduta,
stato: voce.stato,
posizione: voce.posizione ?? '',
note: voce.note ?? ''
});
this.formAperto.set(true);
}
annulla(): void {
this.formAperto.set(false);
}
selezionaMateriale(materiale: MaterialePubblico): void {
this.materialeSelezionato.set({ id: materiale.id, nome: materiale.nome });
this.materialeErrore.set(null);
this.ricerca.setValue('');
}
async submit(): Promise<void> {
if (this.salvataggioInCorso()) {
return;
}
const materiale = this.materialeSelezionato();
if (!materiale) {
this.materialeErrore.set('Seleziona un materiale dal catalogo.');
return;
}
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.salvataggioErrore.set(null);
this.salvataggioInCorso.set(true);
const valori = this.form.getRawValue();
const posizione = valori.posizione.trim();
const note = valori.note.trim();
const voceId = this.voceInModificaId();
try {
const voce = voceId
? await firstValueFrom(
this.magazzinoApi.aggiornaVoce(voceId, {
materialeId: materiale.id,
quantitaPosseduta: valori.quantitaPosseduta,
stato: valori.stato,
posizione: posizione.length > 0 ? posizione : null,
note: note.length > 0 ? note : null
} satisfies AggiornaMagazzinoVoceInput)
)
: await firstValueFrom(
this.magazzinoApi.aggiungiVoce({
materialeId: materiale.id,
quantitaPosseduta: valori.quantitaPosseduta,
stato: valori.stato,
posizione: posizione.length > 0 ? posizione : undefined,
note: note.length > 0 ? note : undefined
} satisfies AggiungiMagazzinoVoceInput)
);
this.voci.update((voci) => {
const esisteGia = voci.some((v) => v.id === voce.id);
return esisteGia ? voci.map((v) => (v.id === voce.id ? voce : v)) : [voce, ...voci];
});
this.formAperto.set(false);
} catch {
this.salvataggioErrore.set('Impossibile salvare la voce di magazzino. Riprova più tardi.');
} finally {
this.salvataggioInCorso.set(false);
}
}
private async carica(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const voci = await firstValueFrom(this.magazzinoApi.getMagazzino());
this.voci.set(voci);
const materiali = await firstValueFrom(this.materialiApi.getMateriali());
this.materialiCatalogo.set(materiali);
} catch {
this.loadError.set('Impossibile caricare il magazzino. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,23 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { MaterialeProposta } from '../catalogo/materiali-api.service';
export type DecisioneProposta = 'approvato' | 'rifiutato';
@Injectable({ providedIn: 'root' })
export class MaterialiModerazioneApiService {
private readonly http = inject(HttpClient);
getProposte(): Observable<MaterialeProposta[]> {
return this.http.get<MaterialeProposta[]>(`${environment.magazzinoApiBaseUrl}/materiali/proposte`);
}
decidiProposta(id: string, decisione: DecisioneProposta): Observable<MaterialeProposta> {
return this.http.patch<MaterialeProposta>(`${environment.magazzinoApiBaseUrl}/materiali/proposte/${id}`, {
decisione
});
}
}
@@ -0,0 +1,17 @@
.moderazione__elenco {
max-width: 640px;
}
.moderazione__proposta {
max-width: none;
}
.moderazione__data {
opacity: 0.6;
}
.moderazione__error {
color: var(--color-accent-800);
font-size: 12px;
margin: var(--space-2) 0 0;
}
@@ -0,0 +1,44 @@
<section class="moderazione om-page">
<h1 class="om-section-title">Moderazione catalogo</h1>
<p class="om-section-sub">Materiali proposti dalle organizzazioni, in attesa di approvazione.</p>
@if (loading()) {
<p class="om-empty">Caricamento proposte…</p>
} @else if (loadError(); as message) {
<p class="moderazione__error" role="alert">{{ message }}</p>
} @else if (proposte().length === 0) {
<p class="om-empty">Nessuna proposta in attesa di approvazione.</p>
} @else {
<div class="om-stack moderazione__elenco">
@for (proposta of proposte(); track proposta.id) {
<div class="card elev-sm moderazione__proposta">
<div class="card-kicker">{{ proposta.categoria }} · {{ proposta.unitaMisura }}</div>
<div class="card-title">{{ proposta.nome }}</div>
<p class="card-body moderazione__data">Proposto il {{ proposta.creatoIl | slice: 0 : 10 }}</p>
<div class="om-row">
<button
type="button"
class="btn btn-primary"
[disabled]="decisioneInCorsoId() === proposta.id"
(click)="decidi(proposta, 'approvato')"
>
Approva
</button>
<button
type="button"
class="btn btn-ghost"
[disabled]="decisioneInCorsoId() === proposta.id"
(click)="decidi(proposta, 'rifiutato')"
>
Rifiuta
</button>
</div>
@if (decisioneErroreId() === proposta.id) {
<p class="moderazione__error" role="alert">Impossibile registrare la decisione. Riprova più tardi.</p>
}
</div>
}
</div>
}
</section>
@@ -0,0 +1,12 @@
import { Routes } from '@angular/router';
import { requireAuthGuard } from '../core/require-auth.guard';
import { requireModeratoreGuard } from './require-moderatore.guard';
export const MODERAZIONE_ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./moderazione').then((m) => m.Moderazione),
canActivate: [requireAuthGuard, requireModeratoreGuard]
}
];
@@ -0,0 +1,120 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { MaterialeProposta } from '../catalogo/materiali-api.service';
import { MaterialiModerazioneApiService } from './materiali-moderazione-api.service';
import { Moderazione } from './moderazione';
describe('Moderazione', () => {
let component: Moderazione;
let fixture: ComponentFixture<Moderazione>;
let moderazioneApi: { getProposte: ReturnType<typeof vi.fn>; decidiProposta: ReturnType<typeof vi.fn> };
const proposte: MaterialeProposta[] = [
{
id: 'prop-1',
nome: 'Fune da bucato',
categoria: 'Campeggio',
unitaMisura: 'pz',
stato: 'proposto',
propostoDaOrgId: 'org-1',
creatoIl: '2026-01-01T00:00:00.000Z'
},
{
id: 'prop-2',
nome: 'Piccone',
categoria: 'Attrezzatura',
unitaMisura: 'pz',
stato: 'proposto',
propostoDaOrgId: 'org-2',
creatoIl: '2026-01-02T00:00:00.000Z'
}
];
async function setup(): Promise<void> {
await TestBed.configureTestingModule({
imports: [Moderazione],
providers: [provideRouter([]), { provide: MaterialiModerazioneApiService, useValue: moderazioneApi }]
}).compileComponents();
fixture = TestBed.createComponent(Moderazione);
component = fixture.componentInstance;
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
beforeEach(() => {
moderazioneApi = { getProposte: vi.fn().mockReturnValue(of(proposte)), decidiProposta: vi.fn() };
});
it('mostra la lista delle proposte in attesa', async () => {
await setup();
fixture.detectChanges();
expect(component.loading()).toBe(false);
expect(component.proposte()).toEqual(proposte);
const compiled = fixture.nativeElement as HTMLElement;
const righe = compiled.querySelectorAll('.moderazione__tabella tbody tr');
expect(righe.length).toBe(2);
expect(righe[0].textContent).toContain('Fune da bucato');
});
it('mostra un messaggio se non ci sono proposte in attesa', async () => {
moderazioneApi.getProposte.mockReturnValue(of([]));
await setup();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Nessuna proposta in attesa');
});
it('mostra un messaggio di errore se il caricamento fallisce', async () => {
moderazioneApi.getProposte.mockReturnValue(throwError(() => new Error('network error')));
await setup();
expect(component.loadError()).toBe('Impossibile caricare le proposte in attesa. Riprova più tardi.');
});
describe('approvazione e rifiuto', () => {
beforeEach(async () => {
await setup();
});
it('approva una proposta e la rimuove dalla lista', async () => {
const propostaApprovata: MaterialeProposta = { ...proposte[0], stato: 'approvato' };
moderazioneApi.decidiProposta.mockReturnValue(of(propostaApprovata));
await component.decidi(proposte[0], 'approvato');
expect(moderazioneApi.decidiProposta).toHaveBeenCalledWith('prop-1', 'approvato');
expect(component.proposte().map((p) => p.id)).toEqual(['prop-2']);
});
it('rifiuta una proposta e la rimuove dalla lista', async () => {
const propostaRifiutata: MaterialeProposta = { ...proposte[1], stato: 'rifiutato' };
moderazioneApi.decidiProposta.mockReturnValue(of(propostaRifiutata));
await component.decidi(proposte[1], 'rifiutato');
expect(moderazioneApi.decidiProposta).toHaveBeenCalledWith('prop-2', 'rifiutato');
expect(component.proposte().map((p) => p.id)).toEqual(['prop-1']);
});
it('mostra un messaggio di errore sulla proposta interessata se la decisione fallisce', async () => {
moderazioneApi.decidiProposta.mockReturnValue(throwError(() => new Error('server error')));
await component.decidi(proposte[0], 'approvato');
expect(component.decisioneErroreId()).toBe('prop-1');
expect(component.proposte().map((p) => p.id)).toEqual(['prop-1', 'prop-2']);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('Impossibile registrare la decisione');
});
});
});
@@ -0,0 +1,63 @@
import { SlicePipe } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatTableModule } from '@angular/material/table';
import { firstValueFrom } from 'rxjs';
import { MaterialeProposta } from '../catalogo/materiali-api.service';
import { DecisioneProposta, MaterialiModerazioneApiService } from './materiali-moderazione-api.service';
@Component({
selector: 'app-moderazione',
imports: [SlicePipe, MatButtonModule, MatTableModule],
templateUrl: './moderazione.html',
styleUrl: './moderazione.css'
})
export class Moderazione implements OnInit {
private readonly moderazioneApi = inject(MaterialiModerazioneApiService);
readonly displayedColumns = ['nome', 'categoria', 'unitaMisura', 'creatoIl', 'azioni'];
readonly loading = signal(true);
readonly loadError = signal<string | null>(null);
readonly proposte = signal<MaterialeProposta[]>([]);
readonly decisioneInCorsoId = signal<string | null>(null);
readonly decisioneErroreId = signal<string | null>(null);
async ngOnInit(): Promise<void> {
await this.caricaProposte();
}
async decidi(proposta: MaterialeProposta, decisione: DecisioneProposta): Promise<void> {
if (this.decisioneInCorsoId()) {
return;
}
this.decisioneErroreId.set(null);
this.decisioneInCorsoId.set(proposta.id);
try {
await firstValueFrom(this.moderazioneApi.decidiProposta(proposta.id, decisione));
this.proposte.update((proposte) => proposte.filter((p) => p.id !== proposta.id));
} catch {
this.decisioneErroreId.set(proposta.id);
} finally {
this.decisioneInCorsoId.set(null);
}
}
private async caricaProposte(): Promise<void> {
this.loading.set(true);
this.loadError.set(null);
try {
const proposte = await firstValueFrom(this.moderazioneApi.getProposte());
this.proposte.set(proposte);
} catch {
this.loadError.set('Impossibile caricare le proposte in attesa. Riprova più tardi.');
} finally {
this.loading.set(false);
}
}
}
@@ -0,0 +1,44 @@
import { TestBed } from '@angular/core/testing';
import { ActivatedRouteSnapshot, provideRouter, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
import Keycloak from 'keycloak-js';
import { requireModeratoreGuard } from './require-moderatore.guard';
const route = {} as ActivatedRouteSnapshot;
const state = {} as RouterStateSnapshot;
function setup(roles: string[]): void {
const keycloakMock = { tokenParsed: { realm_access: { roles } } };
TestBed.configureTestingModule({
providers: [provideRouter([]), { provide: Keycloak, useValue: keycloakMock }]
});
}
describe('requireModeratoreGuard', () => {
it('lascia proseguire la navigazione se l\'utente ha il ruolo moderatore', () => {
setup(['moderatore']);
const result = TestBed.runInInjectionContext(() => requireModeratoreGuard(route, state));
expect(result).toBe(true);
});
it('reindirizza alla home per un utente senza il ruolo moderatore', () => {
setup(['censito']);
const result = TestBed.runInInjectionContext(() => requireModeratoreGuard(route, state));
const router = TestBed.inject(Router);
expect((result as UrlTree).toString()).toBe(router.parseUrl('/').toString());
});
it('reindirizza alla home se il token non ha ruoli', () => {
setup([]);
const result = TestBed.runInInjectionContext(() => requireModeratoreGuard(route, state));
const router = TestBed.inject(Router);
expect((result as UrlTree).toString()).toBe(router.parseUrl('/').toString());
});
});
@@ -0,0 +1,15 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import Keycloak from 'keycloak-js';
import { MODERATORE_ROLE, extractRealmRoles } from '../core/auth/roles';
// La moderazione (es. catalogo materiali) è riservata al ruolo realm moderatore.
// Da usare insieme a requireAuthGuard: qui assumiamo che l'utente sia già autenticato.
export const requireModeratoreGuard: CanActivateFn = () => {
const router = inject(Router);
const keycloak = inject(Keycloak);
const roles = extractRealmRoles(keycloak.tokenParsed);
return roles.includes(MODERATORE_ROLE) ? true : router.parseUrl('/');
};
@@ -0,0 +1,7 @@
export const environment = {
production: false,
keycloakBaseUrl: 'http://localhost:6999',
keycloakRealm: 'scouthub',
keycloakClientId: 'scouthub-frontend',
magazzinoApiBaseUrl: 'http://localhost:8002'
};
@@ -0,0 +1,7 @@
export const environment = {
production: true,
keycloakBaseUrl: '__KEYCLOAK_BASE_URL__',
keycloakRealm: 'scouthub',
keycloakClientId: 'scouthub-frontend',
magazzinoApiBaseUrl: '__MAGAZZINO_API_BASE_URL__'
};
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>ScouthubMagazzinoFe</title>
<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>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
@@ -0,0 +1,38 @@
// 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%;
}
+269
View File
@@ -0,0 +1,269 @@
/* Organic — design-system tokens and component classes (importato da Claude Design). */
: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);
/* 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;
--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-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;
--font-heading: "Caprasimo", system-ui, sans-serif;
--font-heading-weight: 400;
--font-body: "Figtree", system-ui, sans-serif;
--space-1: 4.4px;
--space-2: 8.8px;
--space-3: 13.2px;
--space-4: 17.6px;
--space-6: 26.4px;
--space-8: 35.2px;
--radius-sm: 8px;
--radius-md: 16px;
--radius-lg: 28px;
--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);
}
html, body {
height: 100%;
color-scheme: light;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-body);
}
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }
.washed { filter: saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94); }
/* ══════════════════════════════════════════════════════════════════════════
Componenti — costruiti sui token sopra. CSS puro, nessuna dipendenza.
══════════════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; }
body { font-size: 15px; line-height: 1.55; font-weight: 400; }
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
}
h1 { font-size: 42px; }
h2 { font-size: 32px; }
h3 { font-size: 25px; }
h4 { font-size: 20px; }
h5 { font-size: 16px; }
h6 { font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase; }
p { margin: 0 0 var(--space-3); }
a { color: var(--color-accent); text-underline-offset: 3px; }
img { display: block; max-width: 100%; }
figure { margin: 0; }
figcaption {
font-size: 11px; margin-top: var(--space-1);
color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
:focus { outline: none; }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }
/* — rules — */
.hr {
height: 1px; border: 0; margin: var(--space-4) 0;
background: var(--color-divider);
}
/* — buttons — */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
cursor: pointer; text-decoration: none;
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 14px; line-height: 1.2; color: var(--color-text);
background: transparent; border: 1px solid transparent;
padding: var(--space-2) calc(var(--space-3) * 1.2);
border-radius: var(--radius-md);
}
.btn svg { display: block; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--color-accent); color: var(--color-bg); }
.btn-primary:hover { background: var(--color-accent-600); }
.btn-primary:active { background: var(--color-accent-700); }
.btn-secondary { border-color: var(--color-divider); }
.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
.btn-ghost { color: var(--color-accent); padding-inline: var(--space-1); }
.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
.btn-icon { width: 36px; height: 36px; padding: 0; }
.btn-block { width: 100%; margin-top: var(--space-2); }
/* — forms — */
.field > label {
display: block; font-size: 12px; margin-bottom: 5px;
color: color-mix(in srgb, var(--color-text) 70%, transparent);
}
.input {
width: 100%; min-height: 36px; padding: 6px 10px; font: inherit;
font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
background: var(--color-surface);
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
textarea.input { min-height: 90px; resize: vertical; }
.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
.radio input, .seg-opt input {
position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
}
.radio .dot {
width: 16px; height: 16px; flex: none; border-radius: 50%;
border: 1.5px solid var(--color-divider);
}
.radio:hover .dot { border-color: var(--color-accent); }
.radio input:checked + .dot {
border-color: var(--color-accent); background: var(--color-accent);
box-shadow: inset 0 0 0 4px var(--color-bg);
}
.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.seg {
display: inline-flex; overflow: hidden;
border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.seg-opt {
display: inline-flex; align-items: center; gap: 6px;
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: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);
}
.card-kicker { font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--color-accent); }
.card-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 17px; line-height: 1.2;
}
.card-body { margin: 0; font-size: 13px; opacity: 0.8; flex: 1; }
.card-meta {
display: flex; align-items: center; gap: 6px; font-size: 11px;
color: color-mix(in srgb, var(--color-text) 50%, transparent);
}
.elev-sm { box-shadow: var(--shadow-sm); }
.elev-md { box-shadow: var(--shadow-md); }
.elev-lg { box-shadow: var(--shadow-lg); }
/* — tags — */
.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);
}
.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); }
.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent); }
/* — navigation — */
.nav {
display: flex; align-items: center; gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border-bottom: none;
}
.nav-brand {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 18px; margin-right: auto;
}
.nav a { color: inherit; text-decoration: none; font-size: 14px; }
.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent); }
/* — tables — */
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
.table th {
text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
color: color-mix(in srgb, var(--color-text) 60%, transparent);
padding: var(--space-2); border-bottom: 1px solid var(--color-divider);
}
.table td {
padding: var(--space-2);
border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
}
.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
/* — dialog — */
.dialog-backdrop {
position: fixed; inset: 0; display: grid; place-items: center;
padding: var(--space-4);
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
}
.dialog {
width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-3);
padding: var(--space-4); border-radius: var(--radius-lg);
background: var(--color-surface); box-shadow: var(--shadow-lg);
}
.dialog-title {
font-family: var(--font-heading); font-weight: var(--font-heading-weight);
font-size: 20px;
}
.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) — */
.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; }
.om-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--space-4); }
.om-row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.om-stack { display: grid; gap: var(--space-4); }
.om-toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; margin-bottom: var(--space-5); }
.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); }
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
+31
View File
@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}