Add scouthub-eventi-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 EVENTI_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#__EVENTI_API_BASE_URL__#${EVENTI_API_BASE_URL}#" \
|
||||||
|
src/environments/environment.ts
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY --from=build /app/dist/scouthub-eventi-fe/browser /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# scouthub-eventi-fe
|
||||||
|
|
||||||
|
Frontend Angular per la gestione degli **eventi** Scouthub (calendario per branca, con
|
||||||
|
sotto-eventi a due livelli), affianca `scouthub-attivita-fe`, `scouthub-home-fe` e
|
||||||
|
`scouthub-magazzino-fe` nello stesso ecosistema. Parla con `scouthub-eventi-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
|
||||||
|
|
||||||
|
Le viste calendario (annuale, mensile) e il form di creazione/modifica evento sono implementati e
|
||||||
|
funzionanti. L'intera app è privata (richiede login): non esiste ancora una vista pubblica del
|
||||||
|
calendario.
|
||||||
|
|
||||||
|
## Struttura cartelle
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/
|
||||||
|
core/
|
||||||
|
auth/
|
||||||
|
keycloak.provider.ts provideKeycloakAngular() — login OIDC Authorization Code + PKCE
|
||||||
|
auth-context.ts extractAuthContext() — ruoli + branche ("groups") dal token
|
||||||
|
branca-access.ts hasBrancaAccess()/hasAnyBrancaAccess(), rispecchia assertBrancaAccess del BE
|
||||||
|
require-branca-access.guard.ts guard di routing per creazione/modifica evento
|
||||||
|
require-auth.guard.ts requireAuthGuard — richiede login, altrimenti avvia keycloak.login()
|
||||||
|
eventi-api.service.ts client HTTP verso GET/POST/PUT /eventi (dettaglio, crea, aggiorna)
|
||||||
|
utils/
|
||||||
|
colore-tipo.ts colore deterministico per "tipo" evento (hash su palette fissa)
|
||||||
|
nomi-mesi.ts nomi mesi in italiano per le viste calendario
|
||||||
|
vista-annuale/ feature "calendario annuale" (/vista-annuale, lazy-loaded)
|
||||||
|
vista-annuale-api.service.ts client verso GET /eventi?vista=anno
|
||||||
|
vista-mensile/ feature "calendario mensile" (/vista-mensile, lazy-loaded, home di default)
|
||||||
|
vista-mensile-api.service.ts client verso GET /eventi?vista=mese, filtro branca lato client
|
||||||
|
vista-evento/ feature "dettaglio/form evento" (/vista-evento, lazy-loaded)
|
||||||
|
evento-form/ form condiviso per crea/modifica/sotto-evento
|
||||||
|
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-magazzino-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`): applicato sulla route radice in
|
||||||
|
`app.routes.ts`, protegge quindi tutte le sotto-route in un colpo solo (l'app non ha aree
|
||||||
|
pubbliche).
|
||||||
|
- **extractAuthContext** (`core/auth/auth-context.ts`): rispecchia lato client la stessa logica
|
||||||
|
di `verify-token.middleware.ts` sul backend — i ruoli sono `realm_access.roles` più i ruoli
|
||||||
|
sull'organizzazione attiva (claim `organization`), le branche vengono dal claim standard
|
||||||
|
`groups` (path tipo `/Lupetti`, normalizzati togliendo lo slash iniziale). Non esiste ancora un
|
||||||
|
claim branca dedicato.
|
||||||
|
- **hasBrancaAccess / hasAnyBrancaAccess** (`core/auth/branca-access.ts`): `capo-gruppo` ha
|
||||||
|
accesso in scrittura a qualunque branca dell'organizzazione; `capo-unita` solo alle branche a
|
||||||
|
cui appartiene. Usate solo per nascondere via routing gli entry point non pertinenti: **il
|
||||||
|
controllo autoritativo resta lato backend** (`assertBrancaAccess` su `POST`/`PUT /eventi`).
|
||||||
|
- Le chiamate verso `eventiApiBaseUrl` 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 `vista-mensile` (`/vista-mensile`, home di default)
|
||||||
|
|
||||||
|
Calendario del mese corrente/selezionato: carica **tutti** gli eventi del mese (radice e figli,
|
||||||
|
trasversali a tutte le branche) tramite `GET /eventi?vista=mese`, e filtra lato client per branca
|
||||||
|
tramite un select — così il filtro resta popolato con tutte le branche presenti nel mese anche
|
||||||
|
dopo aver selezionato una branca specifica.
|
||||||
|
|
||||||
|
### Feature `vista-annuale` (`/vista-annuale`)
|
||||||
|
|
||||||
|
Vista d'insieme dell'anno: per ogni giorno con almeno un evento radice, mostra un conteggio e
|
||||||
|
l'elenco sintetico (titolo/tipo/branca) tramite `GET /eventi?vista=anno`. Nessun dettaglio
|
||||||
|
descrizione/risorse in questa vista (aggregato leggero, coerente con quanto espone il backend).
|
||||||
|
|
||||||
|
### Feature `vista-evento` (`/vista-evento`)
|
||||||
|
|
||||||
|
- `/vista-evento/:id` — dettaglio completo di un evento, con i suoi eventuali figli (2° livello)
|
||||||
|
e le risorse collegate create da altri backend (`RisorsaCollegata`).
|
||||||
|
- `/vista-evento/nuovo` — crea un evento radice su una branca a scelta tra quelle dell'utente
|
||||||
|
(`requireCreaEventoGuard`, visibile solo a chi ha accesso ad almeno una branca).
|
||||||
|
- `/vista-evento/:id/modifica` — modifica un evento esistente (`requireBrancaAccessEventoGuard`,
|
||||||
|
carica prima l'evento per conoscerne la branca e poter decidere l'accesso).
|
||||||
|
- `/vista-evento/:id/sotto-evento` — crea un sotto-evento del padre `:id`; il form eredita
|
||||||
|
branca/vincoli di data dal genitore e blocca il campo branca. Non disponibile se `:id` è già
|
||||||
|
esso stesso un sotto-evento (massimo due livelli di annidamento, imposto anche lato backend).
|
||||||
|
|
||||||
|
Il form (`evento-form/`) valida che la data/ora di fine non preceda l'inizio e, in modalità
|
||||||
|
sotto-evento/modifica-di-un-figlio, vincola l'intervallo selezionabile a quello del genitore;
|
||||||
|
resta comunque il backend l'autorità finale su questi vincoli.
|
||||||
|
|
||||||
|
## Prerequisiti
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- I servizi dell'ecosistema Scouthub in esecuzione: Keycloak e `scouthub-eventi-be`
|
||||||
|
(vedi `docker-compose.yml` e `scouthub-eventi-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`)
|
||||||
|
- `eventiApiBaseUrl` — URL base di `scouthub-eventi-be` (default `http://localhost:8084`)
|
||||||
|
|
||||||
|
## 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:4204` (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 subito un redirect di login.
|
||||||
|
|
||||||
|
## Collegare scouthub-eventi-be in locale
|
||||||
|
|
||||||
|
1. Avviare `scouthub-eventi-be` seguendo il suo README (`npm run dev`, porta di default `8084`).
|
||||||
|
2. Le richieste verso `eventiApiBaseUrl` 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 `4204` (per non collidere con `scouthub-attivita-fe` sulla `4200`,
|
||||||
|
`scouthub-home-fe` sulla `4201` e `scouthub-magazzino-fe` sulla `4203`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Apri il browser su `http://localhost:4204/`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
Artefatti di build in `dist/scouthub-eventi-fe/browser` (percorso usato anche dal `Dockerfile`
|
||||||
|
multistage node→nginx).
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
Esegue gli unit test con il builder `@angular/build:unit-test` (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-eventi-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": 4204
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "scouthub-eventi-fe:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "scouthub-eventi-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-eventi-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 escapedEventiApiBaseUrl = environment.eventiApiBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
const eventiApiBearerCondition = createInterceptorCondition<IncludeBearerTokenCondition>({
|
||||||
|
urlPattern: new RegExp(`^${escapedEventiApiBaseUrl}(/.*)?$`, 'i'),
|
||||||
|
bearerPrefix: 'Bearer'
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideKeycloakAngular(),
|
||||||
|
{
|
||||||
|
provide: INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
|
||||||
|
useValue: [eventiApiBearerCondition]
|
||||||
|
},
|
||||||
|
provideHttpClient(withInterceptors([includeBearerTokenInterceptor]))
|
||||||
|
]
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet />
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
import { requireAuthGuard } from './core/require-auth.guard';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
// L'intera app è privata: la guard sulla root protegge tutte le sotto-route.
|
||||||
|
path: '',
|
||||||
|
canActivate: [requireAuthGuard],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: 'vista-mensile'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vista-annuale',
|
||||||
|
loadChildren: () => import('./vista-annuale/vista-annuale.routes').then((m) => m.VISTA_ANNUALE_ROUTES)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vista-mensile',
|
||||||
|
loadChildren: () => import('./vista-mensile/vista-mensile.routes').then((m) => m.VISTA_MENSILE_ROUTES)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vista-evento',
|
||||||
|
loadChildren: () => import('./vista-evento/vista-evento.routes').then((m) => m.VISTA_EVENTO_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 {}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { KeycloakTokenParsed } from 'keycloak-js';
|
||||||
|
|
||||||
|
export interface AuthContextInfo {
|
||||||
|
roles: string[];
|
||||||
|
branche: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrganizationClaimEntry {
|
||||||
|
id?: string;
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGroupPath(path: string): string {
|
||||||
|
return path.replace(/^\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rispecchia lato client la stessa logica di scouthub-eventi-be
|
||||||
|
* (verify-token.middleware.ts, buildAuthContext): i ruoli sono
|
||||||
|
* realm_access.roles + i ruoli sull'organizzazione attiva del token; le
|
||||||
|
* branche vengono dal claim standard "groups" (path del tipo "/Lupetti"),
|
||||||
|
* non ancora esposto da un claim dedicato.
|
||||||
|
*/
|
||||||
|
export function extractAuthContext(tokenParsed: KeycloakTokenParsed | undefined): AuthContextInfo {
|
||||||
|
const realmRoles = (tokenParsed?.['realm_access'] as { roles?: string[] } | undefined)?.roles ?? [];
|
||||||
|
const organizationClaim = tokenParsed?.['organization'] as Record<string, OrganizationClaimEntry> | undefined;
|
||||||
|
const [organization] = organizationClaim ? Object.values(organizationClaim) : [];
|
||||||
|
const orgRoles = organization?.roles ?? [];
|
||||||
|
const groups = (tokenParsed?.['groups'] as string[] | undefined) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles: Array.from(new Set([...realmRoles, ...orgRoles])),
|
||||||
|
branche: groups.map(normalizeGroupPath)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { AuthContextInfo } from './auth-context';
|
||||||
|
|
||||||
|
// Rispecchia assertBrancaAccess lato backend (scouthub-eventi-be): capo-gruppo
|
||||||
|
// ha accesso in scrittura a qualunque branca dell'org, capo-unita/capo solo
|
||||||
|
// alle branche a cui appartiene (claim "groups" del token). capo ha per ora
|
||||||
|
// gli stessi permessi di capo-unita, da differenziare in futuro.
|
||||||
|
export function hasBrancaAccess(auth: AuthContextInfo, brancaId: string): boolean {
|
||||||
|
if (auth.roles.includes('capo-gruppo')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(auth.roles.includes('capo-unita') || auth.roles.includes('capo')) && auth.branche.includes(brancaId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usata per l'entry point "crea evento" (nessuna branca specifica ancora
|
||||||
|
// selezionata): basta poter scrivere su almeno una branca.
|
||||||
|
export function hasAnyBrancaAccess(auth: AuthContextInfo): boolean {
|
||||||
|
return (
|
||||||
|
auth.roles.includes('capo-gruppo') ||
|
||||||
|
((auth.roles.includes('capo-unita') || auth.roles.includes('capo')) && auth.branche.length > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,122 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, provideRouter, Router } from '@angular/router';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
import { EventiApiService, EventoDettaglioView } from '../eventi-api.service';
|
||||||
|
import { requireBrancaAccessEventoGuard, requireCreaEventoGuard } from './require-branca-access.guard';
|
||||||
|
|
||||||
|
const state = {} as RouterStateSnapshot;
|
||||||
|
|
||||||
|
const eventoLupetti: EventoDettaglioView = {
|
||||||
|
id: 'evento-1',
|
||||||
|
orgId: 'org-1',
|
||||||
|
brancaId: 'Lupetti',
|
||||||
|
parentId: null,
|
||||||
|
titolo: 'Campo estivo',
|
||||||
|
descrizione: null,
|
||||||
|
dataInizio: '2026-07-10T08:00:00.000Z',
|
||||||
|
dataFine: '2026-07-10T18:00:00.000Z',
|
||||||
|
tipo: 'campo',
|
||||||
|
location: null,
|
||||||
|
creatoDa: 'user-1',
|
||||||
|
creatoIl: '2026-06-01T00:00:00.000Z',
|
||||||
|
figli: [],
|
||||||
|
risorseCollegate: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(options: {
|
||||||
|
roles?: string[];
|
||||||
|
groups?: string[];
|
||||||
|
getEvento?: ReturnType<typeof vi.fn>;
|
||||||
|
}): void {
|
||||||
|
const keycloakMock = {
|
||||||
|
tokenParsed: {
|
||||||
|
realm_access: { roles: options.roles ?? [] },
|
||||||
|
groups: options.groups ?? []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: Keycloak, useValue: keycloakMock },
|
||||||
|
{ provide: EventiApiService, useValue: { getEvento: options.getEvento ?? vi.fn().mockReturnValue(of(eventoLupetti)) } }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('requireCreaEventoGuard', () => {
|
||||||
|
const route = {} as ActivatedRouteSnapshot;
|
||||||
|
|
||||||
|
it('lascia proseguire capo-gruppo', () => {
|
||||||
|
setup({ roles: ['capo-gruppo'] });
|
||||||
|
const result = TestBed.runInInjectionContext(() => requireCreaEventoGuard(route, state));
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lascia proseguire capo-unita con almeno una branca', () => {
|
||||||
|
setup({ roles: ['capo-unita'], groups: ['/Lupetti'] });
|
||||||
|
const result = TestBed.runInInjectionContext(() => requireCreaEventoGuard(route, state));
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reindirizza alla vista mensile per censito', () => {
|
||||||
|
setup({ roles: ['censito'], groups: ['/Lupetti'] });
|
||||||
|
const result = TestBed.runInInjectionContext(() => requireCreaEventoGuard(route, state));
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
expect((result as UrlTree).toString()).toBe(router.parseUrl('/vista-mensile').toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reindirizza alla vista mensile per capo-unita senza nessuna branca', () => {
|
||||||
|
setup({ roles: ['capo-unita'], groups: [] });
|
||||||
|
const result = TestBed.runInInjectionContext(() => requireCreaEventoGuard(route, state));
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
expect((result as UrlTree).toString()).toBe(router.parseUrl('/vista-mensile').toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lascia proseguire capo con almeno una branca (stessi permessi di capo-unita)', () => {
|
||||||
|
setup({ roles: ['capo'], groups: ['/Lupetti'] });
|
||||||
|
const result = TestBed.runInInjectionContext(() => requireCreaEventoGuard(route, state));
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireBrancaAccessEventoGuard', () => {
|
||||||
|
function buildRoute(id: string | null): ActivatedRouteSnapshot {
|
||||||
|
return { paramMap: { get: () => id } } as unknown as ActivatedRouteSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('lascia proseguire capo-gruppo su qualunque branca', async () => {
|
||||||
|
setup({ roles: ['capo-gruppo'] });
|
||||||
|
const result = await TestBed.runInInjectionContext(() => requireBrancaAccessEventoGuard(buildRoute('evento-1'), state));
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lascia proseguire capo-unita sulla branca dell\'evento', async () => {
|
||||||
|
setup({ roles: ['capo-unita'], groups: ['/Lupetti'] });
|
||||||
|
const result = await TestBed.runInInjectionContext(() => requireBrancaAccessEventoGuard(buildRoute('evento-1'), state));
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reindirizza al dettaglio evento per capo-unita di un\'altra branca', async () => {
|
||||||
|
setup({ roles: ['capo-unita'], groups: ['/Esploratori'] });
|
||||||
|
const result = await TestBed.runInInjectionContext(() => requireBrancaAccessEventoGuard(buildRoute('evento-1'), state));
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
expect((result as UrlTree).toString()).toBe(router.parseUrl('/vista-evento/evento-1').toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reindirizza alla vista mensile se il caricamento dell\'evento fallisce', async () => {
|
||||||
|
setup({ roles: ['capo-gruppo'], getEvento: vi.fn().mockReturnValue(throwError(() => new Error('404'))) });
|
||||||
|
const result = await TestBed.runInInjectionContext(() => requireBrancaAccessEventoGuard(buildRoute('evento-1'), state));
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
expect((result as UrlTree).toString()).toBe(router.parseUrl('/vista-mensile').toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reindirizza alla vista mensile se manca il param id', async () => {
|
||||||
|
setup({ roles: ['capo-gruppo'] });
|
||||||
|
const result = await TestBed.runInInjectionContext(() => requireBrancaAccessEventoGuard(buildRoute(null), state));
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
expect((result as UrlTree).toString()).toBe(router.parseUrl('/vista-mensile').toString());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { ActivatedRouteSnapshot, CanActivateFn, Router } from '@angular/router';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { EventiApiService } from '../eventi-api.service';
|
||||||
|
import { extractAuthContext } from './auth-context';
|
||||||
|
import { hasAnyBrancaAccess, hasBrancaAccess } from './branca-access';
|
||||||
|
|
||||||
|
// Nascondere l'entry point via routing è solo una comodità UX: il backend
|
||||||
|
// rifà comunque la verifica autoritativa su POST/PUT /eventi
|
||||||
|
// (assertBrancaAccess). Da usare insieme a requireAuthGuard (già applicata
|
||||||
|
// sulla root delle route in app.routes.ts).
|
||||||
|
export const requireCreaEventoGuard: CanActivateFn = () => {
|
||||||
|
const router = inject(Router);
|
||||||
|
const keycloak = inject(Keycloak);
|
||||||
|
|
||||||
|
const auth = extractAuthContext(keycloak.tokenParsed);
|
||||||
|
return hasAnyBrancaAccess(auth) ? true : router.parseUrl('/vista-mensile');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per modifica/sotto-evento la branca da verificare è quella dell'evento
|
||||||
|
// esistente (id in route), non nota staticamente: va quindi caricato prima
|
||||||
|
// di poter decidere.
|
||||||
|
export const requireBrancaAccessEventoGuard: CanActivateFn = async (route: ActivatedRouteSnapshot) => {
|
||||||
|
const router = inject(Router);
|
||||||
|
const keycloak = inject(Keycloak);
|
||||||
|
const eventiApi = inject(EventiApiService);
|
||||||
|
|
||||||
|
const id = route.paramMap.get('id');
|
||||||
|
if (!id) {
|
||||||
|
return router.parseUrl('/vista-mensile');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const evento = await firstValueFrom(eventiApi.getEvento(id));
|
||||||
|
const auth = extractAuthContext(keycloak.tokenParsed);
|
||||||
|
return hasBrancaAccess(auth, evento.brancaId) ? true : router.parseUrl(`/vista-evento/${id}`);
|
||||||
|
} catch {
|
||||||
|
return router.parseUrl('/vista-mensile');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
export interface EventoView {
|
||||||
|
id: string;
|
||||||
|
orgId: string;
|
||||||
|
brancaId: string;
|
||||||
|
parentId: string | null;
|
||||||
|
titolo: string;
|
||||||
|
descrizione: string | null;
|
||||||
|
dataInizio: string;
|
||||||
|
dataFine: string;
|
||||||
|
tipo: string;
|
||||||
|
location: string | null;
|
||||||
|
creatoDa: string;
|
||||||
|
creatoIl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RisorsaCollegataView {
|
||||||
|
id: string;
|
||||||
|
tipoRisorsa: string;
|
||||||
|
risorsaId: string;
|
||||||
|
servizioOrigine: string;
|
||||||
|
metadata: unknown;
|
||||||
|
creatoIl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventoDettaglioView extends EventoView {
|
||||||
|
figli: EventoView[];
|
||||||
|
risorseCollegate: RisorsaCollegataView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreaEventoInput {
|
||||||
|
titolo: string;
|
||||||
|
descrizione: string | null;
|
||||||
|
dataInizio: string;
|
||||||
|
dataFine: string;
|
||||||
|
tipo: string;
|
||||||
|
location: string | null;
|
||||||
|
// Obbligatorio per un evento radice, ignorato/non richiesto per un
|
||||||
|
// sotto-evento (eredita la branca del genitore lato backend).
|
||||||
|
brancaId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggiornaEventoInput {
|
||||||
|
titolo?: string;
|
||||||
|
descrizione?: string | null;
|
||||||
|
dataInizio?: string;
|
||||||
|
dataFine?: string;
|
||||||
|
tipo?: string;
|
||||||
|
location?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class EventiApiService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
private readonly baseUrl = `${environment.eventiApiBaseUrl}/eventi`;
|
||||||
|
|
||||||
|
getEvento(id: string): Observable<EventoDettaglioView> {
|
||||||
|
return this.http.get<EventoDettaglioView>(`${this.baseUrl}/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
creaEvento(input: CreaEventoInput): Observable<EventoView> {
|
||||||
|
return this.http.post<EventoView>(this.baseUrl, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
// brancaId e parentId non sono modificabili dopo la creazione: non
|
||||||
|
// compaiono in AggiornaEventoInput perché il backend non li accetta in PUT.
|
||||||
|
aggiornaEvento(id: string, input: AggiornaEventoInput): Observable<EventoView> {
|
||||||
|
return this.http.put<EventoView>(`${this.baseUrl}/${id}`, input);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,23 @@
|
|||||||
|
// Il backend non espone un colore per tipo/branca: lo deriviamo qui in modo
|
||||||
|
// deterministico (hash del nome) da una palette fissa, cosi' lo stesso tipo
|
||||||
|
// ha sempre lo stesso colore senza bisogno di uno stato condiviso.
|
||||||
|
const PALETTE = [
|
||||||
|
'#e57373',
|
||||||
|
'#64b5f6',
|
||||||
|
'#81c784',
|
||||||
|
'#ffd54f',
|
||||||
|
'#ba68c8',
|
||||||
|
'#4db6ac',
|
||||||
|
'#f06292',
|
||||||
|
'#a1887f',
|
||||||
|
'#90a4ae',
|
||||||
|
'#ff8a65'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function coloreTipo(tipo: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < tipo.length; i++) {
|
||||||
|
hash = (hash * 31 + tipo.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
return PALETTE[hash % PALETTE.length];
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export const NOMI_MESI = [
|
||||||
|
'Gennaio',
|
||||||
|
'Febbraio',
|
||||||
|
'Marzo',
|
||||||
|
'Aprile',
|
||||||
|
'Maggio',
|
||||||
|
'Giugno',
|
||||||
|
'Luglio',
|
||||||
|
'Agosto',
|
||||||
|
'Settembre',
|
||||||
|
'Ottobre',
|
||||||
|
'Novembre',
|
||||||
|
'Dicembre'
|
||||||
|
];
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
export interface EventoGiornoView {
|
||||||
|
titolo: string;
|
||||||
|
tipo: string;
|
||||||
|
brancaId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VistaAnnoGiornoView {
|
||||||
|
data: string;
|
||||||
|
conteggio: number;
|
||||||
|
eventi: EventoGiornoView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class VistaAnnualeApiService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
// orgId non va passato come query param: il backend lo ricava sempre da
|
||||||
|
// req.auth.orgId (claim "organization" del JWT), mai da input client
|
||||||
|
// (vedi requireOrgId nel backend eventi).
|
||||||
|
getVistaAnno(anno: number): Observable<VistaAnnoGiornoView[]> {
|
||||||
|
return this.http.get<VistaAnnoGiornoView[]>(`${environment.eventiApiBaseUrl}/eventi`, {
|
||||||
|
params: { vista: 'anno', anno: String(anno) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
.vista-annuale__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-annuale__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 4ch;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-annuale__stato {
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-annuale__stato--errore {
|
||||||
|
color: var(--mat-sys-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-annuale__griglia {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mese {
|
||||||
|
background: var(--color-surface) !important;
|
||||||
|
box-shadow: var(--shadow-sm) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mese ::ng-deep .mat-mdc-card-header {
|
||||||
|
padding: var(--space-3) var(--space-3) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mese ::ng-deep .mat-mdc-card-content {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mese ::ng-deep .mat-mdc-card-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mese__intestazione {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mese__celle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mese__giorno {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mese__giorno:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-text) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mese__giorno--vuoto {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mese__numero {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mese__badge {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
right: 2px;
|
||||||
|
min-width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mese__badge:not(:empty) {
|
||||||
|
min-width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<div class="vista-annuale">
|
||||||
|
<div class="vista-annuale__header">
|
||||||
|
<button mat-icon-button (click)="annoPrecedente()" aria-label="Anno precedente">
|
||||||
|
<mat-icon>chevron_left</mat-icon>
|
||||||
|
</button>
|
||||||
|
<h2>{{ anno() }}</h2>
|
||||||
|
<button mat-icon-button (click)="annoSuccessivo()" aria-label="Anno successivo">
|
||||||
|
<mat-icon>chevron_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<p class="vista-annuale__stato">Caricamento eventi...</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (error()) {
|
||||||
|
<p class="vista-annuale__stato vista-annuale__stato--errore">{{ error() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="vista-annuale__griglia">
|
||||||
|
@for (mese of mesi(); track mese.mese) {
|
||||||
|
<mat-card class="mese" appearance="raised">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title>{{ mese.nome }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<div class="mese__intestazione">
|
||||||
|
@for (nomeGiorno of giorniSettimana; track nomeGiorno) {
|
||||||
|
<span>{{ nomeGiorno }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="mese__celle">
|
||||||
|
@for (cella of mese.celle; track $index) {
|
||||||
|
@if (cella) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mese__giorno"
|
||||||
|
(click)="vaiAMese(mese.mese)"
|
||||||
|
[attr.aria-label]="cella.conteggio + ' eventi il ' + cella.giorno + ' ' + mese.nome"
|
||||||
|
>
|
||||||
|
<span class="mese__numero">{{ cella.giorno }}</span>
|
||||||
|
@if (cella.conteggio > 0) {
|
||||||
|
<span class="mese__badge" [style.background-color]="cella.colore">
|
||||||
|
{{ cella.conteggio > 1 ? cella.conteggio : '' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<span class="mese__giorno mese__giorno--vuoto"></span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const VISTA_ANNUALE_ROUTES: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () => import('./vista-annuale').then((m) => m.VistaAnnuale)
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
import { VistaAnnoGiornoView, VistaAnnualeApiService } from './vista-annuale-api.service';
|
||||||
|
import { VistaAnnuale } from './vista-annuale';
|
||||||
|
|
||||||
|
describe('VistaAnnuale', () => {
|
||||||
|
let component: VistaAnnuale;
|
||||||
|
let fixture: ComponentFixture<VistaAnnuale>;
|
||||||
|
let api: { getVistaAnno: ReturnType<typeof vi.fn> };
|
||||||
|
let router: { navigate: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const anno = new Date().getFullYear();
|
||||||
|
|
||||||
|
const datiIniziali: VistaAnnoGiornoView[] = [
|
||||||
|
{
|
||||||
|
data: `${anno}-03-15`,
|
||||||
|
conteggio: 1,
|
||||||
|
eventi: [{ titolo: 'Uscita', tipo: 'uscita', brancaId: 'branca-1' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
data: `${anno}-03-16`,
|
||||||
|
conteggio: 3,
|
||||||
|
eventi: [
|
||||||
|
{ titolo: 'Campo A', tipo: 'campo', brancaId: 'branca-1' },
|
||||||
|
{ titolo: 'Campo B', tipo: 'campo', brancaId: 'branca-1' },
|
||||||
|
{ titolo: 'Riunione', tipo: 'riunione', brancaId: 'branca-2' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
async function setup(response = of(datiIniziali)): Promise<void> {
|
||||||
|
api = { getVistaAnno: vi.fn().mockReturnValue(response) };
|
||||||
|
router = { navigate: vi.fn() };
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [VistaAnnuale],
|
||||||
|
providers: [
|
||||||
|
{ provide: VistaAnnualeApiService, useValue: api },
|
||||||
|
{ provide: Router, useValue: router }
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(VistaAnnuale);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('carica la vista annuale per l\'anno corrente all\'avvio', async () => {
|
||||||
|
await setup();
|
||||||
|
expect(api.getVistaAnno).toHaveBeenCalledWith(anno);
|
||||||
|
expect(component.loading()).toBe(false);
|
||||||
|
expect(component.error()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('espone il conteggio corretto per i giorni con eventi', async () => {
|
||||||
|
await setup();
|
||||||
|
const marzo = component.mesi().find((m) => m.mese === 3)!;
|
||||||
|
const giorno15 = marzo.celle.find((c) => c?.giorno === 15);
|
||||||
|
const giorno16 = marzo.celle.find((c) => c?.giorno === 16);
|
||||||
|
expect(giorno15?.conteggio).toBe(1);
|
||||||
|
expect(giorno16?.conteggio).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('naviga alla vista mensile con anno e mese quando si clicca un giorno', async () => {
|
||||||
|
await setup();
|
||||||
|
component.vaiAMese(3);
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/vista-mensile'], { queryParams: { anno, mese: 3 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cambia anno e ricarica i dati', async () => {
|
||||||
|
await setup();
|
||||||
|
api.getVistaAnno.mockReturnValue(of([]));
|
||||||
|
|
||||||
|
component.annoSuccessivo();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(component.anno()).toBe(anno + 1);
|
||||||
|
expect(api.getVistaAnno).toHaveBeenCalledWith(anno + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mostra un messaggio dedicato se manca un\'organizzazione attiva (403)', async () => {
|
||||||
|
await setup(throwError(() => new HttpErrorResponse({ status: 403 })));
|
||||||
|
expect(component.error()).toBe('Nessuna organizzazione attiva sul tuo account: impossibile caricare gli eventi.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mostra un messaggio generico per altri errori', async () => {
|
||||||
|
await setup(throwError(() => new HttpErrorResponse({ status: 500 })));
|
||||||
|
expect(component.error()).toBe('Impossibile caricare gli eventi. Riprova più tardi.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { coloreTipo } from '../core/utils/colore-tipo';
|
||||||
|
import { NOMI_MESI } from '../core/utils/nomi-mesi';
|
||||||
|
import { VistaAnnoGiornoView, VistaAnnualeApiService } from './vista-annuale-api.service';
|
||||||
|
|
||||||
|
interface GiornoCella {
|
||||||
|
giorno: number;
|
||||||
|
conteggio: number;
|
||||||
|
colore: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeseGrid {
|
||||||
|
mese: number;
|
||||||
|
nome: string;
|
||||||
|
celle: (GiornoCella | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-vista-annuale',
|
||||||
|
imports: [MatButtonModule, MatCardModule, MatIconModule],
|
||||||
|
templateUrl: './vista-annuale.html',
|
||||||
|
styleUrl: './vista-annuale.css'
|
||||||
|
})
|
||||||
|
export class VistaAnnuale {
|
||||||
|
private readonly api = inject(VistaAnnualeApiService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
readonly giorniSettimana = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom'];
|
||||||
|
|
||||||
|
readonly anno = signal(new Date().getFullYear());
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
private readonly datiPerGiorno = signal<Map<string, VistaAnnoGiornoView>>(new Map());
|
||||||
|
|
||||||
|
readonly mesi = computed<MeseGrid[]>(() => this.costruisciGriglia(this.anno(), this.datiPerGiorno()));
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const anno = this.anno();
|
||||||
|
void this.caricaAnno(anno);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
annoPrecedente(): void {
|
||||||
|
this.anno.update((a) => a - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
annoSuccessivo(): void {
|
||||||
|
this.anno.update((a) => a + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
vaiAMese(mese: number): void {
|
||||||
|
this.router.navigate(['/vista-mensile'], { queryParams: { anno: this.anno(), mese } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async caricaAnno(anno: number): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const risultato = await firstValueFrom(this.api.getVistaAnno(anno));
|
||||||
|
if (this.anno() !== anno) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.datiPerGiorno.set(new Map(risultato.map((g) => [g.data, g])));
|
||||||
|
} catch (err) {
|
||||||
|
if (this.anno() !== anno) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.datiPerGiorno.set(new Map());
|
||||||
|
this.error.set(this.messaggioErrore(err as HttpErrorResponse));
|
||||||
|
} finally {
|
||||||
|
if (this.anno() === anno) {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private messaggioErrore(err: HttpErrorResponse): string {
|
||||||
|
if (err.status === 403) {
|
||||||
|
return 'Nessuna organizzazione attiva sul tuo account: impossibile caricare gli eventi.';
|
||||||
|
}
|
||||||
|
return 'Impossibile caricare gli eventi. Riprova più tardi.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private costruisciGriglia(anno: number, dati: Map<string, VistaAnnoGiornoView>): MeseGrid[] {
|
||||||
|
const mesi: MeseGrid[] = [];
|
||||||
|
|
||||||
|
for (let mese = 1; mese <= 12; mese++) {
|
||||||
|
const giorniNelMese = new Date(anno, mese, 0).getDate();
|
||||||
|
const primoGiorno = new Date(anno, mese - 1, 1);
|
||||||
|
// getDay(): 0=Domenica..6=Sabato; la griglia mostra le settimane da Lunedì.
|
||||||
|
const offset = (primoGiorno.getDay() + 6) % 7;
|
||||||
|
|
||||||
|
const celle: (GiornoCella | null)[] = new Array(offset).fill(null);
|
||||||
|
|
||||||
|
for (let giorno = 1; giorno <= giorniNelMese; giorno++) {
|
||||||
|
const data = `${anno}-${String(mese).padStart(2, '0')}-${String(giorno).padStart(2, '0')}`;
|
||||||
|
const giornoDati = dati.get(data);
|
||||||
|
const primoEvento = giornoDati?.eventi[0];
|
||||||
|
|
||||||
|
celle.push({
|
||||||
|
giorno,
|
||||||
|
conteggio: giornoDati?.conteggio ?? 0,
|
||||||
|
colore: primoEvento ? coloreTipo(primoEvento.tipo) : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
while (celle.length % 7 !== 0) {
|
||||||
|
celle.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
mesi.push({ mese, nome: NOMI_MESI[mese - 1], celle });
|
||||||
|
}
|
||||||
|
|
||||||
|
return mesi;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
.evento-form {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: var(--space-6) auto;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-form__card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: calc(var(--radius-lg) * 1.15);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-form__genitore {
|
||||||
|
color: color-mix(in srgb, var(--color-text) 55%, transparent);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-form__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-form__field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-form__data-ora {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-form__data-ora .evento-form__field {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-form__error {
|
||||||
|
color: var(--mat-sys-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-form__avviso {
|
||||||
|
color: color-mix(in srgb, var(--color-text) 55%, transparent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<section class="evento-form">
|
||||||
|
<div class="evento-form__card">
|
||||||
|
<h1>{{ titolo }}</h1>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<p>Caricamento...</p>
|
||||||
|
} @else if (loadError(); as message) {
|
||||||
|
<p class="evento-form__error" role="alert">{{ message }}</p>
|
||||||
|
} @else {
|
||||||
|
@if (genitore(); as g) {
|
||||||
|
<p class="evento-form__genitore">Sotto-evento di: <strong>{{ g.titolo }}</strong></p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form class="evento-form__form" (submit)="$event.preventDefault(); submit()" novalidate>
|
||||||
|
<mat-form-field appearance="outline" class="evento-form__field">
|
||||||
|
<mat-label>Titolo</mat-label>
|
||||||
|
<input matInput [formControl]="titoloControl" />
|
||||||
|
@if (titoloControl.hasError('required')) {
|
||||||
|
<mat-error>Il titolo è obbligatorio.</mat-error>
|
||||||
|
} @else if (titoloControl.hasError('maxlength')) {
|
||||||
|
<mat-error>Il titolo non può superare i 200 caratteri.</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="evento-form__field">
|
||||||
|
<mat-label>Descrizione</mat-label>
|
||||||
|
<textarea matInput [formControl]="descrizioneControl" rows="3"></textarea>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="evento-form__field">
|
||||||
|
<mat-label>Tipo</mat-label>
|
||||||
|
<input matInput [formControl]="tipoControl" placeholder="Es. campo, riunione, uscita" />
|
||||||
|
@if (tipoControl.hasError('required')) {
|
||||||
|
<mat-error>Il tipo è obbligatorio.</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline" class="evento-form__field">
|
||||||
|
<mat-label>Location</mat-label>
|
||||||
|
<input matInput [formControl]="locationControl" />
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (modalita === 'crea') {
|
||||||
|
<mat-form-field appearance="outline" class="evento-form__field">
|
||||||
|
<mat-label>Branca</mat-label>
|
||||||
|
<mat-select [formControl]="brancaControl">
|
||||||
|
@for (branca of brancheUtente; track branca) {
|
||||||
|
<mat-option [value]="branca">{{ branca }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (brancaControl.hasError('required')) {
|
||||||
|
<mat-error>Seleziona una branca.</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
|
@if (brancheUtente.length === 0) {
|
||||||
|
<p class="evento-form__avviso">Non hai nessuna branca assegnata: non puoi creare eventi.</p>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<mat-form-field appearance="outline" class="evento-form__field">
|
||||||
|
<mat-label>Branca</mat-label>
|
||||||
|
<input matInput [value]="brancaControl.value" disabled />
|
||||||
|
</mat-form-field>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="evento-form__data-ora">
|
||||||
|
<mat-form-field appearance="outline" class="evento-form__field">
|
||||||
|
<mat-label>Data inizio</mat-label>
|
||||||
|
<input matInput type="date" [formControl]="dataInizioControl" [attr.min]="minData()" [attr.max]="maxData()" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="evento-form__field">
|
||||||
|
<mat-label>Ora inizio</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
type="time"
|
||||||
|
[formControl]="oraInizioControl"
|
||||||
|
[attr.min]="minOraInizio()"
|
||||||
|
[attr.max]="maxOraInizio()"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="evento-form__data-ora">
|
||||||
|
<mat-form-field appearance="outline" class="evento-form__field">
|
||||||
|
<mat-label>Data fine</mat-label>
|
||||||
|
<input matInput type="date" [formControl]="dataFineControl" [attr.min]="minData()" [attr.max]="maxData()" />
|
||||||
|
</mat-form-field>
|
||||||
|
<mat-form-field appearance="outline" class="evento-form__field">
|
||||||
|
<mat-label>Ora fine</mat-label>
|
||||||
|
<input matInput type="time" [formControl]="oraFineControl" [attr.min]="minOraFine()" [attr.max]="maxOraFine()" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (submitError(); as message) {
|
||||||
|
<p class="evento-form__error" role="alert">{{ message }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button mat-flat-button color="primary" type="submit" [disabled]="submitting()">
|
||||||
|
{{ submitting() ? 'Salvataggio in corso...' : 'Salva' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
import { Observable, of } from 'rxjs';
|
||||||
|
|
||||||
|
import { EventiApiService, EventoDettaglioView } from '../../core/eventi-api.service';
|
||||||
|
import { EventoForm } from './evento-form';
|
||||||
|
|
||||||
|
describe('EventoForm', () => {
|
||||||
|
let component: EventoForm;
|
||||||
|
let fixture: ComponentFixture<EventoForm>;
|
||||||
|
let api: {
|
||||||
|
getEvento: ReturnType<typeof vi.fn>;
|
||||||
|
creaEvento: ReturnType<typeof vi.fn>;
|
||||||
|
aggiornaEvento: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
let router: { navigate: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const eventoRadice: EventoDettaglioView = {
|
||||||
|
id: 'evento-1',
|
||||||
|
orgId: 'org-1',
|
||||||
|
brancaId: 'Lupetti',
|
||||||
|
parentId: null,
|
||||||
|
titolo: 'Campo estivo',
|
||||||
|
descrizione: 'Descrizione campo',
|
||||||
|
dataInizio: '2026-07-10T08:00:00.000Z',
|
||||||
|
dataFine: '2026-07-12T18:00:00.000Z',
|
||||||
|
tipo: 'campo',
|
||||||
|
location: 'Bosco',
|
||||||
|
creatoDa: 'user-1',
|
||||||
|
creatoIl: '2026-06-01T00:00:00.000Z',
|
||||||
|
figli: [],
|
||||||
|
risorseCollegate: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildRouteMock(options: {
|
||||||
|
path: string;
|
||||||
|
id?: string | null;
|
||||||
|
queryParams?: Record<string, string>;
|
||||||
|
}): Partial<ActivatedRoute> {
|
||||||
|
return {
|
||||||
|
snapshot: {
|
||||||
|
routeConfig: { path: options.path },
|
||||||
|
paramMap: { get: (key: string) => (key === 'id' ? (options.id ?? null) : null) },
|
||||||
|
queryParamMap: { get: (key: string) => options.queryParams?.[key] ?? null }
|
||||||
|
}
|
||||||
|
} as unknown as ActivatedRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(options: {
|
||||||
|
path: string;
|
||||||
|
id?: string | null;
|
||||||
|
queryParams?: Record<string, string>;
|
||||||
|
roles?: string[];
|
||||||
|
groups?: string[];
|
||||||
|
getEventoImpl?: (id: string) => Observable<EventoDettaglioView>;
|
||||||
|
}): Promise<void> {
|
||||||
|
api = {
|
||||||
|
getEvento: vi.fn().mockImplementation((id: string) => (options.getEventoImpl ? options.getEventoImpl(id) : of(eventoRadice))),
|
||||||
|
creaEvento: vi.fn().mockReturnValue(of({ ...eventoRadice, id: 'nuovo-evento' })),
|
||||||
|
aggiornaEvento: vi.fn().mockReturnValue(of({ ...eventoRadice }))
|
||||||
|
};
|
||||||
|
router = { navigate: vi.fn() };
|
||||||
|
const keycloakMock = {
|
||||||
|
tokenParsed: {
|
||||||
|
realm_access: { roles: options.roles ?? [] },
|
||||||
|
groups: options.groups ?? []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [EventoForm],
|
||||||
|
providers: [
|
||||||
|
{ provide: EventiApiService, useValue: api },
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
{ provide: ActivatedRoute, useValue: buildRouteMock(options) },
|
||||||
|
{ provide: Keycloak, useValue: keycloakMock }
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(EventoForm);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('modalità crea', () => {
|
||||||
|
it('preseleziona la branca dal query param se posseduta dall\'utente', async () => {
|
||||||
|
await setup({ path: 'nuovo', queryParams: { branca: 'Lupetti' }, groups: ['/Lupetti'] });
|
||||||
|
expect(component.brancaControl.value).toBe('Lupetti');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invia brancaId scelto e naviga al dettaglio del nuovo evento', async () => {
|
||||||
|
await setup({ path: 'nuovo', groups: ['/Lupetti'] });
|
||||||
|
|
||||||
|
component.titoloControl.setValue('Uscita');
|
||||||
|
component.tipoControl.setValue('uscita');
|
||||||
|
component.brancaControl.setValue('Lupetti');
|
||||||
|
component.dataInizioControl.setValue('2026-08-01');
|
||||||
|
component.oraInizioControl.setValue('09:00');
|
||||||
|
component.dataFineControl.setValue('2026-08-01');
|
||||||
|
component.oraFineControl.setValue('17:00');
|
||||||
|
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
expect(api.creaEvento).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ titolo: 'Uscita', tipo: 'uscita', brancaId: 'Lupetti' })
|
||||||
|
);
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/vista-evento', 'nuovo-evento']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non invia la richiesta se i campi obbligatori mancano', async () => {
|
||||||
|
await setup({ path: 'nuovo', groups: ['/Lupetti'] });
|
||||||
|
await component.submit();
|
||||||
|
expect(api.creaEvento).not.toHaveBeenCalled();
|
||||||
|
expect(component.titoloControl.touched).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('modalità sotto-evento', () => {
|
||||||
|
it('eredita la branca del genitore e disabilita il campo', async () => {
|
||||||
|
await setup({ path: ':id/sotto-evento', id: 'evento-1' });
|
||||||
|
expect(component.brancaControl.value).toBe('Lupetti');
|
||||||
|
expect(component.brancaControl.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vincola min/max data agli estremi del genitore', async () => {
|
||||||
|
await setup({ path: ':id/sotto-evento', id: 'evento-1' });
|
||||||
|
expect(component.minData()).toBe('2026-07-10');
|
||||||
|
expect(component.maxData()).toBe('2026-07-12');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invia parentId invece di brancaId alla creazione', async () => {
|
||||||
|
await setup({ path: ':id/sotto-evento', id: 'evento-1' });
|
||||||
|
|
||||||
|
component.titoloControl.setValue('Laboratorio');
|
||||||
|
component.tipoControl.setValue('attivita');
|
||||||
|
component.dataInizioControl.setValue('2026-07-11');
|
||||||
|
component.oraInizioControl.setValue('10:00');
|
||||||
|
component.dataFineControl.setValue('2026-07-11');
|
||||||
|
component.oraFineControl.setValue('12:00');
|
||||||
|
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
expect(api.creaEvento).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ parentId: 'evento-1', titolo: 'Laboratorio' })
|
||||||
|
);
|
||||||
|
expect(api.creaEvento).toHaveBeenCalledWith(expect.not.objectContaining({ brancaId: expect.anything() }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mostra un errore se il genitore è già un sotto-evento (max due livelli)', async () => {
|
||||||
|
const figlio: EventoDettaglioView = { ...eventoRadice, id: 'evento-2', parentId: 'evento-1' };
|
||||||
|
await setup({ path: ':id/sotto-evento', id: 'evento-2', getEventoImpl: () => of(figlio) });
|
||||||
|
|
||||||
|
expect(component.loadError()).toBe(
|
||||||
|
'Non è possibile creare un sotto-evento di un sotto-evento (massimo due livelli).'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('modalità modifica', () => {
|
||||||
|
it('precompila il form con i dati dell\'evento', async () => {
|
||||||
|
await setup({ path: ':id/modifica', id: 'evento-1' });
|
||||||
|
expect(component.titoloControl.value).toBe('Campo estivo');
|
||||||
|
expect(component.brancaControl.value).toBe('Lupetti');
|
||||||
|
expect(component.brancaControl.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invia le modifiche e naviga al dettaglio', async () => {
|
||||||
|
await setup({ path: ':id/modifica', id: 'evento-1' });
|
||||||
|
|
||||||
|
component.titoloControl.setValue('Campo estivo (modificato)');
|
||||||
|
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
expect(api.aggiornaEvento).toHaveBeenCalledWith(
|
||||||
|
'evento-1',
|
||||||
|
expect.objectContaining({ titolo: 'Campo estivo (modificato)' })
|
||||||
|
);
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/vista-evento', 'evento-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carica il genitore per vincolare le date se l\'evento modificato è un figlio', async () => {
|
||||||
|
const figlio: EventoDettaglioView = { ...eventoRadice, id: 'evento-2', parentId: 'evento-1' };
|
||||||
|
const getEventoImpl = (id: string) => of(id === 'evento-2' ? figlio : eventoRadice);
|
||||||
|
|
||||||
|
await setup({ path: ':id/modifica', id: 'evento-2', getEventoImpl });
|
||||||
|
|
||||||
|
expect(component.minData()).toBe('2026-07-10');
|
||||||
|
expect(component.maxData()).toBe('2026-07-12');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mostra un errore se la data/ora di fine precede l\'inizio', async () => {
|
||||||
|
await setup({ path: 'nuovo', groups: ['/Lupetti'] });
|
||||||
|
|
||||||
|
component.titoloControl.setValue('Uscita');
|
||||||
|
component.tipoControl.setValue('uscita');
|
||||||
|
component.brancaControl.setValue('Lupetti');
|
||||||
|
component.dataInizioControl.setValue('2026-08-02');
|
||||||
|
component.oraInizioControl.setValue('09:00');
|
||||||
|
component.dataFineControl.setValue('2026-08-01');
|
||||||
|
component.oraFineControl.setValue('17:00');
|
||||||
|
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
expect(component.submitError()).toBe("La data/ora di fine deve essere successiva o uguale all'inizio.");
|
||||||
|
expect(api.creaEvento).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { AbstractControl, 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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { extractAuthContext } from '../../core/auth/auth-context';
|
||||||
|
import { AggiornaEventoInput, CreaEventoInput, EventiApiService, EventoView } from '../../core/eventi-api.service';
|
||||||
|
|
||||||
|
type Modalita = 'crea' | 'modifica' | 'sotto-evento';
|
||||||
|
|
||||||
|
function toDateInputValue(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTimeInputValue(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineToIso(data: string, ora: string): string {
|
||||||
|
return new Date(`${data}T${ora}`).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-evento-form',
|
||||||
|
imports: [ReactiveFormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule],
|
||||||
|
templateUrl: './evento-form.html',
|
||||||
|
styleUrl: './evento-form.css'
|
||||||
|
})
|
||||||
|
export class EventoForm implements OnInit {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly api = inject(EventiApiService);
|
||||||
|
private readonly keycloak = inject(Keycloak);
|
||||||
|
|
||||||
|
private readonly routePath = this.route.snapshot.routeConfig?.path ?? '';
|
||||||
|
private readonly eventoId = this.route.snapshot.paramMap.get('id');
|
||||||
|
|
||||||
|
readonly modalita: Modalita =
|
||||||
|
this.routePath === 'nuovo' ? 'crea' : this.routePath.endsWith('sotto-evento') ? 'sotto-evento' : 'modifica';
|
||||||
|
|
||||||
|
// Non c'è un catalogo branche lato backend: le opzioni per un evento radice
|
||||||
|
// sono le sole branche (claim "groups") a cui appartiene l'utente corrente.
|
||||||
|
readonly brancheUtente = extractAuthContext(this.keycloak.tokenParsed).branche;
|
||||||
|
|
||||||
|
readonly loading = signal(true);
|
||||||
|
readonly loadError = signal<string | null>(null);
|
||||||
|
|
||||||
|
// Evento che vincola l'intervallo data/ora selezionabile: il genitore in
|
||||||
|
// modalità sotto-evento, oppure il genitore dell'evento in modifica se
|
||||||
|
// quest'ultimo è già un figlio.
|
||||||
|
readonly genitore = signal<EventoView | null>(null);
|
||||||
|
|
||||||
|
readonly titoloControl = new FormControl('', {
|
||||||
|
nonNullable: true,
|
||||||
|
validators: [Validators.required, Validators.maxLength(200)]
|
||||||
|
});
|
||||||
|
readonly descrizioneControl = new FormControl('', { nonNullable: true });
|
||||||
|
readonly tipoControl = new FormControl('', { nonNullable: true, validators: [Validators.required] });
|
||||||
|
readonly locationControl = new FormControl('', { nonNullable: true });
|
||||||
|
readonly brancaControl = new FormControl<string | null>(null, { validators: [Validators.required] });
|
||||||
|
readonly dataInizioControl = new FormControl('', { nonNullable: true, validators: [Validators.required] });
|
||||||
|
readonly oraInizioControl = new FormControl('', { nonNullable: true, validators: [Validators.required] });
|
||||||
|
readonly dataFineControl = new FormControl('', { nonNullable: true, validators: [Validators.required] });
|
||||||
|
readonly oraFineControl = new FormControl('', { nonNullable: true, validators: [Validators.required] });
|
||||||
|
|
||||||
|
readonly submitting = signal(false);
|
||||||
|
readonly submitError = signal<string | null>(null);
|
||||||
|
|
||||||
|
get titolo(): string {
|
||||||
|
if (this.modalita === 'crea') {
|
||||||
|
return 'Crea evento';
|
||||||
|
}
|
||||||
|
if (this.modalita === 'sotto-evento') {
|
||||||
|
return 'Aggiungi sotto-evento';
|
||||||
|
}
|
||||||
|
return 'Modifica evento';
|
||||||
|
}
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
await this.inizializza();
|
||||||
|
}
|
||||||
|
|
||||||
|
minData(): string | null {
|
||||||
|
const g = this.genitore();
|
||||||
|
return g ? toDateInputValue(g.dataInizio) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
maxData(): string | null {
|
||||||
|
const g = this.genitore();
|
||||||
|
return g ? toDateInputValue(g.dataFine) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
minOraInizio(): string | null {
|
||||||
|
const g = this.genitore();
|
||||||
|
return g && this.dataInizioControl.value === toDateInputValue(g.dataInizio) ? toTimeInputValue(g.dataInizio) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
maxOraInizio(): string | null {
|
||||||
|
const g = this.genitore();
|
||||||
|
return g && this.dataInizioControl.value === toDateInputValue(g.dataFine) ? toTimeInputValue(g.dataFine) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
minOraFine(): string | null {
|
||||||
|
const g = this.genitore();
|
||||||
|
return g && this.dataFineControl.value === toDateInputValue(g.dataInizio) ? toTimeInputValue(g.dataInizio) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
maxOraFine(): string | null {
|
||||||
|
const g = this.genitore();
|
||||||
|
return g && this.dataFineControl.value === toDateInputValue(g.dataFine) ? toTimeInputValue(g.dataFine) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(): Promise<void> {
|
||||||
|
if (this.submitting()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlli: AbstractControl[] = [
|
||||||
|
this.titoloControl,
|
||||||
|
this.tipoControl,
|
||||||
|
this.dataInizioControl,
|
||||||
|
this.oraInizioControl,
|
||||||
|
this.dataFineControl,
|
||||||
|
this.oraFineControl
|
||||||
|
];
|
||||||
|
if (this.modalita === 'crea') {
|
||||||
|
controlli.push(this.brancaControl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controlli.some((c) => c.invalid)) {
|
||||||
|
controlli.forEach((c) => c.markAsTouched());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataInizioIso = combineToIso(this.dataInizioControl.value, this.oraInizioControl.value);
|
||||||
|
const dataFineIso = combineToIso(this.dataFineControl.value, this.oraFineControl.value);
|
||||||
|
|
||||||
|
if (dataFineIso < dataInizioIso) {
|
||||||
|
this.submitError.set("La data/ora di fine deve essere successiva o uguale all'inizio.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.submitError.set(null);
|
||||||
|
this.submitting.set(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const eventoSalvato = await (this.modalita === 'modifica'
|
||||||
|
? this.salvaModifica(dataInizioIso, dataFineIso)
|
||||||
|
: this.salvaCreazione(dataInizioIso, dataFineIso));
|
||||||
|
await this.router.navigate(['/vista-evento', eventoSalvato.id]);
|
||||||
|
} catch (err) {
|
||||||
|
this.submitError.set(this.messaggioErroreSalvataggio(err as HttpErrorResponse));
|
||||||
|
} finally {
|
||||||
|
this.submitting.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private salvaModifica(dataInizioIso: string, dataFineIso: string): Promise<EventoView> {
|
||||||
|
const input: AggiornaEventoInput = {
|
||||||
|
titolo: this.titoloControl.value.trim(),
|
||||||
|
descrizione: this.descrizioneControl.value.trim() || null,
|
||||||
|
tipo: this.tipoControl.value.trim(),
|
||||||
|
location: this.locationControl.value.trim() || null,
|
||||||
|
dataInizio: dataInizioIso,
|
||||||
|
dataFine: dataFineIso
|
||||||
|
};
|
||||||
|
return firstValueFrom(this.api.aggiornaEvento(this.eventoId!, input));
|
||||||
|
}
|
||||||
|
|
||||||
|
private salvaCreazione(dataInizioIso: string, dataFineIso: string): Promise<EventoView> {
|
||||||
|
const input: CreaEventoInput = {
|
||||||
|
titolo: this.titoloControl.value.trim(),
|
||||||
|
descrizione: this.descrizioneControl.value.trim() || null,
|
||||||
|
tipo: this.tipoControl.value.trim(),
|
||||||
|
location: this.locationControl.value.trim() || null,
|
||||||
|
dataInizio: dataInizioIso,
|
||||||
|
dataFine: dataFineIso,
|
||||||
|
...(this.modalita === 'sotto-evento' ? { parentId: this.eventoId! } : { brancaId: this.brancaControl.value! })
|
||||||
|
};
|
||||||
|
return firstValueFrom(this.api.creaEvento(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
private messaggioErroreSalvataggio(err: HttpErrorResponse): string {
|
||||||
|
if (err.status === 403) {
|
||||||
|
return 'Non hai i permessi per salvare un evento su questa branca.';
|
||||||
|
}
|
||||||
|
if (err.status === 400) {
|
||||||
|
return typeof err.error?.message === 'string' ? err.error.message : 'Dati non validi: controlla i campi del form.';
|
||||||
|
}
|
||||||
|
return 'Impossibile salvare. Riprova più tardi.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async inizializza(): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.loadError.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.modalita === 'crea') {
|
||||||
|
const brancaPreselezionata = this.route.snapshot.queryParamMap.get('branca');
|
||||||
|
if (brancaPreselezionata && this.brancheUtente.includes(brancaPreselezionata)) {
|
||||||
|
this.brancaControl.setValue(brancaPreselezionata);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.modalita === 'sotto-evento') {
|
||||||
|
await this.inizializzaSottoEvento();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.inizializzaModifica();
|
||||||
|
} catch {
|
||||||
|
this.loadError.set('Impossibile caricare i dati necessari. Riprova più tardi.');
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async inizializzaSottoEvento(): Promise<void> {
|
||||||
|
const genitore = await firstValueFrom(this.api.getEvento(this.eventoId!));
|
||||||
|
if (genitore.parentId !== null) {
|
||||||
|
this.loadError.set('Non è possibile creare un sotto-evento di un sotto-evento (massimo due livelli).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.genitore.set(genitore);
|
||||||
|
this.brancaControl.setValue(genitore.brancaId);
|
||||||
|
this.brancaControl.disable();
|
||||||
|
this.dataInizioControl.setValue(toDateInputValue(genitore.dataInizio));
|
||||||
|
this.oraInizioControl.setValue(toTimeInputValue(genitore.dataInizio));
|
||||||
|
this.dataFineControl.setValue(toDateInputValue(genitore.dataFine));
|
||||||
|
this.oraFineControl.setValue(toTimeInputValue(genitore.dataFine));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async inizializzaModifica(): Promise<void> {
|
||||||
|
const evento = await firstValueFrom(this.api.getEvento(this.eventoId!));
|
||||||
|
|
||||||
|
this.titoloControl.setValue(evento.titolo);
|
||||||
|
this.descrizioneControl.setValue(evento.descrizione ?? '');
|
||||||
|
this.tipoControl.setValue(evento.tipo);
|
||||||
|
this.locationControl.setValue(evento.location ?? '');
|
||||||
|
this.brancaControl.setValue(evento.brancaId);
|
||||||
|
this.brancaControl.disable();
|
||||||
|
this.dataInizioControl.setValue(toDateInputValue(evento.dataInizio));
|
||||||
|
this.oraInizioControl.setValue(toTimeInputValue(evento.dataInizio));
|
||||||
|
this.dataFineControl.setValue(toDateInputValue(evento.dataFine));
|
||||||
|
this.oraFineControl.setValue(toTimeInputValue(evento.dataFine));
|
||||||
|
|
||||||
|
if (evento.parentId) {
|
||||||
|
const genitore = await firstValueFrom(this.api.getEvento(evento.parentId));
|
||||||
|
this.genitore.set(genitore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
.vista-evento {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: var(--space-6) auto;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__error {
|
||||||
|
color: var(--mat-sys-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: calc(var(--radius-lg) * 1.15);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-4);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__meta {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__tag {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-accent-100);
|
||||||
|
color: var(--color-accent-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__azioni {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__descrizione {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__dettagli {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: var(--space-4) 0;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__dettagli dt {
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in srgb, var(--color-text) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__dettagli dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-giorno {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-giorno__etichetta {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-giorno__corsia {
|
||||||
|
position: relative;
|
||||||
|
height: 22px;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-giorno__corsia--figlio {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-blocco {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
min-width: 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-blocco--genitore {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__vuoto {
|
||||||
|
color: color-mix(in srgb, var(--color-text) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__risorse {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__risorsa {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__risorsa-tipo {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__risorsa-id {
|
||||||
|
color: color-mix(in srgb, var(--color-text) 55%, transparent);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-evento__risorsa-origine {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
color: var(--color-neutral-800);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<section class="vista-evento">
|
||||||
|
@if (loading()) {
|
||||||
|
<p>Caricamento evento...</p>
|
||||||
|
} @else if (loadError(); as message) {
|
||||||
|
<p class="vista-evento__error" role="alert">{{ message }}</p>
|
||||||
|
} @else if (evento(); as ev) {
|
||||||
|
<div class="vista-evento__card">
|
||||||
|
<div class="vista-evento__header">
|
||||||
|
<div>
|
||||||
|
<h1>{{ ev.titolo }}</h1>
|
||||||
|
<p class="vista-evento__meta">
|
||||||
|
<span class="vista-evento__tag">{{ ev.tipo }}</span>
|
||||||
|
<span class="vista-evento__tag">{{ ev.brancaId }}</span>
|
||||||
|
@if (ev.parentId) {
|
||||||
|
<span class="vista-evento__tag">sotto-evento</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vista-evento__azioni">
|
||||||
|
@if (puoGestireEvento()) {
|
||||||
|
<button mat-stroked-button type="button" (click)="vaiAModifica()">Modifica</button>
|
||||||
|
}
|
||||||
|
@if (puoGestireEvento()) {
|
||||||
|
<button
|
||||||
|
mat-flat-button
|
||||||
|
color="primary"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!puoAggiungereSottoEvento()"
|
||||||
|
[attr.title]="ev.parentId ? 'Un sotto-evento non può avere a sua volta sotto-eventi' : null"
|
||||||
|
(click)="vaiASottoEvento()"
|
||||||
|
>
|
||||||
|
Aggiungi sotto-evento
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (ev.descrizione) {
|
||||||
|
<p class="vista-evento__descrizione">{{ ev.descrizione }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<dl class="vista-evento__dettagli">
|
||||||
|
<div>
|
||||||
|
<dt>Inizio</dt>
|
||||||
|
<dd>{{ formatData(ev.dataInizio) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Fine</dt>
|
||||||
|
<dd>{{ formatData(ev.dataFine) }}</dd>
|
||||||
|
</div>
|
||||||
|
@if (ev.location) {
|
||||||
|
<div>
|
||||||
|
<dt>Location</dt>
|
||||||
|
<dd>{{ ev.location }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<h2>Timeline</h2>
|
||||||
|
<div class="vista-evento__timeline">
|
||||||
|
@for (giorno of timeline(); track giorno.data) {
|
||||||
|
<div class="timeline-giorno">
|
||||||
|
<span class="timeline-giorno__etichetta">{{ giorno.etichetta }}</span>
|
||||||
|
|
||||||
|
<div class="timeline-giorno__corsia" [title]="giorno.genitore.orarioLabel">
|
||||||
|
<div
|
||||||
|
class="timeline-blocco timeline-blocco--genitore"
|
||||||
|
[style.left.%]="giorno.genitore.inizioPercento"
|
||||||
|
[style.width.%]="giorno.genitore.larghezzaPercento"
|
||||||
|
[style.background-color]="giorno.genitore.colore"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (figlio of giorno.figli; track figlio.id) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="timeline-giorno__corsia timeline-giorno__corsia--figlio"
|
||||||
|
[title]="figlio.titolo + ' · ' + figlio.orarioLabel"
|
||||||
|
(click)="vaiAEvento(figlio.id)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="timeline-blocco"
|
||||||
|
[style.left.%]="figlio.inizioPercento"
|
||||||
|
[style.width.%]="figlio.larghezzaPercento"
|
||||||
|
[style.background-color]="figlio.colore"
|
||||||
|
>
|
||||||
|
{{ figlio.titolo }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Risorse collegate</h2>
|
||||||
|
@if (ev.risorseCollegate.length === 0) {
|
||||||
|
<p class="vista-evento__vuoto">Nessuna risorsa collegata.</p>
|
||||||
|
} @else {
|
||||||
|
<ul class="vista-evento__risorse">
|
||||||
|
@for (risorsa of ev.risorseCollegate; track risorsa.id) {
|
||||||
|
<li class="vista-evento__risorsa">
|
||||||
|
<span class="vista-evento__risorsa-tipo">{{ risorsa.tipoRisorsa }}</span>
|
||||||
|
<span class="vista-evento__risorsa-id">{{ risorsa.risorsaId }}</span>
|
||||||
|
<span class="vista-evento__risorsa-origine">{{ risorsa.servizioOrigine }}</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
import { requireBrancaAccessEventoGuard, requireCreaEventoGuard } from '../core/auth/require-branca-access.guard';
|
||||||
|
|
||||||
|
export const VISTA_EVENTO_ROUTES: Routes = [
|
||||||
|
{
|
||||||
|
// Deve precedere ':id': altrimenti "nuovo" verrebbe interpretato come id evento.
|
||||||
|
path: 'nuovo',
|
||||||
|
loadComponent: () => import('./evento-form/evento-form').then((m) => m.EventoForm),
|
||||||
|
canActivate: [requireCreaEventoGuard]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id/modifica',
|
||||||
|
loadComponent: () => import('./evento-form/evento-form').then((m) => m.EventoForm),
|
||||||
|
canActivate: [requireBrancaAccessEventoGuard]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id/sotto-evento',
|
||||||
|
loadComponent: () => import('./evento-form/evento-form').then((m) => m.EventoForm),
|
||||||
|
canActivate: [requireBrancaAccessEventoGuard]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':id',
|
||||||
|
loadComponent: () => import('./vista-evento').then((m) => m.VistaEvento)
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
import { Observable, of, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
import { EventiApiService, EventoDettaglioView } from '../core/eventi-api.service';
|
||||||
|
import { VistaEvento } from './vista-evento';
|
||||||
|
|
||||||
|
describe('VistaEvento', () => {
|
||||||
|
let component: VistaEvento;
|
||||||
|
let fixture: ComponentFixture<VistaEvento>;
|
||||||
|
let api: { getEvento: ReturnType<typeof vi.fn> };
|
||||||
|
let router: { navigate: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const eventoBase: EventoDettaglioView = {
|
||||||
|
id: 'evento-1',
|
||||||
|
orgId: 'org-1',
|
||||||
|
brancaId: 'Lupetti',
|
||||||
|
parentId: null,
|
||||||
|
titolo: 'Campo estivo',
|
||||||
|
descrizione: 'Tre giorni sul campo',
|
||||||
|
dataInizio: '2026-07-10T08:00:00.000Z',
|
||||||
|
dataFine: '2026-07-11T18:00:00.000Z',
|
||||||
|
tipo: 'campo',
|
||||||
|
location: 'Bosco',
|
||||||
|
creatoDa: 'user-1',
|
||||||
|
creatoIl: '2026-06-01T00:00:00.000Z',
|
||||||
|
figli: [
|
||||||
|
{
|
||||||
|
id: 'evento-2',
|
||||||
|
orgId: 'org-1',
|
||||||
|
brancaId: 'Lupetti',
|
||||||
|
parentId: 'evento-1',
|
||||||
|
titolo: 'Laboratorio',
|
||||||
|
descrizione: null,
|
||||||
|
dataInizio: '2026-07-10T10:00:00.000Z',
|
||||||
|
dataFine: '2026-07-10T12:00:00.000Z',
|
||||||
|
tipo: 'attivita',
|
||||||
|
location: null,
|
||||||
|
creatoDa: 'user-1',
|
||||||
|
creatoIl: '2026-06-01T00:00:00.000Z'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
risorseCollegate: [
|
||||||
|
{
|
||||||
|
id: 'risorsa-1',
|
||||||
|
tipoRisorsa: 'attivita',
|
||||||
|
risorsaId: 'attivita-9',
|
||||||
|
servizioOrigine: 'scouthub-attivita-be',
|
||||||
|
metadata: null,
|
||||||
|
creatoIl: '2026-06-02T00:00:00.000Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
async function setup(options: {
|
||||||
|
evento?: Observable<EventoDettaglioView>;
|
||||||
|
id?: string;
|
||||||
|
roles?: string[];
|
||||||
|
groups?: string[];
|
||||||
|
} = {}): Promise<void> {
|
||||||
|
api = { getEvento: vi.fn().mockReturnValue(options.evento ?? of(eventoBase)) };
|
||||||
|
router = { navigate: vi.fn() };
|
||||||
|
const keycloakMock = {
|
||||||
|
tokenParsed: {
|
||||||
|
realm_access: { roles: options.roles ?? [] },
|
||||||
|
groups: options.groups ?? []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [VistaEvento],
|
||||||
|
providers: [
|
||||||
|
{ provide: EventiApiService, useValue: api },
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: { snapshot: { paramMap: { get: () => options.id ?? 'evento-1' } } }
|
||||||
|
},
|
||||||
|
{ provide: Keycloak, useValue: keycloakMock }
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(VistaEvento);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('carica il dettaglio evento', async () => {
|
||||||
|
await setup();
|
||||||
|
expect(api.getEvento).toHaveBeenCalledWith('evento-1');
|
||||||
|
expect(component.evento()?.titolo).toBe('Campo estivo');
|
||||||
|
expect(component.loading()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('costruisce una riga di timeline per ogni giorno tra inizio e fine', async () => {
|
||||||
|
await setup();
|
||||||
|
const timeline = component.timeline();
|
||||||
|
expect(timeline.map((g) => g.data)).toEqual(['2026-07-10', '2026-07-11']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mostra il figlio nella timeline solo nel giorno in cui ricade', async () => {
|
||||||
|
await setup();
|
||||||
|
const timeline = component.timeline();
|
||||||
|
const giorno10 = timeline.find((g) => g.data === '2026-07-10')!;
|
||||||
|
const giorno11 = timeline.find((g) => g.data === '2026-07-11')!;
|
||||||
|
|
||||||
|
expect(giorno10.figli.map((f) => f.id)).toEqual(['evento-2']);
|
||||||
|
expect(giorno11.figli).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('naviga al form di modifica', async () => {
|
||||||
|
await setup();
|
||||||
|
component.vaiAModifica();
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/vista-evento', 'evento-1', 'modifica']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('naviga al form sotto-evento', async () => {
|
||||||
|
await setup();
|
||||||
|
component.vaiASottoEvento();
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/vista-evento', 'evento-1', 'sotto-evento']);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gating "aggiungi sotto-evento"', () => {
|
||||||
|
it('visibile e abilitato per capo-gruppo su un evento radice', async () => {
|
||||||
|
await setup({ roles: ['capo-gruppo'] });
|
||||||
|
expect(component.puoGestireEvento()).toBe(true);
|
||||||
|
expect(component.puoAggiungereSottoEvento()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('visibile e abilitato per capo-unita della branca dell\'evento', async () => {
|
||||||
|
await setup({ roles: ['capo-unita'], groups: ['/Lupetti'] });
|
||||||
|
expect(component.puoAggiungereSottoEvento()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nascosto per capo-unita di un\'altra branca', async () => {
|
||||||
|
await setup({ roles: ['capo-unita'], groups: ['/Esploratori'] });
|
||||||
|
expect(component.puoGestireEvento()).toBe(false);
|
||||||
|
expect(component.puoAggiungereSottoEvento()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabilitato se l\'evento corrente è già un sotto-evento (max due livelli)', async () => {
|
||||||
|
const figlio: EventoDettaglioView = { ...eventoBase, id: 'evento-2', parentId: 'evento-1', figli: [] };
|
||||||
|
await setup({ evento: of(figlio), id: 'evento-2', roles: ['capo-gruppo'] });
|
||||||
|
|
||||||
|
expect(component.puoGestireEvento()).toBe(true);
|
||||||
|
expect(component.puoAggiungereSottoEvento()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mostra un messaggio dedicato se l\'evento non esiste (404)', async () => {
|
||||||
|
await setup({ evento: throwError(() => new HttpErrorResponse({ status: 404 })) });
|
||||||
|
expect(component.loadError()).toBe('Evento non trovato.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { Component, OnInit, computed, inject, signal } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { extractAuthContext } from '../core/auth/auth-context';
|
||||||
|
import { hasBrancaAccess } from '../core/auth/branca-access';
|
||||||
|
import { coloreTipo } from '../core/utils/colore-tipo';
|
||||||
|
import { EventiApiService, EventoDettaglioView, EventoView } from '../core/eventi-api.service';
|
||||||
|
|
||||||
|
interface BloccoTimeline {
|
||||||
|
id: string;
|
||||||
|
titolo: string;
|
||||||
|
colore: string;
|
||||||
|
inizioPercento: number;
|
||||||
|
larghezzaPercento: number;
|
||||||
|
orarioLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiornoTimeline {
|
||||||
|
data: string;
|
||||||
|
etichetta: string;
|
||||||
|
genitore: BloccoTimeline;
|
||||||
|
figli: BloccoTimeline[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MS_GIORNO = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-vista-evento',
|
||||||
|
imports: [MatButtonModule, MatCardModule],
|
||||||
|
templateUrl: './vista-evento.html',
|
||||||
|
styleUrl: './vista-evento.css'
|
||||||
|
})
|
||||||
|
export class VistaEvento implements OnInit {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly api = inject(EventiApiService);
|
||||||
|
private readonly keycloak = inject(Keycloak);
|
||||||
|
|
||||||
|
private readonly eventoId = this.route.snapshot.paramMap.get('id') ?? '';
|
||||||
|
|
||||||
|
readonly loading = signal(true);
|
||||||
|
readonly loadError = signal<string | null>(null);
|
||||||
|
readonly evento = signal<EventoDettaglioView | null>(null);
|
||||||
|
|
||||||
|
readonly timeline = computed<GiornoTimeline[]>(() => {
|
||||||
|
const ev = this.evento();
|
||||||
|
return ev ? this.costruisciTimeline(ev) : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nascosto/disabilitato per chi non ha accesso alla branca: la verifica
|
||||||
|
// autoritativa resta comunque lato backend (assertBrancaAccess su
|
||||||
|
// POST/PUT /eventi), qui è solo UX.
|
||||||
|
readonly puoGestireEvento = computed(() => {
|
||||||
|
const ev = this.evento();
|
||||||
|
if (!ev) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auth = extractAuthContext(this.keycloak.tokenParsed);
|
||||||
|
return hasBrancaAccess(auth, ev.brancaId);
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly puoAggiungereSottoEvento = computed(() => {
|
||||||
|
const ev = this.evento();
|
||||||
|
return this.puoGestireEvento() && !!ev && ev.parentId === null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
await this.carica();
|
||||||
|
}
|
||||||
|
|
||||||
|
vaiAEvento(id: string): void {
|
||||||
|
this.router.navigate(['/vista-evento', id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
vaiAModifica(): void {
|
||||||
|
this.router.navigate(['/vista-evento', this.eventoId, 'modifica']);
|
||||||
|
}
|
||||||
|
|
||||||
|
vaiASottoEvento(): void {
|
||||||
|
this.router.navigate(['/vista-evento', this.eventoId, 'sotto-evento']);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatData(dataIso: string): string {
|
||||||
|
return new Date(dataIso).toLocaleString('it-IT', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async carica(): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.loadError.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const evento = await firstValueFrom(this.api.getEvento(this.eventoId));
|
||||||
|
this.evento.set(evento);
|
||||||
|
} catch (err) {
|
||||||
|
this.loadError.set(this.messaggioErrore(err as HttpErrorResponse));
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private messaggioErrore(err: HttpErrorResponse): string {
|
||||||
|
if (err.status === 404) {
|
||||||
|
return 'Evento non trovato.';
|
||||||
|
}
|
||||||
|
return 'Impossibile caricare l\'evento. Riprova più tardi.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private costruisciTimeline(evento: EventoDettaglioView): GiornoTimeline[] {
|
||||||
|
const inizio = new Date(evento.dataInizio);
|
||||||
|
const fine = new Date(evento.dataFine);
|
||||||
|
const giornoInizio = new Date(inizio.getFullYear(), inizio.getMonth(), inizio.getDate());
|
||||||
|
const giornoFine = new Date(fine.getFullYear(), fine.getMonth(), fine.getDate());
|
||||||
|
|
||||||
|
const giorni: GiornoTimeline[] = [];
|
||||||
|
const cursor = new Date(giornoInizio);
|
||||||
|
while (cursor <= giornoFine) {
|
||||||
|
const giorno = new Date(cursor);
|
||||||
|
|
||||||
|
giorni.push({
|
||||||
|
data: this.isoDate(giorno),
|
||||||
|
etichetta: giorno.toLocaleDateString('it-IT', { weekday: 'short', day: 'numeric', month: 'long' }),
|
||||||
|
genitore: this.buildBlocco(evento, giorno),
|
||||||
|
figli: evento.figli.filter((figlio) => this.toccaGiorno(figlio, giorno)).map((figlio) => this.buildBlocco(figlio, giorno))
|
||||||
|
});
|
||||||
|
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return giorni;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildBlocco(evento: EventoView, giorno: Date): BloccoTimeline {
|
||||||
|
const inizioPercento = this.frazioneGiorno(evento.dataInizio, giorno);
|
||||||
|
const finePercento = this.frazioneGiorno(evento.dataFine, giorno);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: evento.id,
|
||||||
|
titolo: evento.titolo,
|
||||||
|
colore: coloreTipo(evento.tipo),
|
||||||
|
inizioPercento,
|
||||||
|
larghezzaPercento: Math.max(finePercento - inizioPercento, 2),
|
||||||
|
orarioLabel: `${this.formatOra(evento.dataInizio)}–${this.formatOra(evento.dataFine)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toccaGiorno(evento: EventoView, giorno: Date): boolean {
|
||||||
|
const inizio = new Date(evento.dataInizio);
|
||||||
|
const fine = new Date(evento.dataFine);
|
||||||
|
const inizioGiorno = new Date(giorno.getFullYear(), giorno.getMonth(), giorno.getDate());
|
||||||
|
const fineGiorno = new Date(giorno.getFullYear(), giorno.getMonth(), giorno.getDate(), 23, 59, 59, 999);
|
||||||
|
return inizio <= fineGiorno && fine >= inizioGiorno;
|
||||||
|
}
|
||||||
|
|
||||||
|
private frazioneGiorno(dataIso: string, giorno: Date): number {
|
||||||
|
const data = new Date(dataIso);
|
||||||
|
const inizioGiorno = new Date(giorno.getFullYear(), giorno.getMonth(), giorno.getDate());
|
||||||
|
const fineGiorno = new Date(giorno.getFullYear(), giorno.getMonth(), giorno.getDate(), 23, 59, 59, 999);
|
||||||
|
const clamped = data < inizioGiorno ? inizioGiorno : data > fineGiorno ? fineGiorno : data;
|
||||||
|
return ((clamped.getTime() - inizioGiorno.getTime()) / MS_GIORNO) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatOra(dataIso: string): string {
|
||||||
|
return new Date(dataIso).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private isoDate(data: Date): string {
|
||||||
|
return `${data.getFullYear()}-${String(data.getMonth() + 1).padStart(2, '0')}-${String(data.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
import { EventoView } from '../core/eventi-api.service';
|
||||||
|
|
||||||
|
export type { EventoView };
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class VistaMensileApiService {
|
||||||
|
private readonly http = inject(HttpClient);
|
||||||
|
|
||||||
|
// Nessun brancaId in query: si carica l'intero mese (lettura trasversale a
|
||||||
|
// tutte le branche dell'org, vedi getVistaMese lato backend quando brancaId
|
||||||
|
// e' assente) e si filtra lato client, cosi' il select "Branca" resta
|
||||||
|
// popolato con tutte le branche del mese anche dopo aver filtrato.
|
||||||
|
getVistaMese(anno: number, mese: number): Observable<EventoView[]> {
|
||||||
|
const meseParam = `${anno}-${String(mese).padStart(2, '0')}`;
|
||||||
|
return this.http.get<EventoView[]>(`${environment.eventiApiBaseUrl}/eventi`, {
|
||||||
|
params: { vista: 'mese', mese: meseParam }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
.vista-mensile__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-mensile__navigazione {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-mensile__navigazione h2 {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 14ch;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-mensile__azioni {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-mensile__azioni mat-form-field {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-mensile__stato {
|
||||||
|
margin: 0 0 var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-mensile__stato--errore {
|
||||||
|
color: var(--mat-sys-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-mensile__calendario {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-mensile__intestazione-settimana {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vista-mensile__griglia {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.giorno {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-height: 110px;
|
||||||
|
padding: 6px;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.giorno--fuori-mese {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.giorno__numero {
|
||||||
|
align-self: flex-end;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.giorno__eventi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-blocco {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: none;
|
||||||
|
border-left: 3px solid;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 3px 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-blocco:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-text) 6%, var(--color-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-blocco__ora {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--color-neutral-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-blocco__titolo {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evento-blocco__figli {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
padding-left: var(--space-2);
|
||||||
|
border-left: 1px dashed var(--color-divider);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<div class="vista-mensile">
|
||||||
|
<div class="vista-mensile__header">
|
||||||
|
<div class="vista-mensile__navigazione">
|
||||||
|
<button mat-icon-button (click)="mesePrecedente()" aria-label="Mese precedente">
|
||||||
|
<mat-icon>chevron_left</mat-icon>
|
||||||
|
</button>
|
||||||
|
<h2>{{ nomiMesi[mese() - 1] }} {{ anno() }}</h2>
|
||||||
|
<button mat-icon-button (click)="meseSuccessivo()" aria-label="Mese successivo">
|
||||||
|
<mat-icon>chevron_right</mat-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vista-mensile__azioni">
|
||||||
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||||
|
<mat-label>Branca</mat-label>
|
||||||
|
<mat-select [value]="filtroBranca()" (selectionChange)="filtroBranca.set($event.value)">
|
||||||
|
<mat-option [value]="null">Tutte</mat-option>
|
||||||
|
@for (branca of branche(); track branca) {
|
||||||
|
<mat-option [value]="branca">{{ branca }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
|
@if (puoCreareEvento()) {
|
||||||
|
<button mat-flat-button color="primary" (click)="creaEvento()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Crea evento
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<p class="vista-mensile__stato">Caricamento eventi...</p>
|
||||||
|
}
|
||||||
|
@if (error()) {
|
||||||
|
<p class="vista-mensile__stato vista-mensile__stato--errore">{{ error() }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="vista-mensile__calendario">
|
||||||
|
<div class="vista-mensile__intestazione-settimana">
|
||||||
|
@for (nomeGiorno of giorniSettimana; track nomeGiorno) {
|
||||||
|
<span>{{ nomeGiorno }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vista-mensile__griglia">
|
||||||
|
@for (settimana of settimane(); track $index) {
|
||||||
|
@for (giorno of settimana; track giorno.data.getTime()) {
|
||||||
|
<div class="giorno" [class.giorno--fuori-mese]="giorno.fuoriMese">
|
||||||
|
<span class="giorno__numero">{{ giorno.numero }}</span>
|
||||||
|
<div class="giorno__eventi">
|
||||||
|
@for (nodo of giorno.radici; track nodo.evento.id) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="evento-blocco"
|
||||||
|
[style.border-left-color]="coloreEvento(nodo.evento.tipo)"
|
||||||
|
(click)="vaiAEvento(nodo.evento)"
|
||||||
|
>
|
||||||
|
<span class="evento-blocco__ora">{{ formatOra(nodo.evento.dataInizio) }}–{{ formatOra(nodo.evento.dataFine) }}</span>
|
||||||
|
<span class="evento-blocco__titolo">{{ nodo.evento.titolo }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (nodo.figli.length > 0) {
|
||||||
|
<div class="evento-blocco__figli">
|
||||||
|
@for (figlio of nodo.figli; track figlio.id) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="evento-blocco evento-blocco--figlio"
|
||||||
|
[style.border-left-color]="coloreEvento(figlio.tipo)"
|
||||||
|
(click)="vaiAEvento(figlio)"
|
||||||
|
>
|
||||||
|
<span class="evento-blocco__ora">{{ formatOra(figlio.dataInizio) }}–{{ formatOra(figlio.dataFine) }}</span>
|
||||||
|
<span class="evento-blocco__titolo">{{ figlio.titolo }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const VISTA_MENSILE_ROUTES: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () => import('./vista-mensile').then((m) => m.VistaMensile)
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
|
||||||
|
import { EventoView, VistaMensileApiService } from './vista-mensile-api.service';
|
||||||
|
import { VistaMensile } from './vista-mensile';
|
||||||
|
|
||||||
|
describe('VistaMensile', () => {
|
||||||
|
let component: VistaMensile;
|
||||||
|
let fixture: ComponentFixture<VistaMensile>;
|
||||||
|
let api: { getVistaMese: ReturnType<typeof vi.fn> };
|
||||||
|
let router: { navigate: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const anno = 2026;
|
||||||
|
const mese = 7;
|
||||||
|
|
||||||
|
const eventoRadice: EventoView = {
|
||||||
|
id: 'evento-1',
|
||||||
|
orgId: 'org-1',
|
||||||
|
brancaId: 'Lupetti',
|
||||||
|
parentId: null,
|
||||||
|
titolo: 'Campo estivo',
|
||||||
|
descrizione: null,
|
||||||
|
dataInizio: '2026-07-10T09:00:00.000Z',
|
||||||
|
dataFine: '2026-07-10T17:00:00.000Z',
|
||||||
|
tipo: 'campo',
|
||||||
|
location: null,
|
||||||
|
creatoDa: 'user-1',
|
||||||
|
creatoIl: '2026-06-01T00:00:00.000Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventoFiglio: EventoView = {
|
||||||
|
...eventoRadice,
|
||||||
|
id: 'evento-2',
|
||||||
|
parentId: 'evento-1',
|
||||||
|
titolo: 'Laboratorio',
|
||||||
|
tipo: 'attivita'
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventoAltraBranca: EventoView = {
|
||||||
|
...eventoRadice,
|
||||||
|
id: 'evento-3',
|
||||||
|
brancaId: 'Esploratori',
|
||||||
|
parentId: null,
|
||||||
|
titolo: 'Uscita reparto'
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildRouteMock(queryParams: Record<string, string> = {}): Partial<ActivatedRoute> {
|
||||||
|
return {
|
||||||
|
snapshot: {
|
||||||
|
queryParamMap: { get: (key: string) => queryParams[key] ?? null }
|
||||||
|
}
|
||||||
|
} as unknown as ActivatedRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(options: {
|
||||||
|
response?: ReturnType<typeof of>;
|
||||||
|
queryParams?: Record<string, string>;
|
||||||
|
roles?: string[];
|
||||||
|
groups?: string[];
|
||||||
|
} = {}): Promise<void> {
|
||||||
|
api = { getVistaMese: vi.fn().mockReturnValue(options.response ?? of([eventoRadice, eventoFiglio, eventoAltraBranca])) };
|
||||||
|
router = { navigate: vi.fn() };
|
||||||
|
const keycloakMock = {
|
||||||
|
tokenParsed: {
|
||||||
|
realm_access: { roles: options.roles ?? [] },
|
||||||
|
groups: options.groups ?? []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [VistaMensile],
|
||||||
|
providers: [
|
||||||
|
{ provide: VistaMensileApiService, useValue: api },
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
{ provide: ActivatedRoute, useValue: buildRouteMock(options.queryParams) },
|
||||||
|
{ provide: Keycloak, useValue: keycloakMock }
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(VistaMensile);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('carica il mese corrente di default', async () => {
|
||||||
|
await setup();
|
||||||
|
const oggi = new Date();
|
||||||
|
expect(api.getVistaMese).toHaveBeenCalledWith(oggi.getFullYear(), oggi.getMonth() + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legge anno e mese iniziali dai query params', async () => {
|
||||||
|
await setup({ queryParams: { anno: String(anno), mese: String(mese) } });
|
||||||
|
expect(component.anno()).toBe(anno);
|
||||||
|
expect(component.mese()).toBe(mese);
|
||||||
|
expect(api.getVistaMese).toHaveBeenCalledWith(anno, mese);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('raggruppa gli eventi figli sotto il genitore nello stesso giorno', async () => {
|
||||||
|
await setup({ queryParams: { anno: String(anno), mese: String(mese) } });
|
||||||
|
|
||||||
|
const settimane = component.settimane();
|
||||||
|
const giorno10 = settimane.flat().find((g) => g.numero === 10 && !g.fuoriMese);
|
||||||
|
|
||||||
|
expect(giorno10?.radici.length).toBe(2);
|
||||||
|
const radiceCampo = giorno10?.radici.find((n) => n.evento.id === 'evento-1');
|
||||||
|
expect(radiceCampo?.figli.map((f) => f.id)).toEqual(['evento-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('popola le opzioni branca con i valori distinti del mese', async () => {
|
||||||
|
await setup({ queryParams: { anno: String(anno), mese: String(mese) } });
|
||||||
|
expect(component.branche()).toEqual(['Esploratori', 'Lupetti']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filtra gli eventi per branca selezionata', async () => {
|
||||||
|
await setup({ queryParams: { anno: String(anno), mese: String(mese) } });
|
||||||
|
component.filtroBranca.set('Esploratori');
|
||||||
|
|
||||||
|
const settimane = component.settimane();
|
||||||
|
const giorno10 = settimane.flat().find((g) => g.numero === 10 && !g.fuoriMese);
|
||||||
|
expect(giorno10?.radici.map((n) => n.evento.id)).toEqual(['evento-3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('naviga alla vista evento al click su un blocco', async () => {
|
||||||
|
await setup({ queryParams: { anno: String(anno), mese: String(mese) } });
|
||||||
|
component.vaiAEvento(eventoRadice);
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/vista-evento', 'evento-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('naviga al form di creazione evento con la branca corrente come query param', async () => {
|
||||||
|
await setup({ queryParams: { anno: String(anno), mese: String(mese) } });
|
||||||
|
component.filtroBranca.set('Lupetti');
|
||||||
|
|
||||||
|
component.creaEvento();
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith(['/vista-evento/nuovo'], { queryParams: { branca: 'Lupetti' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cambia mese e aggiorna la query string', async () => {
|
||||||
|
await setup({ queryParams: { anno: String(anno), mese: String(mese) } });
|
||||||
|
api.getVistaMese.mockReturnValue(of([]));
|
||||||
|
|
||||||
|
component.meseSuccessivo();
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(component.mese()).toBe(8);
|
||||||
|
expect(api.getVistaMese).toHaveBeenCalledWith(anno, 8);
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith([], expect.objectContaining({
|
||||||
|
queryParams: { anno, mese: 8 }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('visibilità pulsante "crea evento"', () => {
|
||||||
|
it('nascosto se non è selezionata una branca', async () => {
|
||||||
|
await setup({ queryParams: { anno: String(anno), mese: String(mese) }, roles: ['capo-gruppo'] });
|
||||||
|
expect(component.puoCreareEvento()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('visibile per capo-gruppo su qualunque branca selezionata', async () => {
|
||||||
|
await setup({ queryParams: { anno: String(anno), mese: String(mese) }, roles: ['capo-gruppo'] });
|
||||||
|
component.filtroBranca.set('Esploratori');
|
||||||
|
expect(component.puoCreareEvento()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('visibile per capo-unita solo sulla propria branca', async () => {
|
||||||
|
await setup({
|
||||||
|
queryParams: { anno: String(anno), mese: String(mese) },
|
||||||
|
roles: ['capo-unita'],
|
||||||
|
groups: ['/Lupetti']
|
||||||
|
});
|
||||||
|
component.filtroBranca.set('Lupetti');
|
||||||
|
expect(component.puoCreareEvento()).toBe(true);
|
||||||
|
|
||||||
|
component.filtroBranca.set('Esploratori');
|
||||||
|
expect(component.puoCreareEvento()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nascosto per censito', async () => {
|
||||||
|
await setup({ queryParams: { anno: String(anno), mese: String(mese) }, roles: ['censito'], groups: ['/Lupetti'] });
|
||||||
|
component.filtroBranca.set('Lupetti');
|
||||||
|
expect(component.puoCreareEvento()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mostra un messaggio dedicato se manca un\'organizzazione attiva (403)', async () => {
|
||||||
|
await setup({ response: throwError(() => new HttpErrorResponse({ status: 403 })) });
|
||||||
|
expect(component.error()).toBe('Nessuna organizzazione attiva sul tuo account: impossibile caricare gli eventi.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import Keycloak from 'keycloak-js';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import { extractAuthContext } from '../core/auth/auth-context';
|
||||||
|
import { hasBrancaAccess } from '../core/auth/branca-access';
|
||||||
|
import { coloreTipo } from '../core/utils/colore-tipo';
|
||||||
|
import { NOMI_MESI } from '../core/utils/nomi-mesi';
|
||||||
|
import { EventoView, VistaMensileApiService } from './vista-mensile-api.service';
|
||||||
|
|
||||||
|
interface NodoEvento {
|
||||||
|
evento: EventoView;
|
||||||
|
figli: EventoView[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiornoCalendario {
|
||||||
|
data: Date;
|
||||||
|
numero: number;
|
||||||
|
fuoriMese: boolean;
|
||||||
|
radici: NodoEvento[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-vista-mensile',
|
||||||
|
imports: [MatButtonModule, MatFormFieldModule, MatIconModule, MatSelectModule],
|
||||||
|
templateUrl: './vista-mensile.html',
|
||||||
|
styleUrl: './vista-mensile.css'
|
||||||
|
})
|
||||||
|
export class VistaMensile {
|
||||||
|
private readonly api = inject(VistaMensileApiService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly keycloak = inject(Keycloak);
|
||||||
|
|
||||||
|
readonly giorniSettimana = ['Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab', 'Dom'];
|
||||||
|
readonly nomiMesi = NOMI_MESI;
|
||||||
|
|
||||||
|
readonly anno = signal(this.leggiParamIniziale('anno') ?? new Date().getFullYear());
|
||||||
|
readonly mese = signal(this.leggiParamIniziale('mese') ?? new Date().getMonth() + 1);
|
||||||
|
|
||||||
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
private readonly eventiMese = signal<EventoView[]>([]);
|
||||||
|
|
||||||
|
readonly filtroBranca = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly branche = computed(() => Array.from(new Set(this.eventiMese().map((e) => e.brancaId))).sort());
|
||||||
|
|
||||||
|
readonly eventiFiltrati = computed(() => {
|
||||||
|
const branca = this.filtroBranca();
|
||||||
|
const eventi = this.eventiMese();
|
||||||
|
return branca ? eventi.filter((e) => e.brancaId === branca) : eventi;
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly settimane = computed(() => this.costruisciSettimane(this.anno(), this.mese(), this.eventiFiltrati()));
|
||||||
|
|
||||||
|
// Visibile solo con una branca corrente selezionata: capo-gruppo può creare
|
||||||
|
// in qualunque branca dell'org, capo-unita/capo solo nelle branche a cui
|
||||||
|
// appartiene (claim "groups" del token, vedi assertBrancaAccess lato backend).
|
||||||
|
readonly puoCreareEvento = computed(() => {
|
||||||
|
const branca = this.filtroBranca();
|
||||||
|
if (!branca) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasBrancaAccess(extractAuthContext(this.keycloak.tokenParsed), branca);
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const anno = this.anno();
|
||||||
|
const mese = this.mese();
|
||||||
|
void this.caricaMese(anno, mese);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
coloreEvento(tipo: string): string {
|
||||||
|
return coloreTipo(tipo);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatOra(dataIso: string): string {
|
||||||
|
return new Date(dataIso).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
mesePrecedente(): void {
|
||||||
|
this.spostaMese(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
meseSuccessivo(): void {
|
||||||
|
this.spostaMese(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
vaiAEvento(evento: EventoView): void {
|
||||||
|
this.router.navigate(['/vista-evento', evento.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
creaEvento(): void {
|
||||||
|
this.router.navigate(['/vista-evento/nuovo'], { queryParams: { branca: this.filtroBranca() } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private spostaMese(delta: number): void {
|
||||||
|
const data = new Date(this.anno(), this.mese() - 1 + delta, 1);
|
||||||
|
this.anno.set(data.getFullYear());
|
||||||
|
this.mese.set(data.getMonth() + 1);
|
||||||
|
|
||||||
|
void this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { anno: this.anno(), mese: this.mese() },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
replaceUrl: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private leggiParamIniziale(nome: 'anno' | 'mese'): number | null {
|
||||||
|
const valore = this.route.snapshot.queryParamMap.get(nome);
|
||||||
|
const numero = valore ? Number(valore) : NaN;
|
||||||
|
return Number.isInteger(numero) ? numero : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async caricaMese(anno: number, mese: number): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const risultato = await firstValueFrom(this.api.getVistaMese(anno, mese));
|
||||||
|
if (this.anno() !== anno || this.mese() !== mese) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.eventiMese.set(risultato);
|
||||||
|
this.filtroBranca.set(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (this.anno() !== anno || this.mese() !== mese) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.eventiMese.set([]);
|
||||||
|
this.error.set(this.messaggioErrore(err as HttpErrorResponse));
|
||||||
|
} finally {
|
||||||
|
if (this.anno() === anno && this.mese() === mese) {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private messaggioErrore(err: HttpErrorResponse): string {
|
||||||
|
if (err.status === 403) {
|
||||||
|
return 'Nessuna organizzazione attiva sul tuo account: impossibile caricare gli eventi.';
|
||||||
|
}
|
||||||
|
return 'Impossibile caricare gli eventi. Riprova più tardi.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private costruisciSettimane(anno: number, mese: number, eventi: EventoView[]): GiornoCalendario[][] {
|
||||||
|
const primoGiornoMese = new Date(anno, mese - 1, 1);
|
||||||
|
const ultimoGiornoMese = new Date(anno, mese, 0);
|
||||||
|
const offsetInizio = (primoGiornoMese.getDay() + 6) % 7; // lun=0..dom=6
|
||||||
|
const offsetFine = 6 - ((ultimoGiornoMese.getDay() + 6) % 7);
|
||||||
|
|
||||||
|
const inizioGriglia = new Date(anno, mese - 1, 1 - offsetInizio);
|
||||||
|
const fineGriglia = new Date(anno, mese - 1, ultimoGiornoMese.getDate() + offsetFine);
|
||||||
|
|
||||||
|
const figliPerParent = new Map<string, EventoView[]>();
|
||||||
|
for (const evento of eventi) {
|
||||||
|
if (evento.parentId) {
|
||||||
|
const lista = figliPerParent.get(evento.parentId) ?? [];
|
||||||
|
lista.push(evento);
|
||||||
|
figliPerParent.set(evento.parentId, lista);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const radici = eventi.filter((e) => e.parentId === null).sort((a, b) => a.dataInizio.localeCompare(b.dataInizio));
|
||||||
|
|
||||||
|
const giorni: GiornoCalendario[] = [];
|
||||||
|
const cursor = new Date(inizioGriglia);
|
||||||
|
while (cursor <= fineGriglia) {
|
||||||
|
const giorno = new Date(cursor);
|
||||||
|
|
||||||
|
const radiciDelGiorno = radici
|
||||||
|
.filter((radice) => this.toccaGiorno(radice, giorno))
|
||||||
|
.map((radice) => ({
|
||||||
|
evento: radice,
|
||||||
|
figli: (figliPerParent.get(radice.id) ?? [])
|
||||||
|
.filter((figlio) => this.toccaGiorno(figlio, giorno))
|
||||||
|
.sort((a, b) => a.dataInizio.localeCompare(b.dataInizio))
|
||||||
|
}));
|
||||||
|
|
||||||
|
giorni.push({
|
||||||
|
data: giorno,
|
||||||
|
numero: giorno.getDate(),
|
||||||
|
fuoriMese: giorno.getMonth() !== mese - 1,
|
||||||
|
radici: radiciDelGiorno
|
||||||
|
});
|
||||||
|
|
||||||
|
cursor.setDate(cursor.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settimane: GiornoCalendario[][] = [];
|
||||||
|
for (let i = 0; i < giorni.length; i += 7) {
|
||||||
|
settimane.push(giorni.slice(i, i + 7));
|
||||||
|
}
|
||||||
|
return settimane;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toccaGiorno(evento: EventoView, giorno: Date): boolean {
|
||||||
|
const inizio = new Date(evento.dataInizio);
|
||||||
|
const fine = new Date(evento.dataFine);
|
||||||
|
const inizioGiorno = new Date(giorno.getFullYear(), giorno.getMonth(), giorno.getDate());
|
||||||
|
const fineGiorno = new Date(giorno.getFullYear(), giorno.getMonth(), giorno.getDate(), 23, 59, 59, 999);
|
||||||
|
return inizio <= fineGiorno && fine >= inizioGiorno;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
keycloakBaseUrl: 'http://localhost:6999',
|
||||||
|
keycloakRealm: 'scouthub',
|
||||||
|
keycloakClientId: 'scouthub-frontend',
|
||||||
|
eventiApiBaseUrl: 'http://localhost:8003'
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
keycloakBaseUrl: '__KEYCLOAK_BASE_URL__',
|
||||||
|
keycloakRealm: 'scouthub',
|
||||||
|
keycloakClientId: 'scouthub-frontend',
|
||||||
|
eventiApiBaseUrl: '__EVENTI_API_BASE_URL__'
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>ScouthubEventiFe</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:wght@400&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,64 @@
|
|||||||
|
// 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.$orange-palette,
|
||||||
|
tertiary: mat.$chartreuse-palette,
|
||||||
|
),
|
||||||
|
typography: 'Figtree, system-ui, sans-serif',
|
||||||
|
density: 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Angoli morbidi e pillole, coerenti col design system "Organic".
|
||||||
|
@include mat.card-overrides(
|
||||||
|
(
|
||||||
|
elevated-container-shape: 28px,
|
||||||
|
outlined-container-shape: 28px,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
@include mat.button-overrides(
|
||||||
|
(
|
||||||
|
filled-container-shape: 999px,
|
||||||
|
outlined-container-shape: 999px,
|
||||||
|
protected-container-shape: 999px,
|
||||||
|
text-container-shape: 999px,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
@include mat.icon-button-overrides(
|
||||||
|
(
|
||||||
|
container-shape: 999px,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
@include mat.form-field-overrides(
|
||||||
|
(
|
||||||
|
outlined-container-shape: 999px,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,75 @@
|
|||||||
|
/* Design tokens "Organic" — palette calda, tipografia Caprasimo/Figtree. */
|
||||||
|
:root {
|
||||||
|
--color-bg: #f5ead8;
|
||||||
|
--color-surface: #ebddc5;
|
||||||
|
--color-text: #201e1d;
|
||||||
|
--color-accent: #c67139;
|
||||||
|
--color-accent-2: #7a8a5e;
|
||||||
|
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
|
||||||
|
|
||||||
|
--color-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-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.12;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: color-mix(in srgb, var(--color-text) 55%, transparent);
|
||||||
|
}
|
||||||
@@ -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