Add scouthub-magazzino-fe
This commit is contained in:
@@ -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
|
||||||
@@ -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/
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.html",
|
||||||
|
"options": {
|
||||||
|
"parser": "angular"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+8287
File diff suppressed because it is too large
Load Diff
@@ -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]))
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
+33
@@ -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;
|
||||||
|
}
|
||||||
+53
@@ -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>
|
||||||
+167
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+89
@@ -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__'
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user