Add scouthub-eventi-fe

This commit is contained in:
Lorenzo Sanesi
2026-07-25 12:03:40 +02:00
parent 08271b8a15
commit 6a55cfa991
56 changed files with 11742 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+45
View File
@@ -0,0 +1,45 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
/.vscode/
+12
View File
@@ -0,0 +1,12 @@
{
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
}
+23
View File
@@ -0,0 +1,23 @@
FROM node:22-bookworm-slim AS build
ARG KEYCLOAK_BASE_URL
ARG 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
+172
View File
@@ -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
```
+80
View File
@@ -0,0 +1,80 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"scouthub-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"
}
}
}
}
}
+10
View File
@@ -0,0 +1,10 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "scouthub-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>
+33
View File
@@ -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]))
]
};
View File
+1
View File
@@ -0,0 +1 @@
<router-outlet />
+30
View File
@@ -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)
}
]
}
];
+16
View File
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});
+10
View File
@@ -0,0 +1,10 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.css'
})
export class App {}
@@ -0,0 +1,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__'
};
+20
View File
@@ -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>
+6
View File
@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
@@ -0,0 +1,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%;
}
+75
View File
@@ -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);
}
+14
View File
@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
}
+31
View File
@@ -0,0 +1,31 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+15
View File
@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"vitest/globals"
]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
}