import { useState } from "react"; import { createFileRoute } from "@tanstack/react-router"; import { BadgeCheck, ChevronDown, Download, FileText, IdCard, Image, Loader2, Lock, Unlink, UserCheck, UserPlus, UserX, } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits"; import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo"; import { nomeCompleto, numeroGiaUsato, prossimoIdGiocatore, RUOLI, useAggiungiGiocatore, useGiocatoriSquadra, useImpostaAttivo, useSalvaDatiSquadra, useSalvaTesseramento, useScollegaAccount, validaDatiSquadra, type DatiSquadra, type DatiTesseramento, type GiocatoreSquadra, } from "@/lib/giocatori-squadra"; import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili"; import { completamento, csvTesseramento, profiloVuoto, sezioniComplete, statoScadenza, type Profilo, type StatoScadenza, } from "@/lib/profili-core"; import { oggiISO } from "@/lib/palloni-core"; import { scaricaCsv } from "@/lib/scout-export"; import { useIsAdmin } from "@/lib/ruoli"; import { Reveal } from "@/components/motion/Reveal"; export const Route = createFileRoute("/admin")({ head: () => ({ meta: [ { title: "Dashboard amministratore — CrAPP" }, { name: "description", content: "Area riservata: stato dei profili, documenti e certificati della squadra, ed export dei dati per il tesseramento CSI.", }, { property: "og:title", content: "Dashboard amministratore — CrAPP" }, { property: "og:description", content: "Stato dei profili della squadra ed export per il tesseramento CSI.", }, ], }), component: Dashboard, }); const statoClasse: Record = { valido: "bg-success text-success-foreground", presente: "bg-success text-success-foreground", scaduto: "bg-destructive text-destructive-foreground", mancante: "bg-secondary text-muted-foreground", assente: "bg-secondary text-muted-foreground", }; const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"; /** L'unico vincolo unique lato database sulla tabella è l'email: messaggio leggibile invece * del codice Postgres (23505). */ function messaggioErrore(e: unknown, fallback: string): string { if (e && typeof e === "object" && "code" in e && (e as { code?: unknown }).code === "23505") { return "Email già usata da un altro giocatore."; } return e instanceof Error ? e.message : fallback; } function Campo({ label, children }: { label: string; children: React.ReactNode }) { return ( ); } /** * Pannello di modifica dell'admin (DD-017): dati squadra, dati personali e collegamento * all'account. I file restano fuori: l'admin li scarica, non li carica al posto di altri. */ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profilo | undefined }) { const { righe } = useGiocatoriSquadra(); const salvaSquadra = useSalvaDatiSquadra(); const salvaProfilo = useSalvaProfilo(); const salvaTesseramento = useSalvaTesseramento(); const scollega = useScollegaAccount(); const impostaAttivo = useImpostaAttivo(); const [datiSquadra, setDatiSquadra] = useState(null); const [bozza, setBozza] = useState(null); const [tesseramento, setTesseramento] = useState(null); const tesseramentoCorrente: DatiTesseramento = tesseramento ?? { numeroTessera: g.numeroTessera, dataTessera: g.dataTessera, }; const squadraCorrente: DatiSquadra = datiSquadra ?? { nome: g.nome, cognome: g.cognome, numero: g.numero, ruolo: g.ruolo, email: g.email, }; const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id); async function confermaSquadra() { const errore = validaDatiSquadra(squadraCorrente); if (errore) { toast.error(errore); return; } if (numeroGiaUsato(righe, g.id, squadraCorrente.numero)) { toast.warning(`Il numero ${squadraCorrente.numero} è già assegnato a un altro giocatore.`); } try { await salvaSquadra.mutateAsync({ giocatoreId: g.id, dati: squadraCorrente }); setDatiSquadra(null); toast.success("Dati squadra aggiornati"); } catch (e) { toast.error(messaggioErrore(e, "Salvataggio non riuscito")); } } async function confermaAttivo(nuovo: boolean) { const messaggio = nuovo ? `Riattivare ${nomeCompleto(g)}?` : `Disattivare ${nomeCompleto(g)}? Sparisce dalla rosa attiva ma i suoi dati (presenze, voti, badge) restano.`; if (!confirm(messaggio)) return; try { await impostaAttivo.mutateAsync({ giocatoreId: g.id, attivo: nuovo }); toast.success(nuovo ? "Giocatore riattivato" : "Giocatore disattivato"); } catch (e) { toast.error(messaggioErrore(e, "Operazione non riuscita")); } } async function confermaTesseramento() { try { await salvaTesseramento.mutateAsync({ giocatoreId: g.id, dati: tesseramentoCorrente }); setTesseramento(null); toast.success("Tesseramento aggiornato"); } catch (e) { toast.error(messaggioErrore(e, "Salvataggio non riuscito")); } } async function confermaProfilo() { try { await salvaProfilo.mutateAsync(profiloCorrente); setBozza(null); toast.success("Profilo aggiornato"); } catch (e) { toast.error(e instanceof Error ? e.message : "Salvataggio non riuscito"); } } async function confermaScollega() { if ( !confirm( `Scollegare l'account di ${nomeCompleto(g)}? Potrà ricollegarsi al prossimo accesso.`, ) ) return; try { await scollega.mutateAsync(g.id); toast.success("Account scollegato"); } catch (e) { toast.error(e instanceof Error ? e.message : "Operazione non riuscita"); } } return (

Dati squadra

setDatiSquadra({ ...squadraCorrente, nome: e.target.value })} className={classiInput} /> setDatiSquadra({ ...squadraCorrente, cognome: e.target.value })} className={classiInput} /> setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) })} className={classiInput} />
setDatiSquadra({ ...squadraCorrente, email: e.target.value || null })} className={classiInput} />

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)} />
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
{g.authUserId ? ( ) : null}
); } function Documento({ icona, label, stato, path, }: { icona: React.ReactNode; label: string; stato: StatoScadenza | "presente" | "assente"; path: string | null; }) { const [inCorso, setInCorso] = useState(false); async function scarica() { if (!path || inCorso) return; setInCorso(true); try { await scaricaFile(path); } catch (error) { toast.error(error instanceof Error ? error.message : "Download non riuscito"); } finally { setInCorso(false); } } return ( ); } function SchedaGiocatore({ g, profilo, oggi, indice, }: { g: GiocatoreSquadra; profilo: Profilo | undefined; oggi: string; indice: number; }) { const [aperta, setAperta] = useState(false); const nome = nomeCompleto(g); const { ruolo, numero } = g; const perc = completamento(profilo); const sezioni = sezioniComplete(profilo); const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi); const fronte = statoScadenza(profilo?.documentoScadenza, profilo?.documentoFrontePath, oggi); const retro = statoScadenza(profilo?.documentoScadenza, profilo?.documentoRetroPath, oggi); return (
} label="Doc fronte" stato={fronte} path={profilo?.documentoFrontePath ?? null} /> } label="Doc retro" stato={retro} path={profilo?.documentoRetroPath ?? null} /> } label="Certificato" stato={certificato} path={profilo?.certificatoPath ?? null} /> } label="Foto" stato={sezioni.foto ? "presente" : "assente"} path={profilo?.fotoPath ?? null} /> {g.numeroTessera ? "Tesserato" : "Da tesserare"}
{aperta ? : null} ); } const datiVuoti: DatiSquadra = { nome: "", cognome: "", numero: 1, ruolo: "", email: null }; /** Form per aggiungere un giocatore alla rosa (DD-017): l'id `g` è calcolato in app, * il database non lo genera da solo. */ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) { const [aperto, setAperto] = useState(false); const [dati, setDati] = useState(datiVuoti); const aggiungi = useAggiungiGiocatore(); async function conferma() { const errore = validaDatiSquadra(dati); if (errore) { toast.error(errore); return; } if (numeroGiaUsato(righe, "", dati.numero)) { toast.warning(`Il numero ${dati.numero} è già assegnato a un altro giocatore.`); } try { await aggiungi.mutateAsync({ id: prossimoIdGiocatore(righe), dati }); toast.success(`${dati.nome} ${dati.cognome} aggiunto alla rosa`); setDati(datiVuoti); setAperto(false); } catch (e) { toast.error(messaggioErrore(e, "Aggiunta non riuscita")); } } if (!aperto) { return ( ); } return (

Nuovo giocatore

setDati({ ...dati, nome: e.target.value })} className={classiInput} /> setDati({ ...dati, cognome: e.target.value })} className={classiInput} /> setDati({ ...dati, numero: Number(e.target.value) })} className={classiInput} />
setDati({ ...dati, email: e.target.value || null })} className={classiInput} />
); } /** Riga compatta per riattivare un giocatore che aveva lasciato la squadra. */ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) { const impostaAttivo = useImpostaAttivo(); async function riattiva() { if (!confirm(`Riattivare ${nomeCompleto(g)}?`)) return; try { await impostaAttivo.mutateAsync({ giocatoreId: g.id, attivo: true }); toast.success("Giocatore riattivato"); } catch (e) { toast.error(messaggioErrore(e, "Operazione non riuscita")); } } return (

{nomeCompleto(g)}

#{g.numero} · {g.ruolo}

); } function Dashboard() { const admin = useIsAdmin(); const { righe: squadra } = useGiocatoriSquadra(); const { profili, isPending } = useProfili(); const oggi = oggiISO(); if (!admin) { return ( <>

Riservata agli amministratori della squadra.

); } const attivi = squadra.filter((g) => g.attivo); const disattivi = squadra.filter((g) => !g.attivo); const completi = attivi.filter((g) => completamento(profili[g.id]) === 100).length; const certificatiOk = attivi.filter( (g) => statoScadenza(profili[g.id]?.certificatoScadenza, profili[g.id]?.certificatoPath, oggi) === "valido", ).length; const tesserati = attivi.filter((g) => g.numeroTessera).length; return ( <>
{isPending ? (

Caricamento…

) : (
{attivi.map((g, i) => ( ))}
)}
{disattivi.length > 0 ? (
{disattivi.map((g) => ( ))}
) : null} ); }