diff --git a/.gitignore b/.gitignore index 7df29fe..bac12d3 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,12 @@ dist-ssr # Optional .vercel # Supabase CLI local state -supabase/.temp/ \ No newline at end of file +supabase/.temp/ + +# Backup locali del database (contengono dati reali degli utenti): +# ignora tutto il contenuto generato, ma tieni in git lo script e il README. +backups/* +!backups/backup.sh +!backups/download-storage.mjs +!backups/storage-core.ts +!backups/README.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index b5111c9..c439835 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone npm run dev # vite dev su http://localhost:8080 npm run build # build di produzione (nitro) npm run lint # eslint (include prettier come regola) +npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file) npm run format # prettier --write . npm run test # suite di test (test/); npm run test:all per quella completa @@ -59,7 +60,7 @@ copre più il comportamento nuovo va aggiornata nello stesso lavoro. Prima di dire che hai finito: 1. i test delle funzioni aggiunte o modificate esistono e sono verdi; -2. `npm run lint` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e); +2. `npm run lint`, `npm run typecheck` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e); 3. la documentazione toccata dalla modifica è aggiornata (vedi Test e Tracciabilità); 4. hai detto all'utente cosa hai cambiato, cosa hai lasciato fuori e quali rischi vedi. diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7fce601..1a3ce87 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,6 +1,6 @@ # Project State -Ultimo aggiornamento: 09/09/2026 +Ultimo aggiornamento: 24/09/2026 ## Stato generale @@ -10,9 +10,10 @@ Backend migrato al nuovo Supabase proprietario. Autenticazione Google, dashboard amministratore e Profilo Giocatore (lato giocatore e lato admin) sono in produzione su `main`. Foto profilo (M6) e Scout Live (M7) non dipendono più da `localStorage`: entrambi ora sincronizzano tra dispositivi tramite Supabase. Le serie di presenze sono calcolate sui dati -reali (M9). Prima versione pre-release rilasciata (0.9.0, vedi `docs/CHANGELOG.md`). Cancellare -un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane da -cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16). +reali (M9). Prima versione pre-release rilasciata (0.9.0); la prima versione stabile, 1.0.0 +con il ruolo allenatore, è pronta ma non ancora pubblicata (vedi `docs/CHANGELOG.md`). +Cancellare un evento pulisce ora a cascata tutte le tabelle collegate (M14) e le righe orfane +da cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16). --- @@ -23,8 +24,8 @@ cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16). - Cursor e Claude Code come ambienti di sviluppo - Vercel configurato; Environment Variables aggiornate al nuovo Supabase (Preview e Production) - Supabase proprietario attivo — Project Ref: `kfkcldwncxqaixetsjes` -- 27 migration in `supabase/migrations/`, fino a `m16_funzione_bonifica_dati_evento_orfani` - (09/09/2026) +- 32 migration in `supabase/migrations/`, fino a `m21_ruolo_allenatore` (24/09/2026), tutte + applicate in produzione (verificato con `npx supabase migration list` il 24/09/2026) - Sviluppo locale verificato con il nuovo Supabase --- @@ -42,6 +43,27 @@ cancellazioni precedenti a M14 sono state bonificate una tantum (M15/M16). - Schema v1.0 e migration da M1 a M16 applicate al nuovo Supabase (`m16_funzione_bonifica_dati_evento_orfani` in produzione dal 09/09/2026, verificata con `npx supabase migration list`) +- Migration `m17_notifiche_utente` (15/09/2026, DD-030): nuova tabella `notifiche_utente` + per il centro notifiche in-app — **in produzione** dal 15/09/2026 (è additiva: non tocca + schema o comportamento esistenti, vedi DD-030). Il codice che la usa (centro notifiche, + route aggiornate) non è ancora deployato: la tabella si riempie già in background +- Migration `m18_nascita_pubblica_giocatori_squadra` (15/09/2026, DD-031): nuova colonna + `giocatori_squadra.nascita`, sincronizzata da `profili_giocatore.data_nascita` — corregge + l'"Invalid Date" in Squadra per chi inserisce la propria nascita dal Profilo invece del + seed storico. **In produzione** dal 15/09/2026, ma il backfill copriva solo il seed + storico: chi aveva già un profilo compilato prima di M18 è rimasto scoperto +- Migration `m19_backfill_nascita_da_profili_esistenti` (15/09/2026, DD-031): completa il + backfill di M18 per tutti i profili già esistenti — **in produzione** (risulta applicata + in `npx supabase migration list` del 24/09/2026) +- Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (24/09/2026, DD-034): ruolo + allenatore (colonna `giocatori_squadra.tipo`, valore `allenatore` di `app_role`, policy e + trigger) — verificate in locale con `npx supabase db reset` e `npm run test:all`, **in + produzione** dal 24/09/2026 (`supabase db push`). Compatibili con la 0.9.2: senza allenatori + registrati non cambiano nulla. Il codice che le usa non è ancora deployato +- Slot `g18` «Beta Tester» impostato come allenatore in produzione il 24/09/2026 (colonna + `tipo`, ruolo assegnato dal trigger di M21). Numero 99 e ruolo «Palleggiatore» + lasciati apposta finché gira la 0.9.2, che lo mostra ancora come giocatore: da svuotare + da `/admin` dopo il deploy del codice nuovo - `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`) più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza diff --git a/README.md b/README.md index 0eb56e9..789bd95 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ npm run dev # http://localhost:8080 ```bash npm run build # build di produzione npm run lint # eslint (include prettier) +npm run typecheck # controllo dei tipi TypeScript npm run test # test unit; npm run test:all per la suite completa ``` diff --git a/backups/README.md b/backups/README.md new file mode 100644 index 0000000..7981cfb --- /dev/null +++ b/backups/README.md @@ -0,0 +1,111 @@ +# Backup di disaster recovery + +Script per scaricare su questo host un backup completo del progetto Supabase: +schema DB, dati, utenti auth e file storage. Serve per poter ripartire in fretta +se il progetto Supabase (o Vercel) venisse perso. + +## Preparazione, passo per passo + +Da fare una volta sola per host (poi il backup si rilancia semplicemente con +`./backups/backup.sh`). + +1. **Installa/aggiorna la CLI Supabase** (via `npx`, non serve installarla a parte): + ```bash + npx supabase --version + ``` +2. **Accedi con il tuo account Supabase**: + ```bash + npx supabase login + ``` + Si apre il browser per l'OAuth; il token resta salvato in locale. +3. **Collega il repo al progetto Supabase** (il `project_id` è già in + `supabase/config.toml`, quindi in genere basta): + ```bash + npx supabase link --project-ref + ``` + Verifica che sia andato a buon fine con `npx supabase projects list` + (il progetto deve comparire con `"linked":true`). +4. **Crea/controlla il file `.env`** nella root del repo (accanto a + `package.json`, non dentro `backups/`) con almeno queste due chiavi: + ``` + VITE_SUPABASE_URL="https://.supabase.co" + SUPABASE_SERVICE_ROLE_KEY="" + ``` + Le trovi su [supabase.com/dashboard](https://supabase.com/dashboard) → progetto → + Project Settings → API. La **service role key** è quella con permessi pieni + (bypassa le RLS): serve solo per scaricare i file dai bucket, non va mai + esposta lato client né committata. +5. **(Solo se userai `--encrypt`) Verifica che gpg sia installato**: + ```bash + gpg --version + ``` + Su Debian/Ubuntu: `sudo apt install gnupg` se manca. + +A questo punto sei pronto per lanciare il backup vero e proprio (vedi sotto). + +## Uso + +```bash +./backups/backup.sh # crea backups/YYYYMMDD_HHMM/ con tutto dentro +./backups/backup.sh --encrypt # come sopra, poi comprime e cifra in un unico + # backups/YYYYMMDD_HHMM.tar.gz.gpg (chiede una + # passphrase) e cancella la cartella in chiaro +``` + +Ogni esecuzione crea una cartella nuova con timestamp: non sovrascrive backup +precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a mano +quando non servono più. + +Se un passaggio fallisce (un dump o anche un solo file dello storage non scaricato), +lo script si ferma con un errore e **non** stampa «Backup completato»: un backup +incompleto non va considerato valido, rilancialo. + +Il backup contiene: + +| File/cartella | Contenuto | +| ------------------- | ---------------------------------------------------------------------------------------------------------- | +| `schema_public.sql` | schema delle tabelle applicative (tabelle, funzioni, RLS) | +| `data_public.sql` | dati delle tabelle applicative | +| `schema_auth.sql` | schema utenti Supabase Auth | +| `data_auth.sql` | utenti reali, incluso l'hash della password | +| `storage/` | tutti i file dei bucket (`avatar-giocatori`, `profili-giocatore`: foto, documenti d'identità, certificati) | + +## Ripristino + +1. Nuovo progetto Supabase (o `supabase start` in locale via Docker). +2. Importa nell'ordine: `schema_public.sql` → `schema_auth.sql` → + `data_auth.sql` → `data_public.sql` (gli utenti auth prima dei dati + pubblici, perché `giocatori.auth_user_id` referenzia `auth.users.id`). +3. Ricrea i bucket `avatar-giocatori` e `profili-giocatore` e ricarica il + contenuto di `storage/`. +4. Reimposta le variabili d'ambiente su Vercel/locale (non incluse in questo + backup — vedi sotto). +5. Redeploy dal repo git (il codice non serve backupparlo qui, è già su + GitHub e Gitea). + +Per un archivio cifrato: `gpg -d backups/AAAAMMGG_hhmm.tar.gz.gpg | tar xz`. + +## Cosa NON è incluso + +- **Variabili d'ambiente Vercel**: richiede `vercel login` interattivo, non + automatizzabile da questo script. Esportale a mano con `vercel env pull`. +- **Codice applicativo**: vive nel repo git, non nel database. + +## Sicurezza + +Questi backup contengono password hash, documenti d'identità e altri dati +personali reali. Per questo: + +- la cartella `backups/` è in `.gitignore` (tranne questo README e gli script: + quelli sì restano in git) — **non committare mai i dati generati**; +- se sposti un backup fuori da questo host, usa sempre `--encrypt` o cifra tu + l'archivio a mano; +- cancella i backup in chiaro quando non ti servono più. + +## File di questa cartella + +| File | Ruolo | +| ---------------------- | ------------------------------------------------------------------------------------------------ | +| `backup.sh` | Punto di ingresso: esegue dump e download in ordine | +| `download-storage.mjs` | Scarica i file dei bucket (usa la service role key del `.env`) | +| `storage-core.ts` | Logica pura (lettura del `.env`, elenco dei file), coperta da `test/unit/backup-storage.test.ts` | diff --git a/backups/backup.sh b/backups/backup.sh new file mode 100755 index 0000000..28dc98d --- /dev/null +++ b/backups/backup.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Backup completo di disaster recovery: schema + dati DB, utenti auth, file storage. +# Uso: +# ./backups/backup.sh crea backups/YYYYMMDD_HHMM/ con dentro tutto +# ./backups/backup.sh --encrypt come sopra, poi comprime e cifra con gpg (chiede una passphrase) +# +# Richiede: essere già collegati al progetto Supabase (supabase login + link, già fatto +# in questo repo) e avere .env con VITE_SUPABASE_URL e SUPABASE_SERVICE_ROLE_KEY. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STAMP="$(date +%Y%m%d_%H%M)" +RUN_DIR="$SCRIPT_DIR/$STAMP" + +mkdir -p "$RUN_DIR" +echo "==> Backup in $RUN_DIR" + +echo "==> Dump schema pubblico" +npx supabase db dump --linked -f "$RUN_DIR/schema_public.sql" + +echo "==> Dump dati pubblici" +npx supabase db dump --linked --data-only -f "$RUN_DIR/data_public.sql" + +echo "==> Dump schema auth" +npx supabase db dump --linked --schema auth -f "$RUN_DIR/schema_auth.sql" + +echo "==> Dump dati auth (utenti)" +npx supabase db dump --linked --schema auth --data-only -f "$RUN_DIR/data_auth.sql" + +echo "==> Download file storage (bucket)" +node "$SCRIPT_DIR/download-storage.mjs" "$RUN_DIR/storage" + +echo "==> Backup completato: $RUN_DIR" + +if [[ "${1:-}" == "--encrypt" ]]; then + ARCHIVE="$RUN_DIR.tar.gz.gpg" + echo "==> Comprimo e cifro in $ARCHIVE (verrà chiesta una passphrase)" + tar -C "$SCRIPT_DIR" -czf - "$STAMP" | gpg --symmetric --cipher-algo AES256 -o "$ARCHIVE" + rm -rf "$RUN_DIR" + echo "==> Archivio cifrato pronto: $ARCHIVE" + echo " Per estrarlo: gpg -d $ARCHIVE | tar xz" +fi diff --git a/backups/download-storage.mjs b/backups/download-storage.mjs new file mode 100644 index 0000000..63b2ce7 --- /dev/null +++ b/backups/download-storage.mjs @@ -0,0 +1,45 @@ +// Scarica tutti i file dei bucket Supabase Storage per il backup di disaster recovery. +// Uso: node backups/download-storage.mjs [cartella-output] +import { createClient } from "@supabase/supabase-js"; +import { mkdir, writeFile, readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { elencaFile, leggiEnv } from "./storage-core.ts"; + +const env = leggiEnv(await readFile(new URL("../.env", import.meta.url), "utf8")); +for (const [chiave, valore] of Object.entries(env)) process.env[chiave] ??= valore; + +const BUCKETS = ["avatar-giocatori", "profili-giocatore"]; +const OUT_DIR = process.argv[2] ?? new URL("./storage/", import.meta.url).pathname; + +const supabase = createClient(process.env.VITE_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); + +const elencaCartella = (bucket) => async (prefisso) => { + const { data, error } = await supabase.storage.from(bucket).list(prefisso, { limit: 1000 }); + if (error) throw error; + return data; +}; + +const falliti = []; + +for (const bucket of BUCKETS) { + console.log(`Bucket: ${bucket}`); + const files = await elencaFile(elencaCartella(bucket)); + console.log(` ${files.length} file trovati`); + for (const path of files) { + const { data, error } = await supabase.storage.from(bucket).download(path); + if (error) { + console.error(` ERRORE ${path}:`, error.message); + falliti.push(`${bucket}/${path}`); + continue; + } + const destPath = join(OUT_DIR, bucket, path); + await mkdir(dirname(destPath), { recursive: true }); + await writeFile(destPath, Buffer.from(await data.arrayBuffer())); + console.log(` scaricato: ${bucket}/${path}`); + } +} +if (falliti.length) { + console.error(`${falliti.length} file non scaricati: il backup storage è incompleto.`); + process.exit(1); +} +console.log("Fatto."); diff --git a/backups/storage-core.ts b/backups/storage-core.ts new file mode 100644 index 0000000..413b3fc --- /dev/null +++ b/backups/storage-core.ts @@ -0,0 +1,30 @@ +/** Logica pura del backup storage, separata dalla rete per poterla testare. */ + +/** Righe `CHIAVE=valore` di un file `.env`; toglie le virgolette solo se aprono e chiudono. */ +export function leggiEnv(testo: string): Record { + const env: Record = {}; + for (const riga of testo.split("\n")) { + const match = /^([\w.]+)=(.*)$/.exec(riga.trim()); + if (!match) continue; + const [, chiave, grezzo] = match as unknown as [string, string, string]; + const tra = /^(["'])(.*)\1$/.exec(grezzo); + env[chiave] = tra ? tra[2]! : grezzo; + } + return env; +} + +export type Voce = { name: string; id: string | null }; + +/** Tutti i percorsi dei file sotto `prefisso`; Supabase segnala le cartelle con `id: null`. */ +export async function elencaFile( + lista: (prefisso: string) => Promise, + prefisso = "", +): Promise { + const file: string[] = []; + for (const voce of await lista(prefisso)) { + const percorso = prefisso ? `${prefisso}/${voce.name}` : voce.name; + if (voce.id === null) file.push(...(await elencaFile(lista, percorso))); + else file.push(percorso); + } + return file; +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb640aa..431574e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,6 +108,7 @@ lanciato, così swipe come quello del calendario atterrano dove il gesto stava a npm run dev # vite dev su http://localhost:8080 npm run build # build di produzione (nitro) npm run lint # eslint (include prettier come regola) +npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file) npm run format # prettier --write . npm run test # test unit (veloci, senza rete né database) npm run test:integration # route server vere diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 930f283..9802c5e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,12 +6,59 @@ e le voci sono divise per categoria (Aggiunto, Modificato, Sicurezza...). L'elen completo delle funzionalità, fatte e previste, sta in [ROADMAP.md](ROADMAP.md); qui si registra solo _quando_ una voce è stata rilasciata e con quale versione. -Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Il progetto è pre-1.0 (`0.y.z`): -finché resta sotto `1.0.0` un aumento di `y` può includere anche cambi non compatibili -all'indietro. +Le versioni sono sempre a tre cifre (`x.y.z`, mai `x.y`). Dalla `1.0.0` si segue il +[versionamento semantico](https://semver.org/lang/it/): `x` cresce con i cambi non +compatibili all'indietro, `y` con le funzionalità nuove, `z` con le sole correzioni. Le +versioni `0.y.z` erano pre-release. ## [Non rilasciato] +## [1.0.0] - 2026-09-24 + +Prima versione stabile. Oltre al ruolo allenatore riunisce il lavoro fatto dalla 0.9.2: +centro notifiche in-app, messaggi dell'admin e la nuova Gestione eventi a calendario. + +### Aggiunto + +- **Ruolo allenatore** — l'admin registra un allenatore da `/admin` (tipo «Allenatore», con + l'email Gmail) e lui si collega al primo accesso come i giocatori. Gestisce gli eventi e + sollecita le presenze; ha un profilo ridotto (dati personali e foto, senza stagione e + badge), compare in Squadra con la dicitura «Allenatore» e non vede badge né cacche. + Migration `m20_ruolo_allenatore_enum` e `m21_ruolo_allenatore` (DD-034). +- **Centro notifiche in-app** — lo storico delle notifiche dentro l'app, indipendente dalla + push: messaggi dell'admin, promemoria automatici 24 ore e 3 ore prima di un evento, turno + palloni e sollecito presenze. Ogni notifica si toglie con uno swipe o con la ×. Migration + `m17_notifiche_utente` (DD-030). +- **Messaggi dell'admin** — dalla tab Notifiche della dashboard l'admin manda un messaggio + libero a tutta la squadra o a un solo giocatore; la tab elenca anche chi non ha le + notifiche attive. +- **Gestione eventi a calendario** — `/eventi` è una griglia mensile: si tocca un giorno per + vederne gli eventi e crearne, modificarne o eliminarne uno. Prima di salvare la modifica + di un evento esistente compare una conferma, come per l'eliminazione. +- **Backup completo** — uno script scarica schema e dati del database e i file dello + storage, per poter ripartire se si perde Supabase o Vercel (vedi `PORTABILITA.md`). + +### Modificato + +- **Notifiche in-app** — il numero delle non lette è un pallino rosso sull'avatar del + profilo, al posto della campanella in alto a destra. Toccando il pallino si apre l'elenco + (leggi ed elimina), toccando l'avatar si va al profilo come prima. +- **Campionato** — l'header riporta la stagione delle gare CSI (es. «Stagione 2025/26») e lo + storico partite è diviso per stagione. +- **Gestione eventi** — tolta la lista "Eventi in calendario": resta solo il calendario + mensile, che si scorre anche con lo swipe come quello della squadra. +- **Gestione eventi** — nel form il giorno toccato sul calendario è già fissato: resta da + scegliere solo l'ora («Cambia» per spostare l'evento in un altro giorno). Il campo + «Luogo» di un nuovo evento parte vuoto invece che con «Palestra Comunale». + +### Corretto + +- **Squadra** — la data di nascita inserita dal proprio Profilo compare a tutta la squadra + (e nei compleanni del Calendario) invece di «Invalid Date»; allineati anche i profili + compilati prima della correzione. Migration `m18_nascita_pubblica_giocatori_squadra` e + `m19_backfill_nascita_da_profili_esistenti` (DD-031). +- **Squadra** — la rosa non si ricalcola più a ogni render mentre i voti MVP si caricano. + ## [0.9.2] - 2026-09-10 ### Aggiunto diff --git a/docs/DATABASE.md b/docs/DATABASE.md index c600c5b..67056d0 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -7,31 +7,33 @@ non in questo file. ## Permessi di scrittura -Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023). La **lettura** +Chi può scrivere cosa, dopo la migration `m11_scritture_per_ruolo` (DD-023) e il ruolo +allenatore di `m21_ruolo_allenatore` (DD-034). La **lettura** resta aperta a tutti gli autenticati su ogni tabella di questo elenco; `anon` non arriva a nessuna di esse da M4 (DD-011). -| Tabella | Chi può scrivere | -| ---------------------------------------------------------------- | ------------------------------------------------------------------- | -| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) | -| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin | -| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli | -| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate | -| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) | -| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) | -| `user_roles` | solo admin | +| Tabella | Chi può scrivere | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `eventi_app` | admin e allenatori (nell'app li gestisce la rotta `/eventi`, riservata a loro) | +| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), mai l'allenatore (M21), più gli admin | +| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`) e nessuno dei due è un allenatore (`m21`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli | +| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate | +| `notifiche_utente` | nessuno scrive da client: le righe nascono da trigger/funzioni `SECURITY DEFINER` o dalla service role (M17); il giocatore può solo segnare come lette le proprie | +| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) | +| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016); l'allenatore anche cambiare nome e cognome del proprio slot (M21) | +| `user_roles` | solo admin; la riga `allenatore` la scrive il trigger `sincronizza_ruolo_allenatore` (M21) | L'identità del giocatore è lo slot di `giocatori_squadra` con `auth_user_id = auth.uid()`. La tabella è verificata da `test/integration/permessi.test.ts` contro il database locale. ## Anagrafica e utenti -| Tabella | Scopo | Note | -| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde, e come sorgente della data di nascita (non ancora una colonna di questa tabella). Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. | -| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). | -| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. | -| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). | +| Tabella | Scopo | Note | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, source of truth della rosa (DD-015): `useRosa()` e gli altri punti che elencano i giocatori la leggono tramite `useGiocatoriSquadra()` (client) o `leggiGiocatoriSquadra()` (server), filtrando `attivo`. `src/lib/crapp-data.ts` resta solo come seed storico e fallback (`rosaFallback()`) quando il database non risponde. Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`, impostabile anche da `/admin`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata per tutta la rosa attiva. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. La colonna `nascita` (migration `m18_nascita_pubblica_giocatori_squadra`, DD-031) è pubblica a tutta la rosa attiva (stessa policy di lettura delle altre colonne): non si scrive mai da qui, un trigger su `profili_giocatore` (`sincronizza_nascita_pubblica`) la tiene allineata a `data_nascita` — impostata, aggiornata o azzerata dallo stesso form Profilo che scrive quella colonna. Il trigger copre solo le scritture successive a M18: `m19_backfill_nascita_da_profili_esistenti` ha allineato una tantum anche chi aveva già compilato il profilo prima di quella migration. La colonna `tipo` (migration `m21_ruolo_allenatore`, DD-034) distingue `giocatore` da `allenatore`: l'allenatore ha uno slot per collegarsi e per i dati personali, ma è fuori dalla rosa di gioco (`inRosa()` nel codice), non ha numero di maglia (`numero` è NULL, ammesso solo per lui dal vincolo `giocatori_squadra_numero_giocatori`) e ha `ruolo` vuoto. `tipo` lo scrive solo un admin. | +| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). | +| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. | +| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). Il valore `allenatore` di `app_role` (migration `m20_ruolo_allenatore_enum`) non si inserisce a mano: lo aggiunge e lo toglie il trigger `sincronizza_ruolo_allenatore` quando uno slot con `tipo = 'allenatore'` viene collegato, scollegato, disattivato o cambia tipo (DD-034). | `giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle. @@ -44,12 +46,12 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa ## Eventi e presenze -| Tabella | Scopo | Note | -| ------------------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. | -| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. | -| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). | -| `presenze` | Presenze agli eventi. | Come sopra (DD-014). | +| Tabella | Scopo | Note | +| ------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. Cancellare un evento pulisce a cascata, tramite trigger, tutte le tabelle collegate elencate in questa pagina (`risposte_presenze`, `cacche_partita`, `mvp_voti`, `pagelle_voti`, `badge_social_voti`, `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite`) — migration `m14_pulizia_dati_evento_cancellato`, DD-029. Le righe orfane da cancellazioni precedenti sono state bonificate una tantum da `m15_bonifica_dati_evento_orfani`, senza toccare i vecchi voti MVP/pagelle/badge social legati a id Scout o CSI; la stessa logica resta richiamabile come funzione `bonifica_dati_evento_orfani()` (`m16_funzione_bonifica_dati_evento_orfani`, riservata al service role) se mai servisse di nuovo. | +| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. `risposto_il` è l'istante della **prima** risposta (migration `m9_risposte_presenze_risposto_il`): confrontato con `eventi_app.creato_il` dà la serie "Conferme 24h". Un trigger lo rende immutabile, così un ripensamento non fa risultare rapida una risposta lenta — `aggiornato_il` resta l'ultima modifica. | +| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). | +| `presenze` | Presenze agli eventi. | Come sopra (DD-014). | ## Scout @@ -61,19 +63,20 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa ## Votazioni -| Tabella | Scopo | Note | -| ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| Tabella | Scopo | Note | +| ------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). | -| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. | +| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli originari della tabella; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. | | `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). | ## Turni e notifiche -| Tabella | Scopo | Note | -| -------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. | -| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | -| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. | +| Tabella | Scopo | Note | +| -------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `turni_palloni` | Gestione dei turni palloni. | Solo turni **confermati**. Gli allenamenti non ricevono proposta automatica (vedi [palloni.md](modules/palloni.md)); M10 azzera i turni salvati su allenamenti da oggi in poi. | +| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | | +| `promemoria_push` | Non più usata. | Serviva da coda del testo quando la push partiva vuota; dal payload cifrato non la scrive né la legge nessuno. Tabella ancora presente, da eliminare con una migrazione. | +| `notifiche_utente` | Centro notifiche in-app, con stato letto/non letto, sull'avatar del profilo. | Introdotta dalla migration `m17_notifiche_utente`. Quattro sorgenti, tutte in parallelo alla push esistente: messaggio libero dell'admin (`notifica-personalizzata.ts`), un promemoria automatico prima di un evento — 24h e 3h prima, due `cron.schedule` (estensione `pg_cron`, abilitata il 31/07/2026 ma usata per la prima volta qui) — il turno palloni (`promemoria-palloni.ts`) e il sollecito presenze (`sollecita-presenze.ts`), questi ultimi due avviati a mano da un admin (il sollecito anche da un allenatore, DD-034). Volutamente non c'è una notifica sulla sola creazione di un evento: conta l'avvicinarsi della data, non il momento in cui è stato messo in calendario. I destinatari di un evento seguono la stessa convenzione di `convocatiEvento()`/`evento_permette_voto()` (M13): `convocati` vuoto = tutta la rosa attiva. Gli allenatori attivi sono sempre tra i destinatari dei promemoria evento, convocati o no (`giocatori_destinatari_evento()` riscritta da M21, DD-034); il sollecito presenze (avviato da admin o allenatore) e il turno palloni non arrivano mai a loro. Letta/segnata come letta dal client con `useNotificheMie()`/`useSegnaLette()` (`src/lib/notifiche-utente.ts`), RLS-only, nessuna route API dedicata per queste due operazioni. | ## Funzioni speciali diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 1d3d7fd..2b1778b 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -16,35 +16,40 @@ Serve a rispondere a domande del tipo: **Accettate** -| ID | Titolo | -| --------------------------------------------------------------------------------- | ------------------------------------- | -| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable | -| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first | -| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive | -| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first | -| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile | -| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB | -| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli | -| [DD-009](#dd-009--tesseramento-csi-manuale-integrazione-api-in-una-fase-successiva) | CSI manuale, poi integrazione API | -| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati) | Niente storico certificati | -| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo | -| [DD-012](#dd-012--rimandare-la-migrazione-degli-id-giocatore) | Rimandare la migrazione ID | -| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack | -| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB | -| [DD-016](#dd-016--schema-dati-profilo-giocatore-f0) | Schema dati Profilo Giocatore | -| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore | -| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email | -| [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) | Il branch lo decide l'utente | -| [DD-020](#dd-020--una-funzione-modificata-senza-test-non-è-finita) | Test obbligatori e verdi | -| [DD-021](#dd-021--molle-interrompibili-al-posto-delle-animazioni-a-durata-fissa) | Molle interrompibili con motion | -| [DD-022](#dd-022--lapp-è-solo-chiara) | App solo chiara | -| [DD-023](#dd-023--ogni-scrittura-è-limitata-a-chi-la-fa) | Scritture limitate per ruolo | -| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate | -| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale | -| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato | -| [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati | -| [DD-028](#dd-028--soglia-minima-di-campione-per-media-voto-e-mvp-in-home) | Soglia minima Media voto e MVP | -| [DD-029](#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati) | Pulizia a cascata evento cancellato | +| ID | Titolo | +| ------------------------------------------------------------------------------------------------ | ------------------------------------- | +| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable | +| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first | +| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive | +| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first | +| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile | +| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB | +| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli | +| [DD-009](#dd-009--tesseramento-csi-manuale-integrazione-api-in-una-fase-successiva) | CSI manuale, poi integrazione API | +| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati) | Niente storico certificati | +| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo | +| [DD-012](#dd-012--rimandare-la-migrazione-degli-id-giocatore) | Rimandare la migrazione ID | +| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack | +| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB | +| [DD-016](#dd-016--schema-dati-profilo-giocatore-f0) | Schema dati Profilo Giocatore | +| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore | +| [DD-018](#dd-018--collegamento-automatico-giocatoreaccount-per-email) | Collegamento automatico per email | +| [DD-019](#dd-019--il-branch-dei-commit-lo-decide-lutente) | Il branch lo decide l'utente | +| [DD-020](#dd-020--una-funzione-modificata-senza-test-non-è-finita) | Test obbligatori e verdi | +| [DD-021](#dd-021--molle-interrompibili-al-posto-delle-animazioni-a-durata-fissa) | Molle interrompibili con motion | +| [DD-022](#dd-022--lapp-è-solo-chiara) | App solo chiara | +| [DD-023](#dd-023--ogni-scrittura-è-limitata-a-chi-la-fa) | Scritture limitate per ruolo | +| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate | +| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale | +| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato | +| [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati | +| [DD-028](#dd-028--soglia-minima-di-campione-per-media-voto-e-mvp-in-home) | Soglia minima Media voto e MVP | +| [DD-029](#dd-029--cancellare-un-evento-pulisce-a-cascata-i-dati-collegati) | Pulizia a cascata evento cancellato | +| [DD-030](#dd-030--centro-notifiche-in-app-storico-separato-dalla-push-letto-solo-con-rls) | Centro notifiche in-app | +| [DD-031](#dd-031--data-di-nascita-pubblica-una-colonna-sincronizzata-non-una-seconda-rls-aperta) | Nascita pubblica sincronizzata | +| [DD-032](#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare) | Stagione CSI dalle date delle gare | +| [DD-033](#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco) | Gestione eventi solo da calendario | +| [DD-034](#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore) | Ruolo allenatore | **In valutazione** @@ -1155,3 +1160,294 @@ di orfani. **Riesame** Se una nuova tabella collegata a un evento non viene aggiunta al trigger quando creata (va aggiornata a mano, non c'è un meccanismo che lo forzi). + +### DD-030 — Centro notifiche in-app: storico separato dalla push, letto solo con RLS + +**Data:** 15 settembre 2026 +**Stato:** Accettata + +**Contesto** +Le notifiche esistenti (`docs/modules/notifiche.md`) sono solo push OS/browser: arrivano se +il dispositivo ha attivato l'interruttore in Profilo, sono effimere (nessuno storico) e non +hanno uno stato letto/non letto — chi non attiva le push, o cambia dispositivo, semplicemente +non le vede mai. Serviva un canale visibile sempre, dentro l'app, con un badge accanto al +profilo che segnali quante cose non lette ci sono. + +**Decisione** +Nuova tabella `notifiche_utente` (migration `m17_notifiche_utente`), popolata da quattro +sorgenti lato database — mai dal client: + +1. messaggio libero dell'admin (`notifica-personalizzata.ts`, in parallelo alla push); +2. promemoria automatico prima di un evento, 24h e 3h prima, due `cron.schedule` + (estensione `pg_cron`, abilitata dal 31/07/2026 e usata per la prima volta qui); +3. turno palloni (`promemoria-palloni.ts`), in parallelo alla push; +4. sollecito presenze (`sollecita-presenze.ts`), in parallelo alla push. + +Lettura e scrittura (segna come letta, elimina) passano dal client Supabase autenticato con +RLS (`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()` in +`src/lib/notifiche-utente.ts`), senza una route API dedicata — stesso pattern di +`useSalvaEvento()` in `eventi.ts`. Nessuna policy INSERT per `authenticated`: le righe nascono +solo da funzioni `SECURITY DEFINER` o dalla service role, mai da un client. + +Pallino con il conteggio sull'avatar del profilo (`PallinoNotifiche`, +`src/components/crapp/ui-bits.tsx`; in origine una campanella separata, tolta per liberare +l'header: il tap sull'avatar resta l'accesso al profilo, quello sul pallino apre il +pannello), pannello a comparsa costruito a mano (nel progetto non c'è una libreria dropdown). Ogni riga si elimina +con uno swipe o una ×: non c'è pulizia automatica delle notifiche vecchie, quindi è l'unico +modo per un giocatore di toglierle di torno per sempre. + +**Alternative scartate** + +- Notificare anche sulla sola creazione di un evento, non solo sull'avvicinarsi della data → + scartata su richiesta esplicita: conta quando l'evento sta per succedere, non il momento in + cui è stato messo in calendario. La prima versione aveva un trigger `AFTER INSERT ON +eventi_app` per questo, rimosso prima del rilascio. +- Una route API dedicata per leggere/segnare come lette (come le route push esistenti) → + scartata: qui non serve la service role (niente da nascondere al giocatore stesso), quindi + la RLS basta ed evita di duplicare la logica di lettura già scritta lato client per gli + eventi. +- Pulizia automatica delle notifiche vecchie con un cron di retention (es. elimina le lette + dopo 30 giorni) → scartata per ora a favore dello swipe manuale: più diretto da capire per + chi usa l'app, e la tabella resta comunque piccola per una sola squadra. Resta un'estensione + naturale se in futuro servisse. +- Semplice `INSERT` invece di `upsert` per turno palloni e sollecito presenze → scartata: un + admin che preme due volte lo stesso pulsante per lo stesso evento avrebbe urtato contro il + vincolo `UNIQUE (giocatore_id, evento_id, tipo)`. L'upsert aggiorna la riga esistente + (testo e `creato_il` freschi, `letta` di nuovo `false`), così ripremere il pulsante si + comporta come un rinvio vero, non un errore silenzioso. + +**Conseguenze** + +- Il pallino sull'avatar riflette solo ciò che sta in `notifiche_utente`: un dispositivo senza + push attive vede comunque tutto, perché il canale è del tutto indipendente. +- `eventi_app.ora` è testo libero e non validato dal form (`` si può + svuotare): la funzione `genera_promemoria_eventi()` usa `ora_evento_a_time()`, che assorbe + l'errore di cast riga per riga invece di far fallire l'intera generazione dei promemoria per + tutti gli eventi quando uno solo ha l'ora scritta male. +- `test/integration/notifiche-utente.test.ts` è la definizione eseguibile del comportamento: + promemoria 24h/3h e deduplica, tolleranza a un'ora malformata, RLS su lettura/segna + letta/elimina, e le tre route (`notifica-personalizzata`, `promemoria-palloni`, + `sollecita-presenze`) che scrivono lo storico in-app. + +**Riesame** +Se la tabella crescesse in modo sensibile (squadre molto più numerose, o giocatori che non +fanno mai swipe), va aggiunta la pulizia automatica scartata sopra. + +### DD-031 — Data di nascita pubblica: una colonna sincronizzata, non una seconda RLS aperta + +**Data:** 15 settembre 2026 +**Stato:** Accettata + +**Contesto** +In "Squadra" la data di nascita mostrava **"Invalid Date"** per chi l'aveva inserita da +solo dal proprio Profilo. Causa: due fonti mai collegate. I 17 giocatori del seed storico +hanno la nascita in `nascitaPerId`, una mappa statica pubblica bundlata nel client +(`src/lib/crapp-data.ts`) — il commento sulla riga la descriveva esplicitamente come +stopgap: _"`giocatori_squadra` non ha ancora questa colonna (DD-015 follow-up)"_. Un +giocatore aggiunto dopo la migrazione DD-015 inserisce invece la nascita dal form Profilo, +che scrive su `profili_giocatore.data_nascita` — tabella con RLS "solo il proprio profilo o +admin" (M2), mai letta da `useRosa()`/`useAnagraficaRosa()` (`src/lib/rosa.ts`), e comunque +non visibile ai compagni per via della RLS. Per quel giocatore `nascitaPerId[g.id]` era +`undefined` → `""` → `formatData("")` in `src/routes/squadra.tsx` produceva `Invalid Date`. + +**Decisione** +Migration `m18_nascita_pubblica_giocatori_squadra`: nuova colonna `giocatori_squadra.nascita` +(quella prevista da DD-015), pubblica a tutta la rosa attiva con la stessa policy di lettura +già in vigore per le altre colonne. Un trigger `SECURITY DEFINER` su `profili_giocatore` +(`sincronizza_nascita_pubblica`, AFTER INSERT/UPDATE/DELETE) la tiene allineata a +`data_nascita`: un solo input nel form Profilo (`CampiProfilo`), due destinazioni — quella +amministrativa esistente (RLS invariata: sé stesso o admin) e questo specchio pubblico. +`profili_giocatore` resta l'unica tabella che il client scrive per la nascita; il client non +tocca mai `giocatori_squadra.nascita` direttamente. + +Essendo `SECURITY DEFINER` di proprietà dell'owner della tabella, la scrittura del trigger +bypassa la RLS di `giocatori_squadra` automaticamente — nessuna nuova policy lì. Serve però +un nuovo ramo in `enforce_giocatori_squadra_update()` (il trigger `BEFORE UPDATE` che oggi +rifiuta qualunque modifica a uno slot già collegato da parte di chi non è admin): accetta un +update dove cambia **solo** `nascita` e il chiamante è il proprietario dello slot +(`OLD.auth_user_id = auth.uid()`). Senza quel ramo l'update annidato del trigger di sync +fallirebbe con l'eccezione già in vigore. + +Backfill una tantum dei 17 storici con gli stessi valori di `nascitaPerId`, poi rimozione di +quell'export (`crapp-data.ts`): era descritto fin dall'inizio come temporaneo, in attesa +esattamente di questa colonna. + +**Alternative scartate** + +- Allentare la RLS di `profili_giocatore` per rendere `data_nascita` leggibile a tutta la + squadra → scartata: quella tabella porta anche indirizzo, telefono, documento d'identità + nello stesso record — aprirne la lettura avrebbe esposto molto più della sola nascita. +- Un secondo campo "data di nascita pubblica" nel form Profilo, distinto da quello + amministrativo → scartata: due campi per lo stesso dato reale avrebbero richiesto + spiegare all'utente perché ne esistono due, con rischio di disallineamento se ne compila + uno solo. +- Far scrivere `giocatori_squadra.nascita` direttamente dal client (nuova RLS policy per il + proprietario dello slot) invece di sincronizzarla da `profili_giocatore` → scartata: la + RLS di update per un non-admin resta `USING (auth_user_id IS NULL)` (solo reclamare uno + slot libero); aprirla anche a righe già collegate avrebbe ampliato la superficie scrivibile + dal client su una tabella con un trigger di sicurezza già delicato (DD-016/DD-023), per un + guadagno che il trigger di sync ottiene comunque senza toccare la RLS. + +**Conseguenze** + +- Chiunque compili la propria data di nascita dal Profilo la vede comparire in Squadra (e + nei compleanni del Calendario) esattamente come i 17 storici — non più un dato "di serie + B". +- Cancellare il profilo (da sé o da un admin) azzera anche la nascita pubblica: non resta un + valore pubblico orfano di un profilo che non esiste più. +- Un giocatore senza nascita nota (profilo mai compilato) non genera più "Invalid Date": + `squadra.tsx` mostra la riga della data di nascita solo se `g.nascita` è valorizzata, + stesso criterio già usato da `compleanniEventi()` per escludere chi non ha ancora un dato. +- `test/integration/nascita-pubblica.test.ts` è la definizione eseguibile del comportamento: + sync alla scrittura, visibilità a un giocatore terzo (non solo l'interessato o l'admin), + azzeramento alla cancellazione, blocco della scrittura diretta dal client. + +**Riesame** +Se in futuro serve differenziare "chi può vedere la nascita" (es. solo squadra, non +pubblico) va rivista insieme alla policy di lettura generale di `giocatori_squadra`, non solo +per questa colonna. + +**Aggiornamento (15 settembre 2026, stesso giorno)** — dopo aver applicato M18 in produzione +(`supabase db push`), la nascita in Squadra compariva solo per i 17 giocatori storici, non +per chi aveva già compilato il proprio profilo prima della migration: il trigger di sync +copre solo le scritture _future_ su `profili_giocatore`, non retroattivamente, e M18 +backfillava a mano solo il seed storico. Migration `m19_backfill_nascita_da_profili_esistenti` +allinea una tantum `giocatori_squadra.nascita` a `profili_giocatore.data_nascita` per tutti i +profili già esistenti, con la stessa disattivazione temporanea del trigger di sicurezza usata +in M18. Da qui in poi il gap non si può ripresentare: ogni scrittura successiva passa dal +trigger di sync, questo backfill serve solo per lo storico antecedente a M18. + +A differenza della bonifica eventi orfani (DD-029, M15→M16), qui il backfill non è stato +reso una funzione richiamabile: resta SQL diretto nella migration. La copertura di test +(`test/integration/backfill-nascita-esistenti.test.ts`) verifica comunque lo stesso +comportamento eseguendo lo stesso pattern SQL via `psql` nel container Postgres locale — +scelta necessaria perché senza una funzione RPC, PostgREST non espone un update +multi-tabella come questo. + +### DD-032 — La stagione CSI si ricava dalle date delle gare + +**Data:** 24 settembre 2026 +**Stato:** Accettata + +**Contesto** +La pagina Campionato (`/classifica`) non diceva a quale stagione si riferissero classifica e +storico partite. Il portale CSI non espone il nome della stagione da nessuna parte: +`project-sheets.php`, `league_details.php`, `team-main.php` e il feed +`getEventsByTeamId.php` riportano solo il nome della competizione (verificato il +24/09/2026). Il `project_id` cambia a ogni stagione ed è aggiornato a mano in +`csi-core.ts`. + +**Decisione** +`stagioneDi()` in `src/lib/csi-core.ts` ricava la stagione ("2025/26") dalla data di una +gara, con l'anno che cambia ad agosto; `stagioneDaPartite()` usa la prima gara del +campionato letto (tutte le gare, non solo quelle giocate, così a inizio stagione basta il +calendario). L'header di `/classifica` mostra quella stagione, e lo storico partite è +diviso per stagione con `raggruppaPerStagione()`: le gare dello Scout Live locale, usate +quando il CSI non ne ha, possono essere di stagioni diverse. + +**Alternative scartate** + +- Una costante `CSI_STAGIONE` accanto a `CSI_PROJECT_ID` → un secondo valore da ricordarsi + di aggiornare a ogni stagione, che può divergere dal `project_id`; ricavata dai dati + segue da sola. +- Usare la stagione "in corso" per la squadra (la data di oggi) → sbaglierebbe proprio nel + periodo di passaggio: a settembre 2026 il CSI mostra ancora i dati 2025/26. + +**Conseguenze** + +- Oggi Campionato mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la + stagione scritta nel codice, mostrano «Stagione 2026/27»: il CSI non ha ancora creato le + pagine della nuova stagione. È voluto — Campionato riporta la stagione dei dati che + mostra (dettagli in [collegamento-csi.md](modules/collegamento-csi.md)). +- Quando si aggiornano i `project_id` l'etichetta si aggiorna senza toccare altro codice. + +**Riesame** +Se il CSI iniziasse a esporre il nome della stagione, leggerlo da lì; se una stagione CSI +dovesse iniziare prima di agosto, spostare la soglia in `stagioneDi()`. + +### DD-033 — Gestione eventi: solo calendario, giorno fissato dal tocco + +**Data:** 24 settembre 2026 +**Stato:** Accettata + +**Contesto** +`/eventi` mostrava sotto la griglia mensile anche la lista cronologica di tutti gli eventi +("Eventi in calendario"). La lista non aveva azioni proprie (toccare una riga apriva lo +stesso drawer del giorno) e allungava la pagina con informazioni già nel calendario. Nel +form, poi, il campo «Data» era già precompilato con il giorno toccato ma sembrava da +compilare: l'amministratore doveva scegliere solo l'ora. + +**Decisione** +La pagina mostra solo il calendario, che si scorre di mese in mese con le frecce o con lo +swipe (stessa fisica a molla di `/calendario`, disattivata con `useMotoRidotto()`). Nel form +il giorno compare come testo fisso e resta da scegliere l'ora; il campo data compare solo +toccando «Cambia», per spostare un evento in un altro giorno. + +**Alternative scartate** + +- Togliere del tutto il campo data → non si potrebbe più spostare un evento esistente, una + funzione che oggi c'è (regola anti-regressione). +- Tenere la lista ma più corta (solo i prossimi eventi) → i prossimi eventi sono già in home + e in `/calendario`; qui sarebbe stata un doppione. + +**Conseguenze** + +- Per trovare un evento lontano bisogna scorrere fino al suo mese; il totale degli eventi + resta nel sottotitolo dell'header. +- Caricamento ed errore di `useEventi()` compaiono dentro la card del calendario, dove prima + stava la lista. + +**Riesame** +Se gli eventi crescono tanto da rendere scomodo lo scorrere dei mesi, valutare una ricerca +per titolo invece di reintrodurre la lista completa. + +### DD-034 — L'allenatore è uno slot della squadra con tipo diverso, non un giocatore + +**Data:** 24 settembre 2026 +**Stato:** Accettata (implementata con le migration M20 e M21) + +**Contesto** +Serve un account per l'allenatore: deve gestire gli eventi (oggi solo admin, `m11`) ma non è +un giocatore. Non ha statistiche, badge né stagione, non vota e non viene votato. L'admin +vuole registrarlo come i giocatori, inserendo la sua email Gmail. Specifica completa in +[modules/allenatore.md](modules/allenatore.md). + +**Decisione** +L'allenatore è una riga di `giocatori_squadra` con una nuova colonna `tipo = 'allenatore'` +(default `'giocatore'`). Così riusa senza cambiarli il collegamento per email (DD-018), +`profili_giocatore` per i dati personali, il bucket avatar e il compleanno sincronizzato +(DD-031). Il permesso di gestire gli eventi arriva da un nuovo valore `allenatore` di +`app_role` in `user_roles` (DD-011), scritto da una funzione `SECURITY DEFINER` quando si +collega uno slot di tipo allenatore: è quindi l'admin a concederlo, registrando lo slot. +"Rosa" diventa `attivo e tipo = 'giocatore'` ovunque. + +**Alternative scartate** + +- Una tabella `staff` separata → isola l'allenatore da voti e rosa senza toccare i filtri, ma + duplica collegamento per email, profilo, avatar e form admin. Resta l'alternativa se i + filtri sulla rosa si rivelano troppi o fragili. +- Registrare l'allenatore come admin → gli darebbe dati personali e documenti di tutta la + squadra (DD-017), molto più del necessario. +- Distinguere l'allenatore dal campo `ruolo` (ruolo in campo) → mescola due concetti e il + campo è testo libero. + +**Conseguenze** + +- Ogni lettura della rosa (client, server, SQL di voto e promemoria) va filtrata per `tipo`: + un punto dimenticato rende l'allenatore votabile o lo conta tra i convocati. L'elenco sta + nella specifica e va coperto dai test di `permessi.test.ts`. +- Il trigger sugli slot cambia: `tipo` lo scrive solo l'admin; nome e cognome li scrive anche + l'allenatore sul proprio slot. +- `numero` diventa nullabile a database, ma solo per gli allenatori (vincolo di M21); nell'app + resta un numero, 0 per l'allenatore, per non toccare i tipi di tutta la rosa. +- Due fonti distinte, ciascuna per il suo scopo: i permessi (eventi, sollecito) da + `user_roles`, la visibilità (badge, cacche, stagione) dal tipo del proprio slot, che è + immediato e non aspetta la query dei ruoli. +- L'allenatore non vede le tab Stagione e Badge del Profilo, né Obiettivi e Badge di Squadra. +- In Squadra compare nella tab Rosa con «Allenatore» al posto del ruolo, ricavato da `tipo`; + riceve i promemoria degli eventi e può sollecitare le presenze, non il turno palloni. +- Non vede badge né classifiche o sondaggi sulle cacche, in nessuna schermata. + +**Riesame** +Se arriva un secondo tipo di staff (dirigente, segreteria) o se i filtri sulla rosa diventano +fonte di bug, passare alla tabella `staff` separata. diff --git a/docs/PORTABILITA.md b/docs/PORTABILITA.md index c696f7a..9b89f3f 100644 --- a/docs/PORTABILITA.md +++ b/docs/PORTABILITA.md @@ -27,6 +27,15 @@ senza dipendere da servizi esclusivi di Lovable Cloud. 5. Job pianificati richiamabili via semplice HTTP POST (es. `/api/public/promemoria-palloni`), così funzionano con cron di sistema, pg_cron o qualsiasi scheduler. +## Backup e disaster recovery + +Lo script [`backups/backup.sh`](../backups/backup.sh) scarica su un host locale schema e +dati del database (`public` e `auth`), utenti Supabase Auth e file dei bucket storage, in +modo da poter ripartire in fretta se il progetto Supabase (o Vercel) venisse perso. +Uso, contenuto del backup e procedura di ripristino sono descritti in +[`backups/README.md`](../backups/README.md). Non ancora automatizzato: l'export delle +variabili d'ambiente Vercel (richiede login interattivo). + ## Migrazione su server proprio (sintesi) 1. `bun run build` (o `npm run build`) → avvio del server Node generato. diff --git a/docs/README.md b/docs/README.md index e1bb1c8..ab11f09 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,16 +6,16 @@ ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--svi ## Dove sta cosa -| Documento | Risponde a | -| ------------------------------------------ | ---------------------------------------------------------------------------- | -| [ROADMAP.md](ROADMAP.md) | Cosa è fatto, cosa è previsto, cosa resta un'idea | -| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo | -| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa | -| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta | -| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio | -| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push | -| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando | -| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file | +| Documento | Risponde a | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| [ROADMAP.md](ROADMAP.md) | Cosa è fatto, cosa è previsto, cosa resta un'idea | +| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo | +| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa | +| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta | +| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio (backup/disaster recovery incluso) | +| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push | +| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando | +| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file | Le regole vincolanti per gli assistenti AI stanno in [AGENTS.md](../AGENTS.md); lo stato corrente del lavoro in [PROJECT_STATE.md](../PROJECT_STATE.md). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 39cceef..a2fccee 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -6,7 +6,7 @@ il _cosa_: `CHANGELOG.md` registra _quando_ una voce è stata rilasciata e con q ## Fatto -Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md`). +Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`). - [x] Gestione squadra - [x] Calendario @@ -21,6 +21,9 @@ Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md` - [x] Turno palloni - [x] Infortuni — conteggio eventi saltati, in forma minima - [x] Notifiche Push (promemoria intelligenti) +- [x] Centro notifiche in-app — pallino con il numero delle non lette sull'avatar del + profilo (il tap sul pallino apre l'elenco), indipendente dalla push (messaggi admin, promemoria evento, turno palloni, + sollecito presenze) - [x] Dashboard amministratore - [x] Download CSV dati - [x] Profilo Giocatore — dati personali, documento d'identità, certificato medico @@ -33,6 +36,8 @@ Tutto quello che è in `main`, rilasciato in versione 0.9.0 (vedi `CHANGELOG.md` - [x] Risultati campionato - [x] Dettaglio partita — formazioni, storico scontri diretti e probabilità di vittoria calcolata dal CSI +- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza + stagione e badge ([specifica](modules/allenatore.md), DD-034) ## Prossimo diff --git a/docs/modules/allenatore.md b/docs/modules/allenatore.md new file mode 100644 index 0000000..c876881 --- /dev/null +++ b/docs/modules/allenatore.md @@ -0,0 +1,190 @@ +# Modulo — Ruolo Allenatore + +**Stato:** implementato +**Decisione:** [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore) +**Migration:** `m20_ruolo_allenatore_enum`, `m21_ruolo_allenatore` +**File principali:** `src/lib/giocatori-squadra.ts` (`tipo`, `inRosa`, `isAllenatore`, +`ruoloVisibile`), `src/lib/ruoli.ts`, `src/lib/user-store.ts` (`useSonoAllenatore`, +`useGiocatoreInCampo`), `src/lib/auth-route.server.ts`, `src/routes/profilo.tsx`, +`src/routes/admin.tsx`, `src/routes/squadra.tsx`, `src/routes/index.tsx` +**Test:** `test/unit/giocatori-squadra.test.ts`, `test/unit/profili-core.test.ts`, +`test/unit/presenze.test.ts`, `test/integration/permessi-allenatore.test.ts`, +`test/integration/permessi-route.test.ts` + +## Obiettivo + +Dare accesso all'app all'allenatore, con permessi diversi da quelli di un giocatore: gestisce +gli eventi (allenamenti, partite, amichevoli) e sollecita le presenze, ma non gioca: compare +in Squadra come membro con la dicitura «Allenatore», però non è convocabile, non vota, non +riceve voti e non ha statistiche, badge o stagione. + +## Registrazione + +Stesso flusso dei giocatori (DD-018), senza passaggi in più: + +1. L'admin, da `/admin` → **Aggiungi**, sceglie il tipo **Allenatore** invece di Giocatore e + inserisce nome, cognome ed email Gmail. Numero di maglia e ruolo in campo non si chiedono. +2. L'allenatore accede con Google; l'email corrisponde allo slot e il collegamento è + automatico. Se l'email non corrisponde, stesso messaggio di errore dei giocatori. +3. Entra in Home. + +Il permesso nasce dalla registrazione fatta dall'admin: l'allenatore non può +auto-promuoversi, perché il tipo dello slot lo scrive solo un admin (come oggi numero e ruolo). + +## Profilo + +Il profilo dell'allenatore ha solo due tab (quelle del giocatore sono quattro): + +| Tab | Giocatore | Allenatore | +| ------------ | --------- | ---------------------------------------------- | +| Stagione | sì | **no** | +| Badge | sì | **no** | +| Documenti | sì | sì, ridotta (vedi sotto); tab iniziale | +| Impostazioni | sì | sì, identica (logout, notifiche, segnalazioni) | + +Un link diretto a `/profilo?tab=stagione` o `?tab=badge` apre Documenti. + +### Dati modificabili dall'allenatore + +| Campo | Dove sta oggi | Nota | +| ---------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- | +| Nome, cognome | `giocatori_squadra` | per un giocatore li scrive solo l'admin; per l'allenatore anche lui sul proprio slot | +| Email (contatto) | `profili_giocatore.email` | non è l'email di login, che resta quella registrata dall'admin | +| Data di nascita | `profili_giocatore.data_nascita` | sincronizzata in `giocatori_squadra.nascita` come per tutti (DD-031): compleanno in Calendario | +| Luogo di nascita | `profili_giocatore.luogo_nascita` | | +| Telefono | `profili_giocatore.telefono` | | +| Foto profilo | bucket `avatar-giocatori` | stesso cerchio avatar e stesso upload dei giocatori | + +**Non** si mostrano: indirizzo di residenza, documento d'identità, certificato medico, foto +tessera, tesseramento CSI. + +### Completamento profilo + +Per l'allenatore il completamento conta solo data e luogo di nascita, telefono ed email, 25% +ciascuno (`completamentoAllenatore()`). Il widget in Home segue la stessa regola: sparisce +quando quei campi sono compilati. + +## Home + +La Home dell'allenatore tiene ciò che riguarda la squadra e toglie ciò che riguarda il +giocatore: restano classifica e bilancio, prossimo impegno e ultima partita; spariscono lo +streak personale, il promemoria palloni, «Da confermare», l'obiettivo di squadra e il +«Colpo d'occhio» con le statistiche personali. + +## Squadra + +L'allenatore compare nella tab **Rosa** insieme ai giocatori, con foto, nome e cognome. Dove +per un giocatore c'è il ruolo in campo (palleggiatore, schiacciatore…) per lui c'è scritto +**Allenatore**, ricavato da `tipo` e non dal campo `ruolo`. Non ha numero di maglia né +statistiche, e non compare nella tab Stats né nelle classifiche. + +Da allenatore, in `/squadra` vede solo **Rosa** e **Stats**: le tab **Obiettivi** e **Badge** +non compaiono. + +| Tab | Giocatore | Allenatore | +| --------- | --------- | ---------- | +| Rosa | sì | sì | +| Stats | sì | sì | +| Obiettivi | sì | **no** | +| Badge | sì | **no** | + +## Badge e cacche nascosti + +L'allenatore non vede **nessun badge** e **nessuna classifica o dato sulle cacche**, in +nessuna schermata: + +| Dove | Cosa si nasconde all'allenatore | +| ----------------------------------- | -------------------------------------------------------------------------------- | +| `/squadra` → Stats | il criterio di ordinamento «Cacche» (`cacchePartita`) | +| `/squadra` → Rosa, scheda giocatore | la riga «Cacche/partita 💩» | +| `/partita/$id` | la sezione «Badge votati dai compagni» e il sondaggio cacche (`SondaggioCacche`) | +| push «Sondaggio pre-partita aperto» | non parte verso i dispositivi degli allenatori (`apri-sondaggio.ts`) | +| `/profilo`, `/squadra` | le tab Badge (già escluse sopra) | + +Presenze, media voto, MVP e palloni restano visibili. + +## Gestione eventi + +L'allenatore ha su `/eventi` gli stessi poteri dell'admin: crea, modifica, sposta ed elimina +eventi e sceglie i convocati (l'elenco dei convocati contiene solo giocatori). Nel database +la policy di scrittura su `eventi_app` è "admin o allenatore" (M21); la cancellazione a +cascata (DD-029) resta invariata. Il bottone «Gestisci eventi» del Calendario compare anche +a lui (`usePuoGestireEventi()`). + +Può anche **sollecitare le presenze** di un evento, come l'admin: la route +`sollecita-presenze.ts` accetta admin o allenatore (`richiediGestoreEventi()`), e il +sollecito arriva solo ai giocatori che non hanno risposto, mai all'allenatore stesso. + +**Restano solo admin:** dashboard `/admin`, dati e documenti dei giocatori, export CSI, +notifica personalizzata, promemoria palloni, apertura sondaggio cacche, correzione delle +risposte presenze altrui, gestione dei ruoli. + +## Notifiche + +L'allenatore riceve i promemoria automatici degli eventi (24h e 3h prima, centro notifiche +in-app di M17) come i giocatori, anche se non è convocato: `giocatori_destinatari_evento()` +lo include sempre. Riceve anche i messaggi dell'admin, e può attivare le push dal proprio +Profilo. Non riceve il sollecito presenze, il turno palloni né il sondaggio cacche, che +riguardano chi gioca. + +## Esclusione dalla rosa + +L'allenatore ha uno slot in `giocatori_squadra` (DD-034), quindi ogni lettura della "rosa di +gioco" lo esclude con `inRosa()` (`attivo` e `tipo = giocatore`) al posto del vecchio filtro +su `attivo`. Fanno eccezione la tab Rosa di Squadra, i compleanni del Calendario +(`useAnagraficaRosa({ conAllenatori: true })`), i destinatari dei promemoria evento e dei +messaggi admin, che lo includono. + +| Livello | Dove si esclude l'allenatore | +| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| client | `useRosa()`, `useAnagraficaRosa()`, `useTurniPalloni()`, `usePresenzeUltimoMeseTutti()`, form convocati di `/eventi`, `EventoCard`, `TurnoPalloni`, `VotazioneMvp`, `VotoSocial` | +| identità | `useGiocatoreInCampo()` è `null` per l'allenatore: niente risposta presenze, voti, pagelle, cacche, promemoria palloni | +| server | `destinatariSollecito()`, `promemoria-palloni.ts`, `apri-sondaggio.ts` | +| database | policy presenze e cacche «la propria riga» solo per `tipo = giocatore`; `evento_permette_voto()` rifiuta votante o votato allenatore | +| admin | tab Squadra (conteggi, export CSI) e Profili (allenatori in un gruppo a parte, senza documenti né tesseramento) | + +Il compleanno dell'allenatore resta visibile in Calendario (è un membro della squadra, non un +giocatore). + +## Permessi (riepilogo) + +| Azione | Giocatore | Allenatore | Admin | +| ----------------------------------------------- | --------- | ------------------ | ------------------ | +| Modificare nome e cognome | no | sì, i propri | tutti | +| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) | +| Documento, certificato, foto tessera | i propri | — | lettura/download | +| Creare/modificare/eliminare eventi, convocati | no | sì | sì | +| Sollecitare le presenze | no | sì | sì | +| Promemoria palloni, notifica personalizzata | no | no | sì | +| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) | +| Tab Stagione e Badge del profilo | sì | no | sì | +| Tab Obiettivi e Badge di Squadra | sì | no | sì | +| Badge e classifiche/dati sulle cacche | sì | no | sì | +| Dashboard `/admin` | no | no | sì | + +## Implementazione + +- **Migration M20** aggiunge `allenatore` all'enum `app_role`: sta da sola perché Postgres non + lascia usare un valore di enum nella transazione che lo crea. +- **Migration M21**: colonna `giocatori_squadra.tipo`; `numero` nullabile solo per gli + allenatori (vincolo `giocatori_squadra_numero_giocatori`); trigger + `enforce_giocatori_squadra_update` con `tipo` bloccato e nome/cognome liberi solo sullo slot + del proprio allenatore; policy «L'allenatore aggiorna il proprio slot»; trigger + `sincronizza_ruolo_allenatore` che aggiunge o toglie la riga `allenatore` in `user_roles` al + collegamento, scollegamento, disattivazione o cambio di tipo; policy `eventi_app` admin o + allenatore; presenze, cacche e voti chiusi all'allenatore; promemoria evento aperti. +- **Client**: nell'app `numero` resta un `number` (0 per l'allenatore, NULL a database). I + permessi (eventi, sollecito) leggono `user_roles` con `usePuoGestireEventi()`; cosa si vede + dipende dal tipo del proprio slot (`useSonoAllenatore()`), che è immediato. +- **Admin**: «Aggiungi giocatore o allenatore» con il campo Tipo; per l'allenatore il form non + chiede numero e ruolo. Il tipo si sceglie solo alla creazione. + +## Scelte confermate + +- Riceve i promemoria degli eventi: sì. +- Sollecita le presenze: sì. Promemoria palloni: no. +- Badge e cacche: mai visibili all'allenatore, né classifiche né sondaggio. +- In Squadra: nessuna sezione Staff; compare in Rosa come gli altri, con «Allenatore» al + posto del ruolo. +- Vede le risposte presenze e le pagelle dei giocatori: sì, la lettura è già aperta a tutti + gli autenticati. diff --git a/docs/modules/calendario.md b/docs/modules/calendario.md index dc23963..ef94a6e 100644 --- a/docs/modules/calendario.md +++ b/docs/modules/calendario.md @@ -1,10 +1,11 @@ # Modulo — Calendario ed Eventi **Stato:** implementato -**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/routes/calendario.tsx` -(vista mensile, tutti), `src/routes/eventi.tsx` (creazione/modifica, solo admin), -`src/components/crapp/EventoCard.tsx` (card condivisa) -**Test:** `test/unit/eventi.test.ts` +**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts` +(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti), +`src/routes/eventi.tsx` (creazione/modifica, admin e allenatori), `src/components/crapp/EventoCard.tsx` +(card condivisa) +**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts` --- @@ -19,14 +20,25 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve - **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti, partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()` - in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa()`, non righe + in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori: +true })` — ci sono anche gli allenatori, DD-034 — non righe vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo di evento, i giorni con più eventi si dividono lo spazio. -- **`/eventi`** — "Gestione eventi", riservata agli amministratori (`useIsAdmin()`): crea, - modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la - rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato` +- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori + (`usePuoGestireEventi()`, [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)): + crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori + (`convocatiEvento()`, vuoto = tutta la rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato` (`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata - in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). + in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). La pagina mostra + solo una griglia mensile (stessa logica di `/calendario`, tramite le funzioni condivise di + `src/lib/calendario.ts`), che si scorre di mese in mese con le frecce o con lo swipe, con + la stessa fisica a molla di `/calendario`. Ogni giorno è cliccabile, anche senza eventi, e + apre un drawer con gli eventi di quel giorno (modifica/elimina) e un bottone "Nuovo evento + in questo giorno" che apre il form con la data già precompilata: creare, modificare ed + eliminare passano solo da lì. Nel form il giorno si legge come testo fisso e resta da + scegliere solo l'ora; il campo data compare solo toccando «Cambia», per spostare l'evento. Fino a 0.9.2 sotto la griglia c'era anche la lista + cronologica di tutti gli eventi, tolta perché ripeteva il calendario + ([DD-033](../DESIGN_DECISIONS.md#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco)). Entrambe leggono la stessa cache (`useEventi()`, `EVENTI_KEY`, `staleTime` 10 minuti: il calendario cambia raramente). `EventoCard.tsx` è la card riusata da entrambe le schermate; diff --git a/docs/modules/collegamento-csi.md b/docs/modules/collegamento-csi.md index ec894b5..11adfc9 100644 --- a/docs/modules/collegamento-csi.md +++ b/docs/modules/collegamento-csi.md @@ -43,6 +43,15 @@ un `project_id`), nessun codice le chiama direttamente. `project_id` (767), `CSI_COPPA_PROJECT_ID` (848) e `team_id` (3359) sono costanti in `src/lib/csi-core.ts`. +**Stagione 2026/27 non ancora sul portale (verificato il 24/09/2026).** Il CSI non ha ancora +creato le pagine di campionato e Coppa 2026/27: le uniche competizioni della squadra sono +ancora `767` e `848`, con gare da novembre 2025 a maggio 2026. Per questo `/classifica` +mostra «Stagione 2025/26» mentre Squadra e Calendario, che hanno la stagione scritta nel +codice, mostrano già «Stagione 2026/27». È voluto: la pagina Campionato riporta la stagione +dei dati CSI che sta mostrando, non quella in corso per la squadra. Quando il CSI pubblica +la nuova stagione si aggiornano i due `project_id` (vedi sotto) e l'etichetta passa da sola +a 2026/27 ([DD-032](../DESIGN_DECISIONS.md#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare)). + Per ritrovare questi id a ogni cambio stagione: `components/team-main.php?team_id=3359` (dietro `team_details.php`) contiene una sezione "Campionati" con un link `league_details.php?project_id=…` per ogni competizione a cui la squadra è iscritta — @@ -155,7 +164,8 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h) ↓ /classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone sotto; tab "Storico partite": ogni squadra col proprio logo, - chevron di dettaglio sulle gare cliccabili) + chevron di dettaglio sulle gare cliccabili, gare divise + per stagione; stagione anche nel sottotitolo dell'header) CSI (portale, 3 endpoint) ↓ fetch server-side on-demand, cache per-partita 6 ore @@ -239,6 +249,9 @@ DettaglioCsiEsteso → src/components/crapp/DettaglioCsi.tsx (formazio Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing. 2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id (vedi "Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano in `csi-core.ts`. + L'etichetta «Stagione 2025/26» di `/classifica` invece non va toccata: il portale non + espone il nome della stagione, quindi `stagioneDi()`/`stagioneDaPartite()` la ricavano + dalla data delle gare (l'anno cambia ad agosto) e segue da sola il nuovo `project_id`. 3. **La cache vive nel processo del server.** Si perde a ogni cold start e non è condivisa tra istanze — vale sia per `/api/public/csi` sia per la `Map` per-partita di `/api/public/csi-partita/$id`. Sufficiente per una squadra; se serve di più, spostare i diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 9ca2145..d404825 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -1,9 +1,11 @@ # Modulo — Notifiche -**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push -**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/push-client.ts`, -`src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`, -`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js` +**Stato:** implementato — un unico opt-in dispositivo abilita tutto il canale push, più un +centro notifiche in-app indipendente, con un pallino sull'avatar del profilo +**File principali:** `src/lib/notifiche-smart.ts`, `src/lib/notifiche-utente.ts`, +`src/lib/push-client.ts`, `src/lib/webpush.server.ts`, `src/routes/api/public/push-config.ts`, +`src/routes/api/public/push-subscribe.ts`, `public/push-sw.js`, +`src/components/crapp/ui-bits.tsx` (`LinkProfilo`, `PallinoNotifiche`) --- @@ -81,14 +83,24 @@ route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna variabile d'ambiente. -| Route | Controllo | Chi la chiama | -| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | -| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin | -| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora | +| Route | Controllo | Chi la chiama | +| ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni`, `notifiche-attive`, `notifica-personalizzata` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante o una vista riservati agli admin | +| `csi`, `push-config`, `push-subscribe` | nessuno | il browser prima del login, che una sessione non ce l'ha ancora | `notifiche-attive` è a sola lettura: non manda push, restituisce gli id giocatore con almeno un dispositivo iscritto in `push_subscriptions` (deduplicati). Alimenta la tab "Notifiche" della dashboard admin (vedi [Profilo giocatore](profilo-giocatore.md)), non l'invio effettivo. +La tab elenca tutti i giocatori attivi della squadra, non solo chi ha le notifiche abilitate: +l'icona (campana piena/barrata) distingue chi ha almeno un dispositivo iscritto da chi non +l'ha ancora attivata. + +`notifica-personalizzata` manda un messaggio libero scritto dall'admin: senza `giocatoreId` +lo manda a tutti i dispositivi iscritti in `push_subscriptions`, con `giocatoreId` solo a +quelli di quel giocatore. Titolo fisso ("Messaggio dallo staff"), corpo il testo scritto +dall'admin (max 300 caratteri). Stessa logica di pulizia delle altre route: una sottoscrizione +che risponde 404/410 viene cancellata dalla tabella. Nella tab "Notifiche" della dashboard +admin c'è un bottone "Invia messaggio a tutti" sopra l'elenco e un bottone per riga giocatore. --- @@ -102,6 +114,51 @@ ripetersi — deduplica puramente locale al dispositivo, non sincronizzata. --- +## Centro notifiche in-app (M17) + +Pallino sull'angolo dell'avatar del profilo in alto a destra (`PallinoNotifiche` dentro +`LinkProfilo`, `src/components/crapp/ui-bits.tsx`): rosso con il numero delle non lette; se +sono tutte lette resta neutro con il totale, così le lette restano raggiungibili per +eliminarle; senza notifiche non compare. Il tap sull'avatar porta sempre a `/profilo`, solo il +tap sul pallino apre il pannello (logica in `pallinoNotifiche()`). Fino a 0.9.2 era una +campanella separata accanto all'avatar. Indipendente dal canale push sopra: +non richiede che il dispositivo abbia attivato «Notifiche», ha uno storico persistente in +`notifiche_utente` (vedi [DATABASE.md](../DATABASE.md)) con stato letto/non letto, e non è la +stessa cosa delle notifiche smart (quelle restano locali, non salvate a database). + +Quattro sorgenti scrivono in `notifiche_utente`, mai il client: + +1. **Messaggio admin** — `notifica-personalizzata.ts` inserisce una riga per destinatario + in parallelo all'invio push esistente (a tutta la rosa attiva se `giocatoreId` è omesso). +2. **Promemoria evento** — due job `pg_cron` (ogni ora per la finestra delle 24h, ogni 15 + minuti per quella delle 3h) generano una notifica per evento imminente, per i convocati + (o tutta la rosa attiva se l'evento non ne specifica), deduplicata da un vincolo + `UNIQUE (giocatore_id, evento_id, tipo)` così un cron che gira più volte non manda + doppioni. Volutamente non c'è una notifica sulla sola creazione dell'evento: conta + l'avvicinarsi della data, non il momento in cui è stato messo in calendario. +3. **Turno palloni** — `promemoria-palloni.ts` (bottone riservato agli admin sulla pagina + evento, vedi [Turno palloni](palloni.md)) inserisce una riga per ciascun avviso calcolato + da `avvisiPalloniEvento()`, in parallelo alla push. +4. **Sollecito presenze** — `sollecita-presenze.ts` (stesso innesco manuale) inserisce una + riga per ciascun giocatore che non ha ancora risposto, in parallelo alla push. + +Turno palloni e sollecito presenze usano un `upsert` su `(giocatore_id, evento_id, tipo)` +invece di un semplice insert: se l'admin preme di nuovo il pulsante per lo stesso evento, la +notifica esistente viene aggiornata (testo e `creato_il` freschi, `letta` riportata a false) +invece di duplicarsi o fallire per il vincolo `UNIQUE`. + +Lettura, "segna come letta" ed eliminazione passano dal client Supabase autenticato con RLS +(`useNotificheMie()`/`useSegnaLette()`/`useEliminaNotifica()`), senza una route API dedicata +— stesso pattern di `useSalvaEvento()` in `src/lib/eventi.ts`. Aprire il pannello segna tutte +le notifiche del giocatore selezionato come lette; non c'è un pulsante "segna singola" +separato. + +**Non c'è pulizia automatica delle notifiche vecchie** (DD-030): l'unico modo per farle +sparire per sempre è eliminarle una per una, con uno swipe verso sinistra sulla riga +(`RigaNotifica` in `ui-bits.tsx`, stessa fisica a molla dello swipe del calendario) o con la +× che compare sopra ogni riga per chi non è su touch. Chi non tocca mai una notifica se la +ritrova per sempre nell'elenco, solo segnata come letta. + ## Limiti noti - Non ci sono preferenze granulari (solo palloni / solo presenze / solo smart): un dispositivo diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md index 346524a..0b100cc 100644 --- a/docs/modules/profilo-giocatore.md +++ b/docs/modules/profilo-giocatore.md @@ -14,6 +14,9 @@ L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al ## Utenti +L'allenatore ha un profilo ridotto (solo Docs con i dati personali essenziali e Opzioni): +la specifica sta in [allenatore.md](allenatore.md). + ### Giocatore Può: @@ -35,7 +38,8 @@ Può: - modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email) - compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017) - scollegare un account da un profilo, liberando lo slot -- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale) +- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale), + oppure un allenatore (tipo «Allenatore»: niente numero né ruolo, DD-034) - disattivare un giocatore che ha lasciato la squadra, e riattivarlo in caso di errore: la riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano agganciati al suo id @@ -91,7 +95,11 @@ Il profilo viene suddiviso in sette aree. **Dati personali** — modificabili dal giocatore. -- Data di nascita +- Data di nascita — oltre a `profili_giocatore.data_nascita`, un trigger la sincronizza in + `giocatori_squadra.nascita` (DD-031): a differenza degli altri campi di questa sezione, + visibile a **tutta la squadra**, non solo al giocatore stesso o all'admin (alimenta + Squadra e i compleanni nel Calendario, vedi `squadra.md`). Cancellare il profilo azzera + anche quel valore pubblico. - Luogo di nascita - Indirizzo di residenza - Telefono @@ -175,6 +183,9 @@ alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` ( giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto di altri. +Gli allenatori stanno nella stessa tab in un gruppo «Allenatori» a parte, senza documenti né +tesseramento, e non entrano nei conteggi della tab Squadra né nell'export CSI (DD-034). + Per ogni giocatore, nella tab Profili, vengono mostrati. - Stato del profilo diff --git a/docs/modules/squadra.md b/docs/modules/squadra.md index 517482b..8cc1e66 100644 --- a/docs/modules/squadra.md +++ b/docs/modules/squadra.md @@ -22,7 +22,11 @@ giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app. - **`useAnagraficaRosa()`** (`rosa.ts`) — solo id, nome, ruolo, numero, data di nascita dei giocatori `attivo`. Serve dove basta sapere chi c'è, es. i compleanni nel Calendario o le - liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni. + liste presenze: non monta gli hook di MVP/pagelle/palloni/infortuni. La data di nascita + viene da `giocatori_squadra.nascita` (migration `m18_nascita_pubblica_giocatori_squadra`, + DD-031): pubblica a tutta la rosa, sincronizzata automaticamente da un trigger quando il + giocatore (o un admin per lui) la salva dal Profilo — vedi `profilo-giocatore.md`. `null` + finché non è stata inserita da nessuno. - **`useRosa()`** (`rosa.ts`) — la stessa anagrafica arricchita con tutte le statistiche personali calcolate a runtime: presenze, partite giocate, serie (presenze, allenamenti, partite, conferme, palloni), MVP vinti, media voto pagelle, palloni, cacche, infortuni, @@ -35,19 +39,29 @@ giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app. Entrambe filtrano solo i giocatori `attivo`: chi ha lasciato la squadra resta nel database (presenze, voti, pagelle e badge della stagione restano agganciati al suo id) ma sparisce -dagli elenchi correnti. +dagli elenchi correnti. Il filtro è `inRosa()` (`giocatori-squadra.ts`), che esclude anche +gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano. +`useAnagraficaRosa({ conAllenatori: true })` li include, per i compleanni. + +## Allenatori in Squadra + +Nella tab Rosa gli allenatori attivi compaiono in cima, con foto, nome e la dicitura +«Allenatore» dove i giocatori hanno il ruolo in campo (`ruoloVisibile()`); niente numero, +statistiche né badge, e niente posto nella tab Stats. Chi usa l'app da allenatore vede solo +Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). La +specifica completa sta in [allenatore.md](allenatore.md). ## Gestione dati squadra (solo amministratore) Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)): -| Azione | Hook | Effetto | -| ---------------------- | ------------------------ | ------------------------------------------------------------- | -| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) | -| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g` (`prossimoIdGiocatore()`), non generato dal database | -| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta | -| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo | -| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) | +| Azione | Hook | Effetto | +| ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) | +| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) | +| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta | +| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo | +| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) | Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo accesso con Google, per corrispondenza email @@ -77,12 +91,7 @@ presentazione — le rispettive specifiche stanno in `badge.md` e `obiettivi-squ ## Limiti noti -1. **`giocatori_squadra` non ha ancora una colonna per la data di nascita.** Per i - giocatori storici (seed iniziale) la nascita viene letta da `crapp-data.ts` - (`nascitaPerId`, lookup per id); un giocatore aggiunto dopo la migrazione non ha nascita - nota finché la colonna non esiste (DD-015). Effetto visibile: niente compleanno nel - Calendario per quei giocatori. -2. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database +1. **`src/lib/crapp-data.ts` resta come fallback**, non più come fonte viva: se il database non risponde o non è ancora popolato, `rosaFallback()` genera una rosa di riserva dai dati statici storici. Un ambiente nuovo senza dati in `giocatori_squadra` mostra quindi comunque una squadra, non una schermata vuota — ma è la rosa 2025/26 hardcoded, non diff --git a/package.json b/package.json index 1ebf31f..ccf20f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crapp", - "version": "0.9.2", + "version": "1.0.0", "private": true, "sideEffects": false, "type": "module", @@ -10,6 +10,7 @@ "build:dev": "vite build --mode development", "preview": "vite preview", "lint": "eslint .", + "typecheck": "tsc --noEmit", "format": "prettier --write .", "test": "bun test/run.ts", "test:integration": "bun test/run.ts integration", diff --git a/src/components/crapp/BarraSottosezioni.tsx b/src/components/crapp/BarraSottosezioni.tsx index 080a029..a2d4315 100644 --- a/src/components/crapp/BarraSottosezioni.tsx +++ b/src/components/crapp/BarraSottosezioni.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; -import { motion } from "motion/react"; +import { motion, type Transition } from "motion/react"; import { cn } from "@/lib/utils"; import { proietta } from "@/lib/molla"; import { useMotoRidotto } from "@/lib/motion"; @@ -13,7 +13,7 @@ export type VoceSottosezione = { }; /** Tween breve: evita molle + exit che tengono due pannelli in DOM insieme. */ -const transizioneTab = { type: "tween" as const, duration: 0.16, ease: [0.25, 0.1, 0.25, 1] }; +const transizioneTab: Transition = { type: "tween", duration: 0.16, ease: [0.25, 0.1, 0.25, 1] }; /** * Barra di sottosezioni in un'unica fila e pannello che mostra una sola sezione @@ -116,11 +116,7 @@ export function BarraSottosezioni({ }} className={cn( "min-h-11 touch-manipulation whitespace-nowrap text-sm font-bold uppercase tracking-wide transition-colors", - riempiLarghezza - ? "min-w-0 flex-1" - : sottolineatura - ? "shrink-0" - : "w-full", + riempiLarghezza ? "min-w-0 flex-1" : sottolineatura ? "shrink-0" : "w-full", sottolineatura ? cn( "rounded-xl px-1 py-2.5 text-center", diff --git a/src/components/crapp/EventoCard.tsx b/src/components/crapp/EventoCard.tsx index 993dadf..9d082d0 100644 --- a/src/components/crapp/EventoCard.tsx +++ b/src/components/crapp/EventoCard.tsx @@ -14,9 +14,9 @@ import { cn } from "@/lib/utils"; import { Card } from "@/components/crapp/ui-bits"; import { formatData, statoMeta, type Stato } from "@/lib/crapp-data"; import type { Evento } from "@/lib/eventi"; -import { useGiocatoriSquadra } from "@/lib/giocatori-squadra"; +import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; -import { useGiocatoreBase } from "@/lib/user-store"; +import { useGiocatoreInCampo } from "@/lib/user-store"; import { dataOggi } from "@/lib/scout-live"; const tipoMeta = { @@ -69,12 +69,12 @@ export function EventoCard({ }) { const { risposte } = usePresenzeEvento(evento.id); const salva = useSalvaPresenza(); - // Solo `io.id` serve qui (per leggere/scrivere la propria risposta): `useGiocatoreBase` - // legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`. + // Solo `io.id` serve qui (per leggere/scrivere la propria risposta, `null` per l'allenatore + // che non risponde, DD-034): `useGiocatoreInCampo` legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`. // Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme. - const io = useGiocatoreBase(); + const io = useGiocatoreInCampo(); const { righe: squadra } = useGiocatoriSquadra(); - const rosa = squadra.filter((g) => g.attivo); + const rosa = squadra.filter(inRosa); const stato = io ? risposte[io.id] : undefined; const presentiVeri = rosa.filter( (g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo", diff --git a/src/components/crapp/Pagelle.tsx b/src/components/crapp/Pagelle.tsx index 03309ba..484074e 100644 --- a/src/components/crapp/Pagelle.tsx +++ b/src/components/crapp/Pagelle.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"; import { Card } from "@/components/crapp/ui-bits"; import { Avatar } from "@/components/crapp/Avatar"; import type { Giocatore } from "@/lib/crapp-data"; -import { useGiocatoreBase } from "@/lib/user-store"; +import { useGiocatoreInCampo } from "@/lib/user-store"; import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle"; const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; @@ -20,8 +20,8 @@ export function Pagelle({ convocati: Giocatore[]; chiuse?: boolean; }) { - // Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. - const io = useGiocatoreBase(); + // Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). + const io = useGiocatoreInCampo(); const { voti: tutti, isPending } = usePagelle(); const vota = useVotaPagella(); const [apertoPer, setApertoPer] = useState(null); diff --git a/src/components/crapp/ProfiloAmministrativo.tsx b/src/components/crapp/ProfiloAmministrativo.tsx index 7e55fd5..a41c8ea 100644 --- a/src/components/crapp/ProfiloAmministrativo.tsx +++ b/src/components/crapp/ProfiloAmministrativo.tsx @@ -12,8 +12,10 @@ import { useSalvaProfilo, type SezioneFile, } from "@/lib/profili"; +import { useSalvaNomeAllenatore, type GiocatoreSquadra } from "@/lib/giocatori-squadra"; import { completamento, + completamentoAllenatore, profiloVuoto, sezioniComplete, type Profilo, @@ -244,6 +246,138 @@ export function CampiProfilo({ ); } +/** + * I dati personali dell'allenatore (DD-034): gli stessi campi di `profili_giocatore`, meno + * indirizzo, documento, certificato e foto tessera. Condivisi tra Profilo e dashboard admin. + */ +export function CampiAllenatore({ + corrente, + aggiorna, +}: { + corrente: Profilo; + aggiorna: (patch: Partial) => void; +}) { + return ( + <> +
+ + aggiorna({ dataNascita: e.target.value })} + className={classiInput} + /> + + + aggiorna({ luogoNascita: e.target.value })} + className={classiInput} + /> + +
+
+ + aggiorna({ telefono: e.target.value })} + className={classiInput} + /> + + + aggiorna({ email: e.target.value })} + className={classiInput} + /> + +
+ + ); +} + +/** + * Tab Docs dell'allenatore: nome e cognome (sul proprio slot, M21) più i dati personali + * ridotti. Un solo pulsante salva entrambi. + */ +export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) { + const { profili } = useProfili(); + const salvaProfilo = useSalvaProfilo(); + const salvaNome = useSalvaNomeAllenatore(); + const [bozza, setBozza] = useState(null); + const [nome, setNome] = useState<{ nome: string; cognome: string } | null>(null); + + const corrente = bozza ?? profili[g.id] ?? profiloVuoto(g.id); + const nomeCorrente = nome ?? { nome: g.nome, cognome: g.cognome }; + const sporco = bozza !== null || nome !== null; + const inCorso = salvaProfilo.isPending || salvaNome.isPending; + const perc = completamentoAllenatore(corrente); + + async function salva() { + try { + if (nome) await salvaNome.mutateAsync({ giocatoreId: g.id, ...nome }); + if (bozza) await salvaProfilo.mutateAsync(bozza); + setNome(null); + setBozza(null); + toast.success("Profilo aggiornato"); + } catch (errore) { + toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito"); + } + } + + return ( +
+
+
+
+
+ + {perc}% + +
+ + +
+ + setNome({ ...nomeCorrente, nome: e.target.value })} + className={classiInput} + /> + + + setNome({ ...nomeCorrente, cognome: e.target.value })} + className={classiInput} + /> + +
+ setBozza({ ...corrente, ...p })} /> + + +
+ ); +} + /** * Dati amministrativi del giocatore: quello che la dashboard amministratore poi legge. * Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente. @@ -380,13 +514,18 @@ export function ProfiloAmministrativo({ */ export function CompletaProfilo({ giocatoreId, + allenatore = false, indice = 0, }: { giocatoreId: string; + /** L'allenatore ha solo i dati personali ridotti (DD-034). */ + allenatore?: boolean; indice?: number; }) { const { profili, isPending } = useProfili(); - const perc = completamento(profili[giocatoreId]); + const perc = allenatore + ? completamentoAllenatore(profili[giocatoreId]) + : completamento(profili[giocatoreId]); if (isPending || perc === 100) return null; return ( @@ -409,7 +548,9 @@ export function CompletaProfilo({ />

- Documento, certificato medico e foto tessera servono per il tesseramento CSI. + {allenatore + ? "Data e luogo di nascita, telefono ed email." + : "Documento, certificato medico e foto tessera servono per il tesseramento CSI."}

diff --git a/src/components/crapp/PromemoriaPalloni.tsx b/src/components/crapp/PromemoriaPalloni.tsx index f5067a8..1de634a 100644 --- a/src/components/crapp/PromemoriaPalloni.tsx +++ b/src/components/crapp/PromemoriaPalloni.tsx @@ -3,12 +3,12 @@ import { formatData } from "@/lib/crapp-data"; import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core"; import { useTurniPalloni } from "@/lib/palloni"; import { useEventi } from "@/lib/eventi"; -import { useGiocatoreBase } from "@/lib/user-store"; +import { useGiocatoreInCampo } from "@/lib/user-store"; /** Avvisi per chi è di turno: prendere i palloni oggi, o riportarli oggi. */ export function PromemoriaPalloni() { - // Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. Montato in Home. - const io = useGiocatoreBase(); + // Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). Montato in Home. + const io = useGiocatoreInCampo(); const { turni } = useTurniPalloni(); const { eventi } = useEventi(); if (!io) return null; diff --git a/src/components/crapp/RosaPresenze.tsx b/src/components/crapp/RosaPresenze.tsx index 974c17d..bbbc15b 100644 --- a/src/components/crapp/RosaPresenze.tsx +++ b/src/components/crapp/RosaPresenze.tsx @@ -8,9 +8,9 @@ import { Barra } from "@/components/motion/Barra"; import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data"; import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { useAnagraficaRosa } from "@/lib/rosa"; -import { useGiocatoreBase } from "@/lib/user-store"; +import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store"; import { intestazioniAutenticate } from "@/lib/auth"; -import { useIsAdmin } from "@/lib/ruoli"; +import { usePuoGestireEventi } from "@/lib/ruoli"; import { dataOggi } from "@/lib/scout-live"; const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"]; @@ -20,8 +20,10 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin const salva = useSalvaPresenza(); // Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano, // niente statistiche di squadra. - const io = useGiocatoreBase(); - const admin = useIsAdmin(); + const io = useGiocatoreInCampo(); + // Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034). + const mittente = useGiocatoreBase(); + const puoSollecitare = usePuoGestireEventi(); const rosa = useAnagraficaRosa(); const [sollecito, setSollecito] = useState(false); const passato = data < dataOggi(); @@ -37,7 +39,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin const res = await fetch("/api/public/sollecita-presenze", { method: "POST", headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) }, - body: JSON.stringify({ eventoId, da: io?.nome }), + body: JSON.stringify({ eventoId, da: mittente?.nome }), }); if (!res.ok) throw new Error(); const dati = (await res.json()) as { inviate: number; destinatari: number }; @@ -115,7 +117,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin ) : null} - {admin ? ( + {puoSollecitare ? ( + + ); +} + /** * Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è * l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a @@ -57,18 +134,105 @@ export function Card({ * iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle, * cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni * pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte. + * + * Sopra l'avatar sta il pallino del centro notifiche (M17): il tap sull'avatar + * porta sempre al profilo, quello sul pallino apre il pannello. */ export function LinkProfilo() { const g = useGiocatoreBase(); if (!g) return ; return ( - - - +
+ + + + +
+ ); +} + +/** + * Pallino del centro notifiche (M17) sull'angolo dell'avatar (aspetto deciso da + * `pallinoNotifiche()`). Copre quattro sorgenti generate lato database (mai dal client): + * messaggi admin, promemoria automatici, turno palloni, sollecito presenze — vedi + * `src/lib/notifiche-utente.ts`. + * + * Nel progetto non c'è una libreria dropdown: il pannello è un `div` posizionato a mano, + * chiuso al click fuori o con Escape. Aprirlo segna tutte le notifiche come lette; per + * toglierle di mezzo per sempre serve lo swipe o la × di `RigaNotifica`. + */ +function PallinoNotifiche() { + const { notifiche, nonLette } = useNotificheMie(); + const segnaLette = useSegnaLette(); + const eliminaNotifica = useEliminaNotifica(); + const [aperto, setAperto] = useState(false); + const riquadro = useRef(null); + + useEffect(() => { + if (!aperto) return; + const chiudiSeFuori = (e: MouseEvent) => { + if (riquadro.current && !riquadro.current.contains(e.target as Node)) setAperto(false); + }; + const chiudiConEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") setAperto(false); + }; + document.addEventListener("mousedown", chiudiSeFuori); + document.addEventListener("keydown", chiudiConEsc); + return () => { + document.removeEventListener("mousedown", chiudiSeFuori); + document.removeEventListener("keydown", chiudiConEsc); + }; + }, [aperto]); + + const pallino = pallinoNotifiche(notifiche.length, nonLette); + if (!pallino) return null; + + function alClick() { + const stavaChiuso = !aperto; + setAperto(stavaChiuso); + if (stavaChiuso && nonLette > 0) segnaLette.mutate(); + } + + // Tolta l'ultima, il pallino sparisce: chiuso anche il pannello, così alla prossima + // notifica non si riapre da solo. + function elimina(id: string) { + if (notifiche.length === 1) setAperto(false); + eliminaNotifica.mutate(id); + } + + return ( +
+ {/* `after:` allarga l'area di tocco oltre i 20px del pallino senza ingrandirlo. */} + + {aperto ? ( +
+
    + + {notifiche.map((n) => ( + + ))} + +
+
+ ) : null} +
); } diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index abf4dbf..899b26b 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -637,7 +637,7 @@ export type Database = { }; }; Enums: { - app_role: "admin" | "user"; + app_role: "admin" | "user" | "allenatore"; }; CompositeTypes: { [_ in never]: never; @@ -762,7 +762,7 @@ export const Constants = { }, public: { Enums: { - app_role: ["admin", "user"], + app_role: ["admin", "user", "allenatore"], }, }, } as const; diff --git a/src/lib/auth-route.server.ts b/src/lib/auth-route.server.ts index 94cbac2..f650272 100644 --- a/src/lib/auth-route.server.ts +++ b/src/lib/auth-route.server.ts @@ -7,9 +7,9 @@ * è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non * può fare da credenziale. * - * Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli - * amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della - * sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può + * Le route che mandano notifiche partono da un pulsante riservato agli amministratori, e il + * sollecito presenze anche agli allenatori (DD-034): `richiediAdmin` e `richiediGestoreEventi` + * verificano il token della sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può * proseguire, altrimenti la `Response` di rifiuto già pronta. */ @@ -24,10 +24,14 @@ function tokenDaRichiesta(request: Request): string | null { } /** - * Lascia passare solo un amministratore autenticato. `401` se manca o non vale il token, - * `403` se il token è buono ma l'utente non è admin. + * Lascia passare solo un utente autenticato con almeno uno dei `ruoli`. `401` se manca o non + * vale il token, `403` se il token è buono ma il ruolo no. */ -export async function richiediAdmin(request: Request): Promise { +async function richiediRuolo( + request: Request, + ruoli: Array<"admin" | "allenatore">, + rifiuto: string, +): Promise { const token = tokenDaRichiesta(request); if (!token) return new Response("Autenticazione richiesta", { status: 401 }); @@ -37,13 +41,22 @@ export async function richiediAdmin(request: Request): Promise if (error || !utente?.user) return new Response("Sessione non valida", { status: 401 }); // Stessa fonte di `src/lib/ruoli.ts`: i permessi stanno solo in `user_roles` (DD-011). - const { data: ruolo } = await supabaseAdmin + const { data: righe } = await supabaseAdmin .from("user_roles") .select("role") .eq("user_id", utente.user.id) - .eq("role", "admin") - .maybeSingle(); + .in("role", ruoli); - if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 }); + if (!righe?.length) return new Response(rifiuto, { status: 403 }); return null; } + +/** Solo un amministratore: promemoria palloni, messaggi, sondaggio, notifiche attive. */ +export function richiediAdmin(request: Request): Promise { + return richiediRuolo(request, ["admin"], "Riservato agli amministratori"); +} + +/** Admin o allenatore: chi gestisce gli eventi può anche sollecitare le presenze (DD-034). */ +export function richiediGestoreEventi(request: Request): Promise { + return richiediRuolo(request, ["admin", "allenatore"], "Riservato a admin e allenatori"); +} diff --git a/src/lib/calendario.ts b/src/lib/calendario.ts new file mode 100644 index 0000000..c6ec8f7 --- /dev/null +++ b/src/lib/calendario.ts @@ -0,0 +1,63 @@ +import { useState } from "react"; + +/** Etichette condivise tra `/calendario` (sola lettura) ed `/eventi` (griglia di gestione). */ +export const giorniIT = ["L", "M", "M", "G", "V", "S", "D"]; + +export const mesiIT = [ + "Gennaio", + "Febbraio", + "Marzo", + "Aprile", + "Maggio", + "Giugno", + "Luglio", + "Agosto", + "Settembre", + "Ottobre", + "Novembre", + "Dicembre", +] as const; + +export function pad2(n: number) { + return String(n).padStart(2, "0"); +} + +/** Numero di giorni nel mese (0-indicizzato) e offset del primo giorno rispetto a lunedì. */ +export function giorniDelMese(anno: number, mese: number) { + const giorni = new Date(Date.UTC(anno, mese + 1, 0)).getUTCDate(); + const primoGiorno = new Date(Date.UTC(anno, mese, 1)).getUTCDay(); + const offsetLunedi = (primoGiorno + 6) % 7; + return { giorni, offsetLunedi }; +} + +/** Stato di navigazione mese per mese, condiviso dalle griglie calendario. */ +export function useMeseNav(initial?: { anno: number; mese: number }) { + const oggi = new Date(); + const [anno, setAnno] = useState(initial?.anno ?? oggi.getFullYear()); + const [mese, setMese] = useState(initial?.mese ?? oggi.getMonth()); + // Serve a far entrare e uscire la griglia dallo stesso lato del gesto: + // se un mese esce a sinistra, il precedente deve rientrare da sinistra. + const [direzione, setDirezione] = useState(0); + + const precedente = () => { + setDirezione(-1); + if (mese === 0) { + setMese(11); + setAnno((a) => a - 1); + } else { + setMese((m) => m - 1); + } + }; + + const successivo = () => { + setDirezione(1); + if (mese === 11) { + setMese(0); + setAnno((a) => a + 1); + } else { + setMese((m) => m + 1); + } + }; + + return { anno, mese, direzione, precedente, successivo }; +} diff --git a/src/lib/crapp-data.ts b/src/lib/crapp-data.ts index 22591ef..b821c2e 100644 --- a/src/lib/crapp-data.ts +++ b/src/lib/crapp-data.ts @@ -122,14 +122,6 @@ export const giocatori: Giocatore[] = rosaCSI })) .sort((a, b) => dividiNome(a.nome).cognome.localeCompare(dividiNome(b.nome).cognome, "it")); -/** - * Fallback per la data di nascita: `giocatori_squadra` non ha ancora questa colonna - * (DD-015 follow-up). Un giocatore aggiunto dopo la migrazione non ha nascita nota. - */ -export const nascitaPerId: Record = Object.fromEntries( - giocatori.map((g) => [g.id, g.nascita]), -); - export type RigaClassifica = { pos: number; squadra: string; diff --git a/src/lib/csi-core.ts b/src/lib/csi-core.ts index 54e9dbc..3120c33 100644 --- a/src/lib/csi-core.ts +++ b/src/lib/csi-core.ts @@ -21,6 +21,43 @@ export const CSI_TEAM_ID = 3359; export const CSI_GIRONE = "Girone B"; export const CSI_NOME_SQUADRA = "C.R.A.P. Volley"; +/** + * Stagione sportiva di una data `YYYY-MM-DD`, nel formato "2025/26". Il portale CSI non + * espone il nome della stagione (né nella classifica né nel calendario della squadra): + * si ricava dalla data della gara, con l'anno che cambia ad agosto — le gare di + * settembre-dicembre aprono la stagione, quelle di gennaio-luglio la chiudono. + */ +export function stagioneDi(dataIso: string): string { + const anno = Number(dataIso.slice(0, 4)); + const mese = Number(dataIso.slice(5, 7)); + const inizio = mese >= 8 ? anno : anno - 1; + return `${inizio}/${String((inizio + 1) % 100).padStart(2, "0")}`; +} + +/** Stagione del campionato letto dal CSI: quella della prima gara, null se non ce ne sono. */ +export function stagioneDaPartite(partite: ReadonlyArray<{ data: string }>): string | null { + const date = partite.map((p) => p.data).filter((d) => /^\d{4}-\d{2}/.test(d)); + if (date.length === 0) return null; + return stagioneDi(date.reduce((a, b) => (a < b ? a : b))); +} + +/** + * Storico partite diviso per stagione, nell'ordine in cui arrivano: le gare scoutate in + * locale possono essere di stagioni diverse, quelle CSI sono tutte dello stesso campionato. + */ +export function raggruppaPerStagione( + match: readonly T[], +): Array<{ stagione: string; match: T[] }> { + const gruppi: Array<{ stagione: string; match: T[] }> = []; + for (const m of match) { + const stagione = stagioneDi(m.data); + const gruppo = gruppi.find((g) => g.stagione === stagione); + if (gruppo) gruppo.match.push(m); + else gruppi.push({ stagione, match: [m] }); + } + return gruppi; +} + export const urlClassifica = (projectId = CSI_PROJECT_ID) => `${CSI_BASE}/components/project-sheets.php?project_id=${projectId}`; export const urlPartite = (teamId = CSI_TEAM_ID) => diff --git a/src/lib/eventi.ts b/src/lib/eventi.ts index cfe3ba9..a68d851 100644 --- a/src/lib/eventi.ts +++ b/src/lib/eventi.ts @@ -104,7 +104,7 @@ export function eventoVuoto(): Evento { id: nuovoIdEvento(), tipo: "allenamento", titolo: "", - luogo: "Palestra Comunale", + luogo: "", data: new Date().toISOString().slice(0, 10), ora: "20:30", note: "", diff --git a/src/lib/giocatori-squadra.ts b/src/lib/giocatori-squadra.ts index 469b348..7df6258 100644 --- a/src/lib/giocatori-squadra.ts +++ b/src/lib/giocatori-squadra.ts @@ -7,10 +7,14 @@ import { dividiNome, giocatori } from "./crapp-data"; * truth per il collegamento account ↔ giocatore; `crapp-data.ts` resta il fallback finché * la migrazione non è completa (DD-016 regola 1). */ +/** Chi gioca e chi allena (DD-034): l'allenatore ha uno slot ma non è nella rosa di gioco. */ +export type TipoMembro = "giocatore" | "allenatore"; + export type GiocatoreSquadra = { id: string; nome: string; cognome: string; + /** 0 per l'allenatore, che non ha numero di maglia (a database è NULL). */ numero: number; ruolo: string; authUserId: string | null; @@ -18,19 +22,24 @@ export type GiocatoreSquadra = { email: string | null; numeroTessera: string | null; dataTessera: string | null; + /** Pubblica a tutta la rosa (M18): sincronizzata da `profili_giocatore.data_nascita`. */ + nascita: string | null; + tipo: TipoMembro; }; export type RigaGiocatoreSquadra = { id: string; nome: string; cognome: string; - numero: number; + numero: number | null; ruolo: string; auth_user_id: string | null; attivo: boolean; email: string | null; numero_tessera: string | null; data_tessera: string | null; + nascita: string | null; + tipo: TipoMembro; }; /** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */ @@ -50,6 +59,8 @@ export function rosaFallback(): GiocatoreSquadra[] { email: null, numeroTessera: null, dataTessera: null, + nascita: g.nascita, + tipo: "giocatore" as const, })); } @@ -57,6 +68,24 @@ export function nomeCompleto(g: GiocatoreSquadra): string { return `${g.nome} ${g.cognome}`.trim(); } +export function isAllenatore(g: Pick | null | undefined): boolean { + return g?.tipo === "allenatore"; +} + +/** + * Rosa di gioco: giocatori attivi, senza allenatori (DD-034). È il filtro di presenze, + * convocati, voti, palloni, classifiche e badge; gli allenatori compaiono solo dove la + * specifica lo dice (Rosa di Squadra, compleanni, promemoria evento). + */ +export function inRosa(g: Pick): boolean { + return g.attivo && g.tipo === "giocatore"; +} + +/** Dove un giocatore ha il ruolo in campo, l'allenatore ha scritto «Allenatore». */ +export function ruoloVisibile(g: Pick): string { + return g.tipo === "allenatore" ? "Allenatore" : g.ruolo; +} + /** Lo slot già collegato a questo account, se esiste. */ export function slotDi( righe: GiocatoreSquadra[], @@ -77,7 +106,7 @@ export function slotPerEmail( } export const COLONNE_SQUADRA = - "id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera"; + "id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera, nascita, tipo"; /** Conversione riga database -> modello applicativo (riusabile anche lato server). */ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra { @@ -85,13 +114,15 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra { id: r.id, nome: r.nome, cognome: r.cognome, - numero: r.numero, + numero: r.numero ?? 0, ruolo: r.ruolo, authUserId: r.auth_user_id, attivo: r.attivo, email: r.email, numeroTessera: r.numero_tessera, dataTessera: r.data_tessera, + nascita: r.nascita, + tipo: r.tipo === "allenatore" ? "allenatore" : "giocatore", }; } @@ -115,7 +146,10 @@ export function useGiocatoriSquadra() { /** Dati squadra: li gestisce solo un amministratore (DD-017). L'email è quella usata per * il collegamento automatico al primo accesso (DD-018), non il dato personale del profilo. */ -export type DatiSquadra = Pick; +export type DatiSquadra = Pick< + GiocatoreSquadra, + "nome" | "cognome" | "numero" | "ruolo" | "email" | "tipo" +>; /** * Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori): @@ -125,6 +159,11 @@ export type DatiSquadra = Pick g.id !== giocatoreId && g.attivo && g.numero === numero); + return righe.some((g) => g.id !== giocatoreId && inRosa(g) && g.numero === numero); +} + +/** Riga da scrivere: all'allenatore numero NULL e ruolo vuoto, il tipo non cambia qui. */ +function rigaDatiSquadra(dati: DatiSquadra) { + const allenatore = dati.tipo === "allenatore"; + return { + nome: dati.nome.trim(), + cognome: dati.cognome.trim(), + numero: allenatore ? null : dati.numero, + ruolo: allenatore ? "" : dati.ruolo.trim(), + email: dati.email?.trim() || null, + }; } /** Modifica dei dati squadra. Solo un admin passa le policy di M1. */ @@ -155,19 +206,13 @@ export function useSalvaDatiSquadra() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => { - const dati = { - nome: input.dati.nome.trim(), - cognome: input.dati.cognome.trim(), - numero: input.dati.numero, - ruolo: input.dati.ruolo.trim(), - email: input.dati.email?.trim() || null, - }; + const riga = rigaDatiSquadra(input.dati); const { error } = await supabaseNuoveTabelle .from("giocatori_squadra") - .update(dati) + .update(riga) .eq("id", input.giocatoreId); if (error) throw error; - return { giocatoreId: input.giocatoreId, dati }; + return { giocatoreId: input.giocatoreId, dati: { ...riga, numero: riga.numero ?? 0 } }; }, onSuccess: (input) => { queryClient.setQueryData(SQUADRA_KEY, (prec) => @@ -220,18 +265,19 @@ export function useAggiungiGiocatore() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (input: { id: string; dati: DatiSquadra }): Promise => { - const riga = { - id: input.id, - nome: input.dati.nome.trim(), - cognome: input.dati.cognome.trim(), - numero: input.dati.numero, - ruolo: input.dati.ruolo.trim(), - email: input.dati.email?.trim() || null, - }; + const riga = { id: input.id, tipo: input.dati.tipo, ...rigaDatiSquadra(input.dati) }; const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga); if (error) throw error; // Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL. - return { ...riga, authUserId: null, attivo: true, numeroTessera: null, dataTessera: null }; + return { + ...riga, + numero: riga.numero ?? 0, + authUserId: null, + attivo: true, + numeroTessera: null, + dataTessera: null, + nascita: null, + }; }, // Aggiornamento locale della cache: nessuna rilettura, stesso ordine della query // (`.order("cognome").order("nome")`). @@ -245,6 +291,32 @@ export function useAggiungiGiocatore() { }); } +/** + * Nome e cognome scritti dall'allenatore sul proprio slot (DD-034): per un giocatore sono + * dati squadra dell'admin, e il trigger di M21 rifiuta la stessa scrittura. + */ +export function useSalvaNomeAllenatore() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { giocatoreId: string; nome: string; cognome: string }) => { + const dati = { nome: input.nome.trim(), cognome: input.cognome.trim() }; + if (!dati.nome) throw new Error("Il nome non può essere vuoto."); + if (!dati.cognome) throw new Error("Il cognome non può essere vuoto."); + const { error } = await supabaseNuoveTabelle + .from("giocatori_squadra") + .update(dati) + .eq("id", input.giocatoreId); + if (error) throw error; + return { giocatoreId: input.giocatoreId, dati }; + }, + onSuccess: (input) => { + queryClient.setQueryData(SQUADRA_KEY, (prec) => + (prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)), + ); + }, + }); +} + /** * Attiva o disattiva un giocatore (es. ha lasciato la squadra): non elimina la riga, così * presenze, voti, pagelle e badge della stagione restano agganciati al suo id. diff --git a/src/lib/notifiche-admin.ts b/src/lib/notifiche-admin.ts index 6fd4954..1e45061 100644 --- a/src/lib/notifiche-admin.ts +++ b/src/lib/notifiche-admin.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { intestazioniAutenticate } from "./auth"; const NOTIFICHE_ATTIVE_KEY = ["notifiche-attive"] as const; @@ -20,3 +20,18 @@ export function useNotificheAttive() { staleTime: 5 * 60_000, }); } + +/** Invia un messaggio push libero a un giocatore, o a tutti se `giocatoreId` è omesso. */ +export function useInviaNotifica() { + return useMutation({ + mutationFn: async ({ messaggio, giocatoreId }: { messaggio: string; giocatoreId?: string }) => { + const res = await fetch("/api/public/notifica-personalizzata", { + method: "POST", + headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) }, + body: JSON.stringify({ messaggio, giocatoreId }), + }); + if (!res.ok) throw new Error("Impossibile inviare la notifica"); + return (await res.json()) as { inviate: number; destinatari: number }; + }, + }); +} diff --git a/src/lib/notifiche-utente.ts b/src/lib/notifiche-utente.ts new file mode 100644 index 0000000..5397520 --- /dev/null +++ b/src/lib/notifiche-utente.ts @@ -0,0 +1,145 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle"; +import { useGiocatoreId } from "./user-store"; + +/** + * Centro notifiche in-app (M17): pallino sull'avatar del profilo, con il numero delle non + * lette. Quattro sorgenti riempiono `notifiche_utente` (lato database, mai dal client): un + * messaggio libero dell'admin, un promemoria automatico prima di un evento (24h e 3h), il + * turno palloni e il sollecito presenze (questi due avviati a mano da un admin, in + * parallelo alla push esistente). Qui si legge e si segna come letto, con + * `supabaseNuoveTabelle` perché `types.ts` non include ancora questa tabella (vedi + * client-nuove-tabelle.ts). + */ +export type TipoNotifica = + | "admin" + | "evento_promemoria_24h" + | "evento_promemoria_3h" + | "turno_palloni" + | "sollecita_presenze"; + +export type NotificaUtente = { + id: string; + tipo: TipoNotifica; + titolo: string; + corpo: string; + eventoId: string | null; + letta: boolean; + creataIl: string; +}; + +export type RigaNotifica = { + id: string; + tipo: TipoNotifica; + titolo: string; + corpo: string; + evento_id: string | null; + letta: boolean; + creato_il: string; +}; + +const COLONNE = "id, tipo, titolo, corpo, evento_id, letta, creato_il"; + +/** Conversione riga database -> modello applicativo. */ +export function daRiga(r: RigaNotifica): NotificaUtente { + return { + id: r.id, + tipo: r.tipo, + titolo: r.titolo, + corpo: r.corpo, + eventoId: r.evento_id, + letta: r.letta, + creataIl: r.creato_il, + }; +} + +function chiaveNotifiche(giocatoreId: string | null) { + return ["notifiche-utente", giocatoreId] as const; +} + +/** Ultime notifiche del giocatore selezionato, più recenti per prime. */ +export function useNotificheMie() { + const giocatoreId = useGiocatoreId(); + const query = useQuery({ + queryKey: chiaveNotifiche(giocatoreId), + queryFn: async (): Promise => { + const { data, error } = await supabaseNuoveTabelle + .from("notifiche_utente") + .select(COLONNE) + .eq("giocatore_id", giocatoreId) + .order("creato_il", { ascending: false }) + .limit(30); + if (error) throw error; + return ((data ?? []) as RigaNotifica[]).map(daRiga); + }, + enabled: !!giocatoreId, + staleTime: 30_000, + refetchInterval: 60_000, + }); + const notifiche = query.data ?? []; + return { ...query, notifiche, nonLette: notifiche.filter((n) => !n.letta).length }; +} + +/** Segna come lette tutte le notifiche non lette del giocatore selezionato. */ +export function useSegnaLette() { + const giocatoreId = useGiocatoreId(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => { + if (!giocatoreId) return; + const { error } = await supabaseNuoveTabelle + .from("notifiche_utente") + .update({ letta: true }) + .eq("giocatore_id", giocatoreId) + .eq("letta", false); + if (error) throw error; + }, + onSuccess: () => { + qc.setQueryData(chiaveNotifiche(giocatoreId), (prec) => + (prec ?? []).map((n) => ({ ...n, letta: true })), + ); + }, + }); +} + +/** + * Elimina una notifica del giocatore selezionato (swipe o pulsante nel pannello). A + * differenza di "segna come letta" qui la riga sparisce anche dal database, non solo + * dalla vista: non c'è pulizia automatica delle notifiche vecchie, quindi eliminarla è + * l'unico modo per un giocatore di toglierla di mezzo per sempre. + */ +export function useEliminaNotifica() { + const giocatoreId = useGiocatoreId(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + const { error } = await supabaseNuoveTabelle.from("notifiche_utente").delete().eq("id", id); + if (error) throw error; + return id; + }, + onSuccess: (id) => { + qc.setQueryData(chiaveNotifiche(giocatoreId), (prec) => + (prec ?? []).filter((n) => n.id !== id), + ); + }, + }); +} + +/** Testo del badge: il conteggio esatto fino a 9, poi "9+" per non farlo esplodere. */ +export function contatoreBadge(nonLette: number): string { + return nonLette > 9 ? "9+" : String(nonLette); +} + +/** + * Pallino sull'avatar del profilo: rosso con il numero delle non lette; se sono tutte lette + * resta neutro con il totale, altrimenti le notifiche già lette non sarebbero più + * raggiungibili per eliminarle. Senza notifiche non c'è (`null`). + */ +export function pallinoNotifiche( + totale: number, + nonLette: number, +): { testo: string; daLeggere: boolean } | null { + if (totale === 0) return null; + if (nonLette > 0) return { testo: contatoreBadge(nonLette), daLeggere: true }; + return { testo: contatoreBadge(totale), daLeggere: false }; +} diff --git a/src/lib/palloni.ts b/src/lib/palloni.ts index 9b49067..81928be 100644 --- a/src/lib/palloni.ts +++ b/src/lib/palloni.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; import { completaTurni } from "./palloni-core"; import { useEventi } from "./eventi"; -import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra"; +import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra"; export const TURNI_KEY = ["turni-palloni"] as const; @@ -29,7 +29,7 @@ export function useTurniPalloni() { const { eventi } = useEventi(); const { righe: squadra } = useGiocatoriSquadra(); const salvati = query.data ?? {}; - const rosa = squadra.filter((g) => g.attivo).map((g) => ({ id: g.id, nome: nomeCompleto(g) })); + const rosa = squadra.filter(inRosa).map((g) => ({ id: g.id, nome: nomeCompleto(g) })); return { ...query, salvati, turni: completaTurni(salvati, eventi, rosa) }; } diff --git a/src/lib/presenze-mese.ts b/src/lib/presenze-mese.ts index 5d8cfcb..ff8d66c 100644 --- a/src/lib/presenze-mese.ts +++ b/src/lib/presenze-mese.ts @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useEventi } from "./eventi"; import { useRispostePresenze } from "./presenze"; -import { useGiocatoriSquadra } from "./giocatori-squadra"; +import { inRosa, useGiocatoriSquadra } from "./giocatori-squadra"; import { presenzeUltimoMese, presenzeUltimoMeseTutti, @@ -30,7 +30,7 @@ export function usePresenzeUltimoMeseTutti(): Record { - const idRosa = squadra.filter((g) => g.attivo).map((g) => g.id); + const idRosa = squadra.filter(inRosa).map((g) => g.id); return presenzeUltimoMeseTutti(eventi, presenze, idRosa); }, [eventi, presenze, squadra]); } diff --git a/src/lib/presenze.ts b/src/lib/presenze.ts index a2f3cc7..dcda46d 100644 --- a/src/lib/presenze.ts +++ b/src/lib/presenze.ts @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; import type { Stato } from "./crapp-data"; import type { Evento } from "./eventi"; +import { inRosa, type TipoMembro } from "./giocatori-squadra"; import { aggiornaSerie } from "./serie"; import { dataOggi } from "./scout-live"; @@ -71,17 +72,18 @@ export function totaliEventiGiocatore( } /** - * Chi va sollecitato per un evento: i giocatori attivi che non hanno ancora risposto, più + * Chi va sollecitato per un evento: i giocatori attivi (non gli allenatori) che non hanno ancora risposto, più * quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i * palloni: la route `/api/public/sollecita-presenze` la chiama con i dati che ha già letto. */ export function destinatariSollecito( - squadra: Array<{ id: string; attivo: boolean }>, + squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>, risposte: Array<{ giocatore_id: string; stato: string }>, ): string[] { const stati = new Map(risposte.map((r) => [r.giocatore_id, r.stato])); + // L'allenatore non risponde alle presenze, quindi non va sollecitato (DD-034). return squadra - .filter((g) => g.attivo) + .filter(inRosa) .filter((g) => { const stato = stati.get(g.id); return stato === undefined || stato === "forse"; diff --git a/src/lib/profili-core.ts b/src/lib/profili-core.ts index ae40c07..20d8e86 100644 --- a/src/lib/profili-core.ts +++ b/src/lib/profili-core.ts @@ -144,6 +144,18 @@ export function completamento(p: Profilo | null | undefined): number { ); } +/** + * L'allenatore compila solo i dati personali ridotti (DD-034): niente indirizzo, documento, + * certificato né foto tessera, che servono al tesseramento dei giocatori. + */ +export const CAMPI_ALLENATORE = ["dataNascita", "luogoNascita", "telefono", "email"] as const; + +/** Percentuale dei campi dell'allenatore compilati: 100 quando ci sono tutti. */ +export function completamentoAllenatore(p: Profilo | null | undefined): number { + const pieni = CAMPI_ALLENATORE.filter((c) => !!p?.[c]?.trim()).length; + return Math.round((pieni / CAMPI_ALLENATORE.length) * 100); +} + export type StatoScadenza = "mancante" | "scaduto" | "valido"; /** Un certificato scaduto blocca il tesseramento: per l'admin non vale come presente. */ diff --git a/src/lib/rosa.ts b/src/lib/rosa.ts index 6f184e4..6e8ae58 100644 --- a/src/lib/rosa.ts +++ b/src/lib/rosa.ts @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import { nascitaPerId, type Giocatore } from "./crapp-data"; -import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra"; +import type { Giocatore } from "./crapp-data"; +import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra"; import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti"; import { mediePagelle, usePagelle } from "./pagelle"; import { statisticheCacche, useCacche } from "./cacche"; @@ -27,26 +27,29 @@ function iniziali(nome: string, cognome: string): string { /** * Solo anagrafica (id, nome, ruolo, numero, data di nascita) dei giocatori attivi — es. - * per i compleanni nel Calendario o le liste presenze. A differenza di `useRosa` non - * legge MVP, pagelle, cacche, palloni né infortuni: evita di montare quei cinque hook e - * il relativo `useMemo` solo per l'anagrafica. + * per le liste presenze o lo scout. A differenza di `useRosa` non legge MVP, pagelle, + * cacche, palloni né infortuni: evita di montare quei cinque hook e il relativo `useMemo` + * solo per l'anagrafica. Con `conAllenatori` include anche gli allenatori attivi, per chi + * elenca i membri della squadra e non solo chi gioca (compleanni in Calendario, DD-034). */ -export function useAnagraficaRosa(): Array< +export function useAnagraficaRosa({ + conAllenatori = false, +}: { conAllenatori?: boolean } = {}): Array< Pick > { const { righe: squadra } = useGiocatoriSquadra(); return useMemo( () => squadra - .filter((g) => g.attivo) + .filter((g) => (conAllenatori ? g.attivo : inRosa(g))) .map((g) => ({ id: g.id, nome: nomeCompleto(g), ruolo: g.ruolo, numero: g.numero, - nascita: nascitaPerId[g.id] ?? "", + nascita: g.nascita ?? "", })), - [squadra], + [squadra, conAllenatori], ); } @@ -54,7 +57,7 @@ export function useAnagraficaRosa(): Array< * Rosa completa con tutte le statistiche personali (presenze, MVP, media voto, * palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra` * (DD-015): solo i giocatori attivi, gli altri restano nel database ma spariscono - * dagli elenchi correnti. Usa solo cache già in memoria: nessuna query aggiuntiva + * dagli elenchi correnti. Gli allenatori non ci sono (DD-034): non hanno statistiche. Usa solo cache già in memoria: nessuna query aggiuntiva * rispetto a quelle che l'app fa comunque. */ export function useRosa(): Giocatore[] { @@ -67,7 +70,7 @@ export function useRosa(): Giocatore[] { const { eventi } = useEventi(); const { presenze: mappaPresenze, tempi } = useRispostePresenze(); - const votiMvp = voti.data ?? []; + const votiMvp = voti.data; return useMemo(() => { const medie = mediePagelle(pagelle); @@ -76,34 +79,32 @@ export function useRosa(): Giocatore[] { // premiare chi ha davvero portato i palloni, non chi l'algoritmo di rotazione ha // scelto per un evento passato senza che nessuno confermasse nulla. const palloni = conteggioTurni(turniSalvati, eventi); - const mvpVinti = mvpVintiPerGiocatore(votiMvp); + const mvpVinti = mvpVintiPerGiocatore(votiMvp ?? []); - return squadra - .filter((g) => g.attivo) - .map((g) => ({ - id: g.id, - nome: nomeCompleto(g), - numero: g.numero, - ruolo: g.ruolo, - nascita: nascitaPerId[g.id] ?? "", - iniziali: iniziali(g.nome, g.cognome), - presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze), - totaliEventi: totaliEventiGiocatore(g.id, eventi), - partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze), - streak: serieConsecutiva(g.id, eventi, mappaPresenze), - serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"), - seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"), - serieConferme: serieConferme(g.id, eventi, tempi), - mvp: mvpVinti[g.id] ?? 0, - mediaVoto: medie[g.id]?.media ?? 0, - votiPagella: medie[g.id]?.voti ?? 0, - palloni: palloni[g.id] ?? 0, - seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi), - cacche: statCacche[g.id]?.giornateTop ?? 0, - cacchePartita: statCacche[g.id]?.media ?? 0, - infortuni: infortuni[g.id] ?? 0, - ritardi: ritardi[g.id] ?? 0, - })); + return squadra.filter(inRosa).map((g) => ({ + id: g.id, + nome: nomeCompleto(g), + numero: g.numero, + ruolo: g.ruolo, + nascita: g.nascita ?? "", + iniziali: iniziali(g.nome, g.cognome), + presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze), + totaliEventi: totaliEventiGiocatore(g.id, eventi), + partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze), + streak: serieConsecutiva(g.id, eventi, mappaPresenze), + serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"), + seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"), + serieConferme: serieConferme(g.id, eventi, tempi), + mvp: mvpVinti[g.id] ?? 0, + mediaVoto: medie[g.id]?.media ?? 0, + votiPagella: medie[g.id]?.voti ?? 0, + palloni: palloni[g.id] ?? 0, + seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi), + cacche: statCacche[g.id]?.giornateTop ?? 0, + cacchePartita: statCacche[g.id]?.media ?? 0, + infortuni: infortuni[g.id] ?? 0, + ritardi: ritardi[g.id] ?? 0, + })); }, [ squadra, votiMvp, diff --git a/src/lib/ruoli.ts b/src/lib/ruoli.ts index bc1c25c..4ae1dbf 100644 --- a/src/lib/ruoli.ts +++ b/src/lib/ruoli.ts @@ -2,33 +2,49 @@ import { useQuery } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; import { useSessione } from "./auth"; -export const RUOLI_KEY = ["ruolo-admin"] as const; +export const RUOLI_KEY = ["ruoli-utente"] as const; + +export type Ruolo = "admin" | "allenatore"; /** - * Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011). - * Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare. + * Permessi: unica fonte è `user_roles` nel database (DD-011). Nessuna lista di nomi, + * altrimenti basterebbe scegliere il nome giusto per amministrare. Il ruolo `allenatore` + * lo scrive un trigger quando si collega uno slot di tipo allenatore (M21, DD-034). */ /** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */ -async function fetchRuoloAdmin(utenteId: string | null): Promise { +async function fetchRuoli(utenteId: string | null): Promise { if (!utenteId) return null; const { data, error } = await supabase .from("user_roles") .select("role") .eq("user_id", utenteId) - .eq("role", "admin") - .maybeSingle(); + .in("role", ["admin", "allenatore"]); if (error) throw error; - return !!data; + return (data ?? []).map((r) => r.role as Ruolo); } -export function useIsAdmin(): boolean { +function useRuoli(): Ruolo[] { const { utenteId } = useSessione(); // Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta. const query = useQuery({ queryKey: [...RUOLI_KEY, utenteId], - queryFn: () => fetchRuoloAdmin(utenteId), + queryFn: () => fetchRuoli(utenteId), staleTime: 30 * 60_000, }); - return query.data === true; + return query.data ?? []; +} + +export function useIsAdmin(): boolean { + return useRuoli().includes("admin"); +} + +export function useIsAllenatore(): boolean { + return useRuoli().includes("allenatore"); +} + +/** Chi crea e modifica gli eventi e sollecita le presenze: admin o allenatore (DD-034). */ +export function usePuoGestireEventi(): boolean { + const ruoli = useRuoli(); + return ruoli.includes("admin") || ruoli.includes("allenatore"); } diff --git a/src/lib/user-store.ts b/src/lib/user-store.ts index 3b3c4c9..10d6dd5 100644 --- a/src/lib/user-store.ts +++ b/src/lib/user-store.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from "react"; import type { Giocatore } from "./crapp-data"; -import { useGiocatoriSquadra, type GiocatoreSquadra } from "./giocatori-squadra"; +import { isAllenatore, useGiocatoriSquadra, type GiocatoreSquadra } from "./giocatori-squadra"; import { useIo } from "./rosa"; const KEY = "crapp-user-v1"; @@ -56,6 +56,23 @@ export function useGiocatoreBase(): GiocatoreSquadra | null { return id ? (righe.find((x) => x.id === id) ?? null) : null; } +/** + * Chi usa l'app è un allenatore (DD-034): decide cosa si vede (niente badge, cacche, + * stagione, obiettivi). I permessi veri stanno in `user_roles` (`usePuoGestireEventi`). + */ +export function useSonoAllenatore(): boolean { + return isAllenatore(useGiocatoreBase()); +} + +/** + * Il proprio slot solo se è di un giocatore: per rispondere alle presenze, votare, + * dichiarare le cacche o portare i palloni. Per l'allenatore è `null`. + */ +export function useGiocatoreInCampo(): GiocatoreSquadra | null { + const g = useGiocatoreBase(); + return g && !isAllenatore(g) ? g : null; +} + /** Giocatore corrente con statistiche calcolate da dati reali (presenze, MVP, badge, …). */ export function useGiocatoreCorrente(): Giocatore | null { return useIo(); diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index b99d831..af1cfbf 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id' import { Route as PartitaIdRouteImport } from './routes/partita.$id' import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio' import { Route as ApiPublicCsiRouteImport } from './routes/api/public/csi' +import { Route as ApiPublicNotificaPersonalizzataRouteImport } from './routes/api/public/notifica-personalizzata' import { Route as ApiPublicNotificheAttiveRouteImport } from './routes/api/public/notifiche-attive' import { Route as ApiPublicPromemoriaPalloniRouteImport } from './routes/api/public/promemoria-palloni' import { Route as ApiPublicPushConfigRouteImport } from './routes/api/public/push-config' @@ -100,6 +101,12 @@ const ApiPublicCsiRoute = ApiPublicCsiRouteImport.update({ path: '/api/public/csi', getParentRoute: () => rootRouteImport, } as any) +const ApiPublicNotificaPersonalizzataRoute = + ApiPublicNotificaPersonalizzataRouteImport.update({ + id: '/api/public/notifica-personalizzata', + path: '/api/public/notifica-personalizzata', + getParentRoute: () => rootRouteImport, + } as any) const ApiPublicNotificheAttiveRoute = ApiPublicNotificheAttiveRouteImport.update({ id: '/api/public/notifiche-attive', @@ -149,6 +156,7 @@ export interface FileRoutesByFullPath { '/partita/$id': typeof PartitaIdRoute '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/csi': typeof ApiPublicCsiRoute + '/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute '/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute @@ -171,6 +179,7 @@ export interface FileRoutesByTo { '/partita/$id': typeof PartitaIdRoute '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/csi': typeof ApiPublicCsiRoute + '/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute '/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute @@ -194,6 +203,7 @@ export interface FileRoutesById { '/partita/$id': typeof PartitaIdRoute '/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute '/api/public/csi': typeof ApiPublicCsiRoute + '/api/public/notifica-personalizzata': typeof ApiPublicNotificaPersonalizzataRoute '/api/public/notifiche-attive': typeof ApiPublicNotificheAttiveRoute '/api/public/promemoria-palloni': typeof ApiPublicPromemoriaPalloniRoute '/api/public/push-config': typeof ApiPublicPushConfigRoute @@ -218,6 +228,7 @@ export interface FileRouteTypes { | '/partita/$id' | '/api/public/apri-sondaggio' | '/api/public/csi' + | '/api/public/notifica-personalizzata' | '/api/public/notifiche-attive' | '/api/public/promemoria-palloni' | '/api/public/push-config' @@ -240,6 +251,7 @@ export interface FileRouteTypes { | '/partita/$id' | '/api/public/apri-sondaggio' | '/api/public/csi' + | '/api/public/notifica-personalizzata' | '/api/public/notifiche-attive' | '/api/public/promemoria-palloni' | '/api/public/push-config' @@ -262,6 +274,7 @@ export interface FileRouteTypes { | '/partita/$id' | '/api/public/apri-sondaggio' | '/api/public/csi' + | '/api/public/notifica-personalizzata' | '/api/public/notifiche-attive' | '/api/public/promemoria-palloni' | '/api/public/push-config' @@ -285,6 +298,7 @@ export interface RootRouteChildren { PartitaIdRoute: typeof PartitaIdRoute ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute ApiPublicCsiRoute: typeof ApiPublicCsiRoute + ApiPublicNotificaPersonalizzataRoute: typeof ApiPublicNotificaPersonalizzataRoute ApiPublicNotificheAttiveRoute: typeof ApiPublicNotificheAttiveRoute ApiPublicPromemoriaPalloniRoute: typeof ApiPublicPromemoriaPalloniRoute ApiPublicPushConfigRoute: typeof ApiPublicPushConfigRoute @@ -393,6 +407,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiPublicCsiRouteImport parentRoute: typeof rootRouteImport } + '/api/public/notifica-personalizzata': { + id: '/api/public/notifica-personalizzata' + path: '/api/public/notifica-personalizzata' + fullPath: '/api/public/notifica-personalizzata' + preLoaderRoute: typeof ApiPublicNotificaPersonalizzataRouteImport + parentRoute: typeof rootRouteImport + } '/api/public/notifiche-attive': { id: '/api/public/notifiche-attive' path: '/api/public/notifiche-attive' @@ -453,6 +474,7 @@ const rootRouteChildren: RootRouteChildren = { PartitaIdRoute: PartitaIdRoute, ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute, ApiPublicCsiRoute: ApiPublicCsiRoute, + ApiPublicNotificaPersonalizzataRoute: ApiPublicNotificaPersonalizzataRoute, ApiPublicNotificheAttiveRoute: ApiPublicNotificheAttiveRoute, ApiPublicPromemoriaPalloniRoute: ApiPublicPromemoriaPalloniRoute, ApiPublicPushConfigRoute: ApiPublicPushConfigRoute, diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index f14ff1f..dd7ffb6 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -3,6 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { BadgeCheck, Bell, + BellOff, ChevronDown, Download, FileText, @@ -10,6 +11,7 @@ import { Image, Loader2, Lock, + Send, Unlink, UserCheck, UserPlus, @@ -19,12 +21,15 @@ import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Campo, classiInput, PageHeader, Select, StatTile } from "@/components/crapp/ui-bits"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; -import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo"; +import { CampiAllenatore, CampiProfilo } from "@/components/crapp/ProfiloAmministrativo"; import { + inRosa, + isAllenatore, nomeCompleto, numeroGiaUsato, prossimoIdGiocatore, RUOLI, + ruoloVisibile, useAggiungiGiocatore, useGiocatoriSquadra, useImpostaAttivo, @@ -39,6 +44,7 @@ import { import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili"; import { completamento, + completamentoAllenatore, csvTesseramento, profiloVuoto, sezioniComplete, @@ -49,8 +55,17 @@ import { import { oggiISO } from "@/lib/palloni-core"; import { scaricaCsv } from "@/lib/scout-export"; import { useIsAdmin } from "@/lib/ruoli"; -import { useNotificheAttive } from "@/lib/notifiche-admin"; +import { useInviaNotifica, useNotificheAttive } from "@/lib/notifiche-admin"; import { Reveal } from "@/components/motion/Reveal"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; export const Route = createFileRoute("/admin")({ head: () => ({ @@ -115,7 +130,10 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi numero: g.numero, ruolo: g.ruolo, email: g.email, + tipo: g.tipo, }; + // L'allenatore non ha numero, ruolo in campo né tesseramento (DD-034). + const allenatore = isAllenatore(g); const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id); async function confermaSquadra() { @@ -124,7 +142,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi toast.error(errore); return; } - if (numeroGiaUsato(righe, g.id, squadraCorrente.numero)) { + if (!allenatore && numeroGiaUsato(righe, g.id, squadraCorrente.numero)) { toast.error(`Il numero ${squadraCorrente.numero} è già assegnato a un altro giocatore.`); return; } @@ -205,28 +223,34 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi className={classiInput} /> - - setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) })} - className={classiInput} - /> - - - - + {allenatore ? null : ( + <> + + + setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) }) + } + className={classiInput} + /> + + + + + + )} -

Tesseramento CSI

-
- - - setTesseramento({ ...tesseramentoCorrente, numeroTessera: e.target.value || null }) - } - className={classiInput} - /> - - - - setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null }) - } - className={classiInput} - /> - -
- + {allenatore ? null : ( + <> +

Tesseramento CSI

+
+ + + setTesseramento({ + ...tesseramentoCorrente, + numeroTessera: e.target.value || null, + }) + } + className={classiInput} + /> + + + + setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null }) + } + className={classiInput} + /> + +
+ + + )} - setBozza({ ...profiloCorrente, ...patch })} - sezioni={sezioniComplete(profiloCorrente)} - /> + {allenatore ? ( + <> +

Dati personali

+ setBozza({ ...profiloCorrente, ...patch })} + /> + + ) : ( + setBozza({ ...profiloCorrente, ...patch })} + sezioni={sezioniComplete(profiloCorrente)} + /> + )} ); } return (
-

Nuovo giocatore

+

+ {allenatore ? "Nuovo allenatore" : "Nuovo giocatore"} +

+ + +
- - setDati({ ...dati, numero: Number(e.target.value) })} - className={classiInput} - /> - - - - + {allenatore ? null : ( + <> + + setDati({ ...dati, numero: Number(e.target.value) })} + className={classiInput} + /> + + + + + + )}

{nomeCompleto(g)}

- #{g.numero} · {g.ruolo} + {isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}

- {attivi - .filter((g) => notificheAttive.has(g.id)) - .map((g) => ( + {membri.map((g) => { + const attiva = notificheAttive.has(g.id); + return (
- -

{nomeCompleto(g)}

+ {attiva ? ( + + ) : ( + + )} +

+ {nomeCompleto(g)} +

+
- ))} + ); + })}
) : ( @@ -737,6 +884,58 @@ function Dashboard() { { id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche }, ]} /> + + { + if (!aperto) { + setDestinatarioMessaggio(null); + setMessaggio(""); + } + }} + > + + + Invia messaggio + + {destinatarioMessaggio ? `Destinatario: ${destinatarioMessaggio.nome}.` : ""} + + +
+