diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 1e95fbb..c39731a 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -178,10 +178,11 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin. ## Prossimo sviluppo -**Avviso certificati in scadenza** (24/09/2026, branch `develop`): specifica scritta in -[docs/modules/profilo-giocatore.md](docs/modules/profilo-giocatore.md#avviso-certificati), -decisione in DD-035, codice non ancora iniziato. Solo client: nessuna migration e nessuna -push. Le altre voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo». +**Avviso certificati in scadenza** (27/09/2026, branch `develop`): codice scritto secondo la +specifica in [docs/modules/profilo-giocatore.md](docs/modules/profilo-giocatore.md#avviso-certificati) +(decisione DD-035), non ancora rilasciato in una versione. Solo client: nessuna migration e +nessuna push. Le altre voci aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto +«Prossimo». --- diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md index f2ff735..785fc6f 100644 --- a/docs/modules/profilo-giocatore.md +++ b/docs/modules/profilo-giocatore.md @@ -82,7 +82,7 @@ sulla tab Stagione. ### Avviso certificati -**Stato:** da implementare (DD-035). +**Stato:** implementato (DD-035). Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza diff --git a/src/components/crapp/AvvisoCertificati.tsx b/src/components/crapp/AvvisoCertificati.tsx new file mode 100644 index 0000000..94b4ac9 --- /dev/null +++ b/src/components/crapp/AvvisoCertificati.tsx @@ -0,0 +1,113 @@ +import { AlertCircle } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { useProfili } from "@/lib/profili"; +import { + avvisiCertificati, + formatDataBreve, + testoScadenza, + type AvvisoCertificato, +} from "@/lib/profili-core"; +import { useGiocatoriSquadra } from "@/lib/giocatori-squadra"; +import { oggiISO } from "@/lib/palloni-core"; +import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli"; +import { useGiocatoreBase } from "@/lib/user-store"; + +function Card({ + colore, + titolo, + righe, + cliccabile, +}: { + colore: "warning" | "primary"; + titolo: string; + righe: string[]; + cliccabile?: boolean; +}) { + const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${ + colore === "warning" + ? "bg-warning text-warning-foreground" + : "bg-primary text-primary-foreground" + } ${cliccabile ? "premi" : ""}`; + const contenuto = ( + <> +

+ {titolo} +

+ {righe.map((r) => ( +

+ {r} +

+ ))} + + ); + return cliccabile ? ( + + {contenuto} + + ) : ( +
{contenuto}
+ ); +} + +/** Avviso certificati in scadenza/scaduti (DD-035): calcolato al volo, niente push. */ +export function AvvisoCertificati() { + const admin = useIsAdmin(); + const ruoliPronti = useRuoliPronti(); + const base = useGiocatoreBase(); + const { righe: rosa } = useGiocatoriSquadra(); + const { profili, isPending } = useProfili(); + + if (!ruoliPronti || isPending) return null; + + const oggi = oggiISO(); + + if (admin) { + const { scaduti, inScadenza } = avvisiCertificati(rosa, profili, oggi); + if (scaduti.length === 0 && inScadenza.length === 0) return null; + return ( + <> + {scaduti.length > 0 ? ( + rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))} + /> + ) : null} + {inScadenza.length > 0 ? ( + rigaStaff(a, testoScadenza(a.giorni)))} + /> + ) : null} + + ); + } + + if (!base) return null; + const { scaduti, inScadenza } = avvisiCertificati([base], profili, oggi); + const mio = scaduti[0] ?? inScadenza[0]; + if (!mio) return null; + + return mio.giorni < 0 ? ( + + ) : ( + + ); +} + +function rigaStaff(a: AvvisoCertificato, dettaglio: string): string { + return `${a.nome} ${a.cognome} — ${dettaglio}`; +} diff --git a/src/lib/profili-core.ts b/src/lib/profili-core.ts index 20d8e86..aff48d6 100644 --- a/src/lib/profili-core.ts +++ b/src/lib/profili-core.ts @@ -1,5 +1,5 @@ import { rigaCsv } from "./scout-export"; -import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra"; +import { inRosa, nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra"; /** * Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato @@ -215,3 +215,73 @@ export function csvTesseramento( export function etichettaGiocatore(g: GiocatoreSquadra): string { return `#${g.numero} ${nomeCompleto(g)}`; } + +/** Soglia dell'avviso certificati in Home (DD-035). */ +export const GIORNI_AVVISO_CERTIFICATO = 7; + +/** Giorni di calendario tra due date `AAAA-MM-GG` (ora locale): negativo se `scadenza` è passata. */ +export function giorniAllaScadenza(scadenza: string, oggi: string): number { + const a = new Date(`${scadenza}T00:00:00`); + const b = new Date(`${oggi}T00:00:00`); + return Math.round((a.getTime() - b.getTime()) / 86_400_000); +} + +/** Data in formato `GG/MM/AAAA`, per i testi dell'avviso certificati. */ +export function formatDataBreve(iso: string): string { + return new Date(`${iso}T00:00:00`).toLocaleDateString("it-IT", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +/** Riga dell'avviso certificati per un giocatore (DD-035). */ +export function testoScadenza(giorni: number): string { + if (giorni === 0) return "scade oggi"; + if (giorni === 1) return "scade domani"; + return `scade tra ${giorni} giorni`; +} + +export type AvvisoCertificato = { + giocatoreId: string; + nome: string; + cognome: string; + scadenza: string; + giorni: number; +}; + +function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number { + return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome); +} + +/** + * Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da + * `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha + * ancora un certificato caricato (è un problema diverso, non un avviso di scadenza). + */ +export function avvisiCertificati( + rosa: GiocatoreSquadra[], + profili: Record, + oggi: string, +): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } { + const scaduti: AvvisoCertificato[] = []; + const inScadenza: AvvisoCertificato[] = []; + for (const g of rosa) { + if (!inRosa(g)) continue; + const p = profili[g.id]; + if (!p?.certificatoScadenza || !p.certificatoPath) continue; + const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi); + if (giorni > GIORNI_AVVISO_CERTIFICATO) continue; + const avviso: AvvisoCertificato = { + giocatoreId: g.id, + nome: g.nome, + cognome: g.cognome, + scadenza: p.certificatoScadenza, + giorni, + }; + (giorni < 0 ? scaduti : inScadenza).push(avviso); + } + scaduti.sort(confrontaAvvisi); + inScadenza.sort(confrontaAvvisi); + return { scaduti, inScadenza }; +} diff --git a/src/lib/ruoli.ts b/src/lib/ruoli.ts index 4ae1dbf..2398c6d 100644 --- a/src/lib/ruoli.ts +++ b/src/lib/ruoli.ts @@ -24,15 +24,23 @@ async function fetchRuoli(utenteId: string | null): Promise { return (data ?? []).map((r) => r.role as Ruolo); } -function useRuoli(): Ruolo[] { +function useRuoliQuery() { const { utenteId } = useSessione(); // Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta. - const query = useQuery({ + return useQuery({ queryKey: [...RUOLI_KEY, utenteId], queryFn: () => fetchRuoli(utenteId), staleTime: 30 * 60_000, }); - return query.data ?? []; +} + +function useRuoli(): Ruolo[] { + return useRuoliQuery().data ?? []; +} + +/** L'avviso certificati (DD-035) aspetta i ruoli: niente flash dell'avviso sbagliato. */ +export function useRuoliPronti(): boolean { + return !useRuoliQuery().isPending; } export function useIsAdmin(): boolean { diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 46b1f07..2c72149 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -3,6 +3,7 @@ import { cn } from "@/lib/utils"; import { Flame, ChevronRight } from "lucide-react"; import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni"; +import { AvvisoCertificati } from "@/components/crapp/AvvisoCertificati"; import { Card, LinkProfilo, Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits"; import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo"; import { Reveal } from "@/components/motion/Reveal"; @@ -120,6 +121,8 @@ function Index() { + +
= { + g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" }, // in scadenza, 5 giorni + g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-10", certificatoPath: "p2" }, // scaduto da 10 giorni + g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-10-31", certificatoPath: "p3" }, // valido, fuori soglia + g4: { ...vuoto, giocatoreId: "g4", certificatoScadenza: "2026-09-21", certificatoPath: "p4" }, // disattivato: escluso + g5: { ...vuoto, giocatoreId: "g5", certificatoScadenza: "2026-09-21", certificatoPath: "p5" }, // allenatore: escluso +}; +const avvisi = avvisiCertificati(rosaCertificati, profiliCertificati, oggiTest); +assert.deepEqual( + avvisi.scaduti.map((a) => a.giocatoreId), + ["g2"], +); +assert.deepEqual( + avvisi.inScadenza.map((a) => a.giocatoreId), + ["g1"], +); + +assert.equal( + avvisiCertificati( + [{ ...squadra[0]!, id: "g6" }], + { g6: { ...vuoto, giocatoreId: "g6", certificatoScadenza: null, certificatoPath: null } }, + oggiTest, + ).inScadenza.length, + 0, + "certificato mancante: nessun avviso", +); + +const dueInScadenza: Record = { + g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-27", certificatoPath: "p1" }, // 7 giorni, più lontano + g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-21", certificatoPath: "p2" }, // 1 giorno, più vicino +}; +const ordinati = avvisiCertificati( + [ + { ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" }, + { ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" }, + ], + dueInScadenza, + oggiTest, +).inScadenza.map((a) => a.giocatoreId); +assert.deepEqual(ordinati, ["g2", "g1"], "la scadenza più vicina viene prima"); + +const pariGiorni: Record = { + g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" }, + g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-25", certificatoPath: "p2" }, +}; +const alfabetico = avvisiCertificati( + [ + { ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" }, + { ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" }, + ], + pariGiorni, + oggiTest, +).inScadenza.map((a) => a.giocatoreId); +assert.deepEqual(alfabetico, ["g2", "g1"], "a parità di data, ordine alfabetico per cognome"); + +// soglia esatta (DD-035): 8 giorni fuori, 7 e 0 dentro +const rosaSoglia: GiocatoreSquadra[] = [ + { ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" }, + { ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" }, + { ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" }, +]; +const profiliSoglia: Record = { + g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-28", certificatoPath: "p1" }, // 8 giorni: fuori + g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-27", certificatoPath: "p2" }, // 7 giorni: dentro + g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-20", certificatoPath: "p3" }, // 0 giorni: dentro +}; +const soglia = avvisiCertificati(rosaSoglia, profiliSoglia, oggiTest); +assert.deepEqual( + soglia.inScadenza.map((a) => a.giocatoreId), + ["g3", "g2"], + "8 giorni resta valido, 7 e 0 generano avviso", +); +assert.equal(soglia.scaduti.length, 0); + +// ordinamento tra scaduti: il più vecchio prima, poi alfabetico a parità di data +const rosaScaduti: GiocatoreSquadra[] = [ + { ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" }, + { ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" }, + { ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" }, +]; +const profiliScaduti: Record = { + g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-01", certificatoPath: "p1" }, // scaduto da 19 giorni + g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-01", certificatoPath: "p2" }, // pari data di g1 + g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-19", certificatoPath: "p3" }, // scaduto da 1 giorno +}; +const scadutiOrdinati = avvisiCertificati(rosaScaduti, profiliScaduti, oggiTest).scaduti.map( + (a) => a.giocatoreId, +); +assert.deepEqual( + scadutiOrdinati, + ["g2", "g1", "g3"], + "il più vecchio prima, poi alfabetico a parità di data (Alfa prima di Zeta)", +); + console.log("profili-core: ok");