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