From 2ec72fd08fb5c5be58712d34202561651133cf47 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 24 Sep 2026 14:54:35 +0200 Subject: [PATCH] Implementa il ruolo allenatore nell'app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modello: tipo sugli slot, inRosa() al posto del filtro su attivo in tutte le letture della rosa di gioco (presenze, convocati, voti, palloni, classifiche, badge, sollecito); gli allenatori restano solo nei compleanni e in Squadra. - Permessi: user_roles letto una volta per tutti i ruoli; usePuoGestireEventi() apre /eventi e il sollecito presenze anche all'allenatore, lato server con richiediGestoreEventi(). Palloni, sondaggio e messaggi restano all'admin. - Identità: useGiocatoreInCampo() è null per l'allenatore, così non risponde alle presenze e non vota; useSonoAllenatore() decide cosa si vede. - Profilo: per l'allenatore solo Docs (nome, cognome, nascita, telefono, email) e Opzioni; card avatar e tab Opzioni estratte per riusarle. - Home, Squadra e partita: niente statistiche personali, badge, cacche e obiettivi per l'allenatore; in Rosa compare con la dicitura «Allenatore». - Admin: tipo in «Aggiungi», allenatori a parte in Profili, fuori da conteggi ed export CSI. Test unit su rosa di gioco e completamento, route test sull'allenatore. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/modules/calendario.md | 12 +- docs/modules/profilo-giocatore.md | 9 +- docs/modules/squadra.md | 26 +- src/components/crapp/EventoCard.tsx | 12 +- src/components/crapp/Pagelle.tsx | 6 +- .../crapp/ProfiloAmministrativo.tsx | 145 +++++- src/components/crapp/PromemoriaPalloni.tsx | 6 +- src/components/crapp/RosaPresenze.tsx | 14 +- src/components/crapp/SondaggioCacche.tsx | 6 +- src/components/crapp/TurnoPalloni.tsx | 4 +- src/components/crapp/VotazioneMvp.tsx | 10 +- src/components/crapp/VotoSocial.tsx | 10 +- src/lib/auth-route.server.ts | 33 +- src/lib/giocatori-squadra.ts | 104 ++++- src/lib/palloni.ts | 4 +- src/lib/presenze-mese.ts | 4 +- src/lib/presenze.ts | 8 +- src/lib/profili-core.ts | 12 + src/lib/rosa.ts | 69 +-- src/lib/ruoli.ts | 36 +- src/lib/user-store.ts | 19 +- src/routes/admin.tsx | 343 +++++++++------ src/routes/api/public/apri-sondaggio.ts | 15 +- src/routes/api/public/promemoria-palloni.ts | 6 +- src/routes/api/public/sollecita-presenze.ts | 4 +- src/routes/calendario.tsx | 8 +- src/routes/eventi.tsx | 13 +- src/routes/index.tsx | 139 +++--- src/routes/partita.$id.tsx | 19 +- src/routes/profilo.tsx | 413 ++++++++++-------- src/routes/squadra.tsx | 145 ++++-- test/integration/permessi-route.test.ts | 30 ++ test/unit/giocatori-squadra.test.ts | 65 ++- test/unit/presenze.test.ts | 11 +- test/unit/profili-core.test.ts | 32 +- 35 files changed, 1215 insertions(+), 577 deletions(-) diff --git a/docs/modules/calendario.md b/docs/modules/calendario.md index 2c172ad..ef94a6e 100644 --- a/docs/modules/calendario.md +++ b/docs/modules/calendario.md @@ -3,7 +3,7 @@ **Stato:** implementato **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, solo admin), `src/components/crapp/EventoCard.tsx` +`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` @@ -20,12 +20,14 @@ 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). La pagina mostra solo una griglia mensile (stessa logica di `/calendario`, tramite le funzioni condivise di diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md index ddfd6fe..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 @@ -179,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 12eae72..8cc1e66 100644 --- a/docs/modules/squadra.md +++ b/docs/modules/squadra.md @@ -39,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 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 ? ( + {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.map((g) => { + {membri.map((g) => { const attiva = notificheAttive.has(g.id); return (
diff --git a/src/routes/api/public/apri-sondaggio.ts b/src/routes/api/public/apri-sondaggio.ts index 0ea2b89..1bd2148 100644 --- a/src/routes/api/public/apri-sondaggio.ts +++ b/src/routes/api/public/apri-sondaggio.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { richiediAdmin } from "@/lib/auth-route.server"; import { leggiEventi } from "@/lib/eventi.server"; import { inviaPush } from "@/lib/webpush.server"; +import { isAllenatore } from "@/lib/giocatori-squadra"; +import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server"; const schema = z.object({ eventoId: z.string().min(1).max(50) }); @@ -22,15 +24,20 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({ if (!partita) return new Response("Evento non trovato", { status: 404 }); const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); - const { data: iscrizioni } = await supabaseAdmin + const { data: tutte } = await supabaseAdmin .from("push_subscriptions") - .select("endpoint, p256dh, auth"); + .select("endpoint, p256dh, auth, giocatore_id"); + // Il sondaggio cacche non riguarda l'allenatore, che non le vede (DD-034). + const allenatori = new Set( + (await leggiGiocatoriSquadra()).filter(isAllenatore).map((g) => g.id), + ); + const iscrizioni = (tutte ?? []).filter((i) => !allenatori.has(i.giocatore_id)); const titolo = "💩 Sondaggio pre-partita aperto"; const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`; let inviate = 0; - for (const iscrizione of iscrizioni ?? []) { + for (const iscrizione of iscrizioni) { try { const { stato } = await inviaPush(iscrizione, titolo, testo); if (stato === 404 || stato === 410) { @@ -46,7 +53,7 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({ } } - return Response.json({ inviate, destinatari: (iscrizioni ?? []).length }); + return Response.json({ inviate, destinatari: iscrizioni.length }); }, }, }, diff --git a/src/routes/api/public/promemoria-palloni.ts b/src/routes/api/public/promemoria-palloni.ts index 52832f3..ac8db37 100644 --- a/src/routes/api/public/promemoria-palloni.ts +++ b/src/routes/api/public/promemoria-palloni.ts @@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"; import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; import { richiediAdmin } from "@/lib/auth-route.server"; -import { nomeCompleto } from "@/lib/giocatori-squadra"; +import { inRosa, nomeCompleto } from "@/lib/giocatori-squadra"; import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server"; import { avvisiPalloniEvento, completaTurni } from "@/lib/palloni-core"; import { inviaPush } from "@/lib/webpush.server"; @@ -37,9 +37,7 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({ for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id; const squadra = await leggiGiocatoriSquadra(); - 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) })); const turni = completaTurni(salvati, eventi, rosa); const avvisi = avvisiPalloniEvento(turni, eventi, evento.id); diff --git a/src/routes/api/public/sollecita-presenze.ts b/src/routes/api/public/sollecita-presenze.ts index d08d615..de3e2b9 100644 --- a/src/routes/api/public/sollecita-presenze.ts +++ b/src/routes/api/public/sollecita-presenze.ts @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; -import { richiediAdmin } from "@/lib/auth-route.server"; +import { richiediGestoreEventi } from "@/lib/auth-route.server"; import { formatData } from "@/lib/crapp-data"; import { leggiEventi } from "@/lib/eventi.server"; import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server"; @@ -17,7 +17,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({ server: { handlers: { POST: async ({ request }) => { - const negato = await richiediAdmin(request); + const negato = await richiediGestoreEventi(request); if (negato) return negato; const parsed = schema.safeParse(await request.json()); diff --git a/src/routes/calendario.tsx b/src/routes/calendario.tsx index effa329..400b663 100644 --- a/src/routes/calendario.tsx +++ b/src/routes/calendario.tsx @@ -10,7 +10,7 @@ import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { Card, PageHeader, Section } from "@/components/crapp/ui-bits"; import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi"; import { useAnagraficaRosa } from "@/lib/rosa"; -import { useIsAdmin } from "@/lib/ruoli"; +import { usePuoGestireEventi } from "@/lib/ruoli"; import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario"; import { Drawer, @@ -55,9 +55,9 @@ function Calendario() { }, []); const [giornoSelezionato, setGiornoSelezionato] = useState(null); const [drawerAperto, setDrawerAperto] = useState(false); - const admin = useIsAdmin(); + const puoGestire = usePuoGestireEventi(); const { eventi } = useEventi(); - const rosa = useAnagraficaRosa(); + const rosa = useAnagraficaRosa({ conAllenatori: true }); // `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo // `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi. const ridotto = useMotoRidotto(); @@ -244,7 +244,7 @@ function Calendario() { - {admin ? ( + {puoGestire ? (
= [ ]; function GestioneEventi() { - // Solo verità (`!io`, gate admin): `useGiocatoreBase` basta, niente statistiche. + // Solo verità (`!io`, gate admin o allenatore): `useGiocatoreBase` basta, niente statistiche. const io = useGiocatoreBase(); - const admin = useIsAdmin(); + // Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso. + const puoGestire = usePuoGestireEventi(); const { eventi, isPending, isError, error, refetch } = useEventi(); const { righe: squadra } = useGiocatoriSquadra(); const salva = useSalvaEvento(); @@ -72,7 +73,7 @@ function GestioneEventi() { const [daEliminare, setDaEliminare] = useState(null); const [confermaModifica, setConfermaModifica] = useState(false); const [cambiaGiorno, setCambiaGiorno] = useState(false); - const rosa = squadra.filter((g) => g.attivo); + const rosa = squadra.filter(inRosa); // SSR-safe: la data di oggi arriva solo dopo il mount. const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null); @@ -85,7 +86,7 @@ function GestioneEventi() { const { anno, mese, direzione, precedente, successivo } = useMeseNav(); const { giorni, offsetLunedi } = giorniDelMese(anno, mese); - if (!io || !admin) { + if (!io || !puoGestire) { return ( <> diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 6b60011..46b1f07 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,4 +1,5 @@ import { createFileRoute, Link } from "@tanstack/react-router"; +import { cn } from "@/lib/utils"; import { Flame, ChevronRight } from "lucide-react"; import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni"; @@ -11,6 +12,8 @@ import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; import { useEventi, type Evento } from "@/lib/eventi"; import { useRispostePresenze } from "@/lib/presenze"; import { useIo, useObiettivi } from "@/lib/rosa"; +import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra"; +import { useGiocatoreBase } from "@/lib/user-store"; import { useCsi } from "@/lib/csi"; import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core"; import { useScoutMatches } from "@/lib/scout-store"; @@ -38,7 +41,10 @@ export const Route = createFileRoute("/")({ }); function Index() { + // `useIo` c'è solo per i giocatori; l'allenatore ha lo slot ma non statistiche (DD-034). + const base = useGiocatoreBase(); const giocatore = useIo(); + const allenatore = isAllenatore(base); const { eventi } = useEventi(); const { presenze } = useRispostePresenze(); const oggi = new Date().toISOString().slice(0, 10); @@ -65,7 +71,8 @@ function Index() { ? { ...ultimaBase, mvp: (eventoUltima && mvpPerMatch[eventoUltima.id]) ?? "" } : null; - if (!giocatore) return null; + if (!base || (!giocatore && !allenatore)) return null; + const nome = giocatore?.nome ?? nomeCompleto(base); return ( <> @@ -78,7 +85,7 @@ function Index() { />

- Ciao {giocatore.nome.split(" ")[0]} + Ciao {nome.split(" ")[0]}

CRAP Volley

@@ -86,7 +93,9 @@ function Index() {
-
+

{noi ? `${noi.pos}º` : "—"}

In classifica

@@ -97,19 +106,21 @@ function Index() {

Bilancio W-L

-
-

- - {giocatore.streak} -

-

Streak

-
+ {giocatore ? ( +
+

+ + {giocatore.streak} +

+

Streak

+
+ ) : null}
- +
-
-
- {daConfermare.length > 0 ? ( - daConfermare.map((e) => { - const link = linkPerEvento(e); - return ; - }) - ) : ( -

- Nient'altro da confermare: sei in pari. -

- )} -
-
+ {giocatore ? ( +
+
+ {daConfermare.length > 0 ? ( + daConfermare.map((e) => { + const link = linkPerEvento(e); + return ; + }) + ) : ( +

+ Nient'altro da confermare: sei in pari. +

+ )} +
+
+ ) : null}
-
- Tutti - - } - > - {obiettivo ? ( - -
- {obiettivo.emoji} {obiettivo.titolo} -
- -

- Siamo al —{" "} - {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}. -

-

- {microcopyObiettivo(obiettivo)} -

-

{obiettivo.impatto}

-
- ) : null} -
+ {allenatore ? null : ( +
+ Tutti + + } + > + {obiettivo ? ( + +
+ {obiettivo.emoji} {obiettivo.titolo} +
+ +

+ Siamo al —{" "} + {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}. +

+

+ {microcopyObiettivo(obiettivo)} +

+

{obiettivo.impatto}

+
+ ) : null} +
+ )} -
-
- - - -
-
+ {giocatore ? ( +
+
+ + + +
+
+ ) : null} ); } diff --git a/src/routes/partita.$id.tsx b/src/routes/partita.$id.tsx index 7935953..375fd17 100644 --- a/src/routes/partita.$id.tsx +++ b/src/routes/partita.$id.tsx @@ -14,6 +14,7 @@ import { ScoutEntry } from "@/components/crapp/ScoutEntry"; import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store"; import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export"; import { useIsAdmin } from "@/lib/ruoli"; +import { useSonoAllenatore } from "@/lib/user-store"; import { VotazioneMvp } from "@/components/crapp/VotazioneMvp"; import { VotoSocial } from "@/components/crapp/VotoSocial"; import { TurnoPalloni } from "@/components/crapp/TurnoPalloni"; @@ -47,6 +48,7 @@ function PartitaDetail() { const { id } = Route.useParams(); const { evento } = useEvento(id); const admin = useIsAdmin(); + const sonoAllenatore = useSonoAllenatore(); const scoutMatches = useScoutMatches(); const { data: csi } = useCsi(); const { risposte } = usePresenzeEvento(id); @@ -203,9 +205,12 @@ function PartitaDetail() {
{match ? ( -
- -
+ // Badge e cacche non si mostrano all'allenatore (DD-034). + sonoAllenatore ? null : ( +
+ +
+ ) ) : (

@@ -219,9 +224,11 @@ function PartitaDetail() {

-
- -
+ {sonoAllenatore ? null : ( +
+ +
+ )} {match ? (
diff --git a/src/routes/profilo.tsx b/src/routes/profilo.tsx index 38cf258..020417c 100644 --- a/src/routes/profilo.tsx +++ b/src/routes/profilo.tsx @@ -14,8 +14,9 @@ import { } from "@/lib/avatar-store"; import { SerieGriglia } from "@/components/crapp/SerieCard"; import { CollezioneBadge } from "@/components/crapp/CollezioneBadge"; -import { ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo"; +import { ProfiloAllenatore, ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo"; import { useVotiSocial } from "@/lib/badge-social"; +import { inizialiDa } from "@/lib/crapp-data"; import { useIo } from "@/lib/rosa"; import { usePresenzeUltimoMese } from "@/lib/presenze-mese"; import { @@ -24,7 +25,13 @@ import { pushSupportato, statoNotifiche, } from "@/lib/push-client"; -import { resetGiocatore } from "@/lib/user-store"; +import { resetGiocatore, useGiocatoreBase } from "@/lib/user-store"; +import { + isAllenatore, + nomeCompleto, + ruoloVisibile, + type GiocatoreSquadra, +} from "@/lib/giocatori-squadra"; import { esci } from "@/lib/auth"; import { useIsAdmin } from "@/lib/ruoli"; import { Reveal } from "@/components/motion/Reveal"; @@ -60,130 +67,29 @@ export const Route = createFileRoute("/profilo")({ }); function Profilo() { + // L'allenatore ha un profilo ridotto: solo Docs e Opzioni, niente stagione né badge (DD-034). + const base = useGiocatoreBase(); + if (base && isAllenatore(base)) return ; + return ; +} + +function ProfiloGiocatore() { const { tab } = Route.useSearch(); const tabIniziale = tab ?? "stagione"; const votiSocial = useVotiSocial(); const g = useIo(); - const admin = useIsAdmin(); const ultimoMese = usePresenzeUltimoMese(g?.id); - const inputRef = useRef(null); - const fotoEsiste = useAvatarEsiste(g?.id); - const impostaAvatarEsiste = useImpostaAvatarEsiste(); - const [bust, setBust] = useState(0); - const [notifiche, setNotifiche] = useState(false); - const [inCorso, setInCorso] = useState(false); - const [supportate, setSupportate] = useState(true); - - useEffect(() => { - setSupportate(pushSupportato()); - statoNotifiche() - .then(setNotifiche) - .catch(() => setNotifiche(false)); - }, []); if (!g) return null; - async function cambiaNotifiche() { - if (!g || inCorso) return; - setInCorso(true); - try { - if (notifiche) { - await disattivaNotifiche(); - setNotifiche(false); - toast.success("Notifiche disattivate"); - } else { - await attivaNotifiche(g.id); - setNotifiche(true); - toast.success("Notifiche attive"); - } - } catch (error) { - toast.error(error instanceof Error ? error.message : "Notifiche non disponibili"); - } finally { - setInCorso(false); - } - } - - async function logout() { - try { - await esci(); - resetGiocatore(); - } catch (error) { - toast.error(error instanceof Error ? error.message : "Uscita non riuscita"); - } - } - const percPresenze = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0; - async function onFile(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - e.target.value = ""; - if (!file || !g) return; - try { - await caricaAvatar(g.id, file); - setBust(Date.now()); - impostaAvatarEsiste(g.id, true); - toast.success("Immagine profilo aggiornata"); - } catch { - toast.error("Non sono riuscito a caricare l'immagine"); - } - } - return ( <> {/* Sul profilo il logo torna in home al posto del link al profilo. */} - - - - } - /> + } /> - - -
- - -
-
- - {fotoEsiste.data ? ( - - ) : null} -
-
-
+ -
- - {admin ? ( - - Dashboard amministratore - - - ) : null} - - Segnala un bug - - - - Suggerisci una nuova funzionalità - - -
-

CrAPP v{APP_VERSION}

- -
- ), + contenuto: , }, ]} /> ); } + +function ProfiloAllenatorePagina({ g }: { g: GiocatoreSquadra }) { + const { tab } = Route.useSearch(); + // Stagione e Badge non esistono per l'allenatore: un link diretto apre Docs. + const tabIniziale = tab === "impostazioni" ? "impostazioni" : "documenti"; + const nome = nomeCompleto(g); + + return ( + <> + } /> + + + + }, + { + id: "impostazioni", + label: "Opzioni", + contenuto: , + }, + ]} + /> + + ); +} + +function LinkHome() { + return ( + + + + ); +} + +/** Foto profilo: stessa per giocatore e allenatore (bucket `avatar-giocatori`). */ +function CardAvatar({ id, fallback }: { id: string; fallback: string }) { + const inputRef = useRef(null); + const fotoEsiste = useAvatarEsiste(id); + const impostaAvatarEsiste = useImpostaAvatarEsiste(); + const [bust, setBust] = useState(0); + + async function onFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + try { + await caricaAvatar(id, file); + setBust(Date.now()); + impostaAvatarEsiste(id, true); + toast.success("Immagine profilo aggiornata"); + } catch { + toast.error("Non sono riuscito a caricare l'immagine"); + } + } + + return ( + + +
+ + +
+
+ + {fotoEsiste.data ? ( + + ) : null} +
+
+
+ ); +} + +/** Tab Opzioni: notifiche, dashboard admin, segnalazioni, uscita. Uguale per tutti. */ +function TabOpzioni({ giocatoreId }: { giocatoreId: string }) { + const admin = useIsAdmin(); + const [notifiche, setNotifiche] = useState(false); + const [inCorso, setInCorso] = useState(false); + const [supportate, setSupportate] = useState(true); + + useEffect(() => { + setSupportate(pushSupportato()); + statoNotifiche() + .then(setNotifiche) + .catch(() => setNotifiche(false)); + }, []); + + async function cambiaNotifiche() { + if (inCorso) return; + setInCorso(true); + try { + if (notifiche) { + await disattivaNotifiche(); + setNotifiche(false); + toast.success("Notifiche disattivate"); + } else { + await attivaNotifiche(giocatoreId); + setNotifiche(true); + toast.success("Notifiche attive"); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Notifiche non disponibili"); + } finally { + setInCorso(false); + } + } + + async function logout() { + try { + await esci(); + resetGiocatore(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Uscita non riuscita"); + } + } + + return ( +
+
+ + {admin ? ( + + Dashboard amministratore + + + ) : null} + + Segnala un bug + + + + Suggerisci una nuova funzionalità + + +
+

CrAPP v{APP_VERSION}

+ +
+ ); +} diff --git a/src/routes/squadra.tsx b/src/routes/squadra.tsx index 01bc926..0c89d97 100644 --- a/src/routes/squadra.tsx +++ b/src/routes/squadra.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"; import { PageHeader, StatTile } from "@/components/crapp/ui-bits"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; import { Avatar } from "@/components/crapp/Avatar"; -import { formatData } from "@/lib/crapp-data"; +import { formatData, inizialiDa } from "@/lib/crapp-data"; import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi"; import { useRosa, @@ -15,6 +15,13 @@ import { type CriterioClassifica, } from "@/lib/rosa"; import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese"; +import { + isAllenatore, + nomeCompleto, + ruoloVisibile, + useGiocatoriSquadra, +} from "@/lib/giocatori-squadra"; +import { useSonoAllenatore } from "@/lib/user-store"; import { totaliSquadra, useScoutMatches } from "@/lib/scout-store"; import { useCsi } from "@/lib/csi"; import { partiteGiocate } from "@/lib/csi-core"; @@ -87,6 +94,14 @@ function valore( function Squadra() { const rosa = useRosa(); + // Gli allenatori stanno in Rosa con i giocatori, senza numero né statistiche (DD-034). + const { righe: squadra } = useGiocatoriSquadra(); + const allenatori = useMemo(() => squadra.filter((g) => g.attivo && isAllenatore(g)), [squadra]); + // Chi allena non vede badge, cacche né obiettivi (DD-034). + const sonoAllenatore = useSonoAllenatore(); + const criteriVisibili = sonoAllenatore + ? criteri.filter((c) => c.id !== "cacchePartita") + : criteri; const mese = usePresenzeUltimoMeseTutti(); const scoutMatches = useScoutMatches(); const { voti: pagelle } = usePagelle(); @@ -115,9 +130,36 @@ function Squadra() { const contenutoRosa = useMemo( () => (
+ {allenatori.map((a) => ( +
+ + + + {nomeCompleto(a)} + + + + {a.nascita ? ( + + {formatData(a.nascita)} + + ) : null} + + +
+ ))} {rosa.map((g) => { const stati = badgeGiocatore(g); - const sbloccati = [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)]; + const sbloccati = sonoAllenatore + ? [] + : [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)]; const isOpen = aperto === g.id; return (
@@ -185,7 +227,9 @@ function Squadra() { { l: "Presenze di fila", v: g.streak }, { l: "Media voto", v: g.mediaVoto || "—" }, { l: "MVP", v: g.mvp }, - { l: "Cacche/partita 💩", v: g.cacchePartita || "—" }, + ...(sonoAllenatore + ? [] + : [{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" }]), ].map((s) => (

{s.v}

@@ -196,37 +240,45 @@ function Squadra() { ))}
-

- Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali} -

- {sbloccati.length === 0 ? ( -

- Nessun badge sbloccato per ora. -

- ) : ( -
- {sbloccati.map((b) => { - const Icon = b.def.icon; - const meta = gradoMeta[b.grado!]; - return ( - -
- -

{b.def.nome}

-

- {b.valore} {b.def.unita} - {b.prossimaSoglia - ? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}` - : ""} -

-

- {meta.label} -

-
-
- ); - })} -
+ {sonoAllenatore ? null : ( + <> +

+ Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali} +

+ {sbloccati.length === 0 ? ( +

+ Nessun badge sbloccato per ora. +

+ ) : ( +
+ {sbloccati.map((b) => { + const Icon = b.def.icon; + const meta = gradoMeta[b.grado!]; + return ( + +
+ +

+ {b.def.nome} +

+

+ {b.valore} {b.def.unita} + {b.prossimaSoglia + ? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}` + : ""} +

+

+ {meta.label} +

+
+
+ ); + })} +
+ )} + )}
) : null} @@ -235,7 +287,7 @@ function Squadra() { })}
), - [rosa, aperto, mese], + [rosa, aperto, mese, allenatori, sonoAllenatore], ); const contenutoStats = useMemo( @@ -271,7 +323,7 @@ function Squadra() { Classifica per
- {criteri.map((c) => ( + {criteriVisibili.map((c) => (
), - [mediaPresenze, matchGiocati, pagelle, team, filtroAperto, criterio, ordinati, rank, max], + [ + mediaPresenze, + matchGiocati, + pagelle, + team, + filtroAperto, + criterio, + ordinati, + rank, + max, + criteriVisibili, + ], ); const contenutoObiettivi = useMemo( @@ -450,8 +513,12 @@ function Squadra() { voci={[ { id: "rosa", label: "Rosa", contenuto: contenutoRosa }, { id: "stats", label: "Stats", contenuto: contenutoStats }, - { id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi }, - { id: "badge", label: "Badge", contenuto: contenutoBadge }, + ...(sonoAllenatore + ? [] + : [ + { id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi }, + { id: "badge", label: "Badge", contenuto: contenutoBadge }, + ]), ]} /> diff --git a/test/integration/permessi-route.test.ts b/test/integration/permessi-route.test.ts index 96429b6..59f015a 100644 --- a/test/integration/permessi-route.test.ts +++ b/test/integration/permessi-route.test.ts @@ -66,8 +66,19 @@ if (!locale) { body: JSON.stringify({ user_id: idAdmin, role: "admin" }), }); + // Il ruolo allenatore lo scrive il trigger di M21 al collegamento dello slot; qui basta + // la riga in `user_roles`, che è ciò che la route legge (DD-034). + const emailAllenatore = `test-route-allenatore-${Date.now()}@example.test`; + const idAllenatore = await creaUtente(emailAllenatore); + await fetch(`${SUPABASE}/rest/v1/user_roles`, { + method: "POST", + headers: { ...authAdmin, "content-type": "application/json" }, + body: JSON.stringify({ user_id: idAllenatore, role: "allenatore" }), + }); + const tokenGiocatore = await accedi(emailGiocatore); const tokenAdmin = await accedi(emailAdmin); + const tokenAllenatore = await accedi(emailAllenatore); const server = await avviaServer(); console.log(`permessi route su ${server.baseUrl} (database ${SUPABASE})`); @@ -108,6 +119,20 @@ if (!locale) { } }); + await prova( + "l'allenatore sollecita le presenze ma non manda palloni né sondaggio", + async () => { + const sollecito = await chiama("/api/public/sollecita-presenze", { + authorization: `Bearer ${tokenAllenatore}`, + }); + assert.equal(sollecito.status, 404, "sollecito: superato l'accesso, evento inesistente"); + for (const percorso of ["/api/public/apri-sondaggio", "/api/public/promemoria-palloni"]) { + const res = await chiama(percorso, { authorization: `Bearer ${tokenAllenatore}` }); + assert.equal(res.status, 403, `${percorso}: restano all'admin`); + } + }, + ); + /** Messaggio libero: stesso controllo d'accesso, corpo diverso. */ const chiamaMessaggio = (intestazioni: Record = {}) => fetch(`${server.baseUrl}/api/public/notifica-personalizzata`, { @@ -125,6 +150,11 @@ if (!locale) { assert.equal(res.status, 403); }); + await prova("l'allenatore non manda messaggi personalizzati", async () => { + const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAllenatore}` }); + assert.equal(res.status, 403); + }); + await prova("un amministratore manda il messaggio (nessun dispositivo iscritto)", async () => { const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAdmin}` }); assert.equal(res.status, 200); diff --git a/test/unit/giocatori-squadra.test.ts b/test/unit/giocatori-squadra.test.ts index 38b4606..6d71cfe 100644 --- a/test/unit/giocatori-squadra.test.ts +++ b/test/unit/giocatori-squadra.test.ts @@ -1,10 +1,14 @@ /** Check dell'anagrafica squadra: `bun test/unit/giocatori-squadra.test.ts`. */ import assert from "node:assert/strict"; import { + daRigaSquadra, + inRosa, + isAllenatore, nomeCompleto, numeroGiaUsato, prossimoIdGiocatore, rosaFallback, + ruoloVisibile, slotDi, slotPerEmail, validaDatiSquadra, @@ -24,6 +28,7 @@ const riga = (parziale: Partial = {}): GiocatoreSquadra => ({ numeroTessera: null, dataTessera: null, nascita: null, + tipo: "giocatore", ...parziale, }); @@ -92,7 +97,14 @@ assert.equal( assert.equal(slotPerEmail(rosaEmail, ""), null, "stringa vuota trattata come nessuna email"); // --- validaDatiSquadra ---------------------------------------------------------------- -const datiOk = { nome: "Mario", cognome: "Rossi", numero: 7, ruolo: "schiacciatore", email: null }; +const datiOk = { + nome: "Mario", + cognome: "Rossi", + numero: 7, + ruolo: "schiacciatore", + email: null, + tipo: "giocatore" as const, +}; assert.equal(validaDatiSquadra(datiOk), null, "dati validi: nessun errore"); assert.match(validaDatiSquadra({ ...datiOk, nome: "" })!, /nome/i); assert.match( @@ -155,5 +167,56 @@ assert.equal( "conta solo il 7 attivo di g1, non serve escludere g3", ); assert.equal(numeroGiaUsato(rosaNumeri, "g4", 11), false, "un numero libero non risulta usato"); +assert.equal( + numeroGiaUsato([riga({ id: "g5", numero: 0, tipo: "allenatore" })], "g4", 0), + false, + "l'allenatore non occupa numeri di maglia", +); + +// --- allenatore (DD-034) --------------------------------------------------------- +const allenatore = riga({ id: "g20", numero: 0, ruolo: "", tipo: "allenatore" }); +assert.equal(isAllenatore(allenatore), true); +assert.equal(isAllenatore(riga()), false); +assert.equal(isAllenatore(null), false, "senza slot non si è allenatori"); +assert.equal(inRosa(riga()), true, "giocatore attivo: in rosa"); +assert.equal(inRosa(riga({ attivo: false })), false, "giocatore disattivato: fuori"); +assert.equal(inRosa(allenatore), false, "l'allenatore non è nella rosa di gioco"); +assert.equal(ruoloVisibile(allenatore), "Allenatore", "dove c'è il ruolo in campo"); +assert.equal(ruoloVisibile(riga({ ruolo: "Libero" })), "Libero"); +assert.equal( + validaDatiSquadra({ ...datiOk, tipo: "allenatore", numero: 0, ruolo: "" }), + null, + "l'allenatore non ha numero né ruolo", +); +assert.match( + validaDatiSquadra({ ...datiOk, tipo: "allenatore", nome: " " })!, + /nome/i, + "il nome resta obbligatorio anche per l'allenatore", +); +assert.match( + validaDatiSquadra({ ...datiOk, tipo: "allenatore", email: "senza-chiocciola" })!, + /email/i, +); +const rigaDb = { + id: "g20", + nome: "Carla", + cognome: "Neri", + numero: null, + ruolo: "", + auth_user_id: null, + attivo: true, + email: "carla@gmail.com", + numero_tessera: null, + data_tessera: null, + nascita: null, + tipo: "allenatore" as const, +}; +assert.equal(daRigaSquadra(rigaDb).numero, 0, "numero NULL a database diventa 0"); +assert.equal(daRigaSquadra(rigaDb).tipo, "allenatore"); +assert.equal(daRigaSquadra({ ...rigaDb, numero: 7, tipo: "giocatore" }).numero, 7); +assert.ok( + rosaFallback().every((g) => g.tipo === "giocatore"), + "il fallback contiene solo giocatori", +); console.log("giocatori-squadra: ok"); diff --git a/test/unit/presenze.test.ts b/test/unit/presenze.test.ts index 8405b94..645cc38 100644 --- a/test/unit/presenze.test.ts +++ b/test/unit/presenze.test.ts @@ -120,11 +120,12 @@ assert.equal(totaliEventiGiocatore("g1", conRistretti, OGGI), 5, "chi non è con // --- destinatari del sollecito: chi non ha risposto, più i "forse" ------------ const squadra = [ - { id: "g1", attivo: true }, - { id: "g2", attivo: true }, - { id: "g3", attivo: true }, - { id: "g4", attivo: true }, - { id: "g5", attivo: false }, // uscito dalla squadra: non lo si disturba più + { id: "g1", attivo: true, tipo: "giocatore" as const }, + { id: "g2", attivo: true, tipo: "giocatore" as const }, + { id: "g3", attivo: true, tipo: "giocatore" as const }, + { id: "g4", attivo: true, tipo: "giocatore" as const }, + { id: "g5", attivo: false, tipo: "giocatore" as const }, // uscito: non lo si disturba più + { id: "g6", attivo: true, tipo: "allenatore" as const }, // non risponde: mai sollecitato (DD-034) ]; const risposte = [ diff --git a/test/unit/profili-core.test.ts b/test/unit/profili-core.test.ts index 4810956..a026fdd 100644 --- a/test/unit/profili-core.test.ts +++ b/test/unit/profili-core.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { aRigaProfilo, completamento, + completamentoAllenatore, csvTesseramento, daRigaProfilo, sezioniComplete, @@ -115,6 +116,7 @@ const squadra: GiocatoreSquadra[] = [ numeroTessera: null, dataTessera: null, nascita: null, + tipo: "giocatore", }, { id: "g2", @@ -128,6 +130,7 @@ const squadra: GiocatoreSquadra[] = [ numeroTessera: null, dataTessera: null, nascita: null, + tipo: "giocatore", }, ]; const csv = csvTesseramento(squadra, { g1: completo }); @@ -155,7 +158,14 @@ assert.equal(slotDi(squadra, "u2")?.id, "g2"); assert.equal(slotDi(squadra, "sconosciuto"), null); // --- dati squadra modificabili dall'admin (DD-017) --------------------------- -const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda", email: null }; +const datiOk = { + nome: "Ivan", + cognome: "Cacciari", + numero: 23, + ruolo: "Banda", + email: null, + tipo: "giocatore" as const, +}; assert.equal(validaDatiSquadra(datiOk), null); assert.match(validaDatiSquadra({ ...datiOk, nome: " " }) ?? "", /nome/i); assert.match(validaDatiSquadra({ ...datiOk, cognome: "" }) ?? "", /cognome/i); @@ -184,4 +194,24 @@ assert.ok( "il fallback non può collegare account", ); +// --- completamento dell'allenatore (DD-034) -------------------------------------- +assert.equal(completamentoAllenatore(null), 0, "senza profilo: 0%"); +assert.equal( + completamentoAllenatore({ ...vuoto, dataNascita: "1980-01-01", telefono: "333" }), + 50, + "due campi su quattro", +); +assert.equal( + completamentoAllenatore({ + ...vuoto, + dataNascita: "1980-01-01", + luogoNascita: "Bologna", + telefono: "333", + email: "a@b.it", + }), + 100, + "bastano i quattro dati personali: niente indirizzo, documento, certificato, foto", +); +assert.equal(completamentoAllenatore({ ...vuoto, telefono: " " }), 0, "spazi soli non contano"); + console.log("profili-core: ok");