From d813dee282c16ee4d86cab57e628b9b840d9c62f Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 1 Sep 2026 11:29:41 +0200 Subject: [PATCH] Dashboard admin: aggiungi, disattiva e riattiva giocatori MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aggiungi giocatore: nuovo form in /admin (nome, cognome, numero, ruolo, email opzionale), id g calcolato in automatico. - Email modificabile anche per i giocatori già in rosa dal pannello Dati squadra, non solo alla creazione — completa quanto rimandato da DD-018. - Disattiva/Riattiva: un giocatore che lascia la squadra sparisce dalla rosa attiva senza che la riga venga eliminata, così presenze, voti, pagelle e badge della stagione restano agganciati al suo id. Nuova sezione "Giocatori disattivati" per riattivarli. - Messaggio d'errore leggibile per l'unico vincolo unique della tabella (email duplicata) invece del codice Postgres grezzo. Nessuna migration: sia l'inserimento sia la modifica passano dalla policy admin FOR ALL già esistente su giocatori_squadra. Co-Authored-By: Claude Sonnet 5 --- docs/CHANGELOG.md | 5 + docs/modules/profilo-giocatore.md | 8 +- src/lib/giocatori-squadra.ts | 79 ++++++++-- src/routes/admin.tsx | 231 +++++++++++++++++++++++++++- test/unit/giocatori-squadra.test.ts | 26 +++- test/unit/profili-core.test.ts | 2 +- 6 files changed, 332 insertions(+), 19 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fe72568..a1b2405 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -23,6 +23,11 @@ qui: sta in [ROADMAP.md](ROADMAP.md). confronta l'email dell'account Google con `giocatori_squadra.email` e collega da solo. Senza corrispondenza compare solo un messaggio d'errore, con un pulsante per uscire e riprovare con un altro account. +- Dashboard admin: nuove azioni "Aggiungi giocatore" (con email opzionale per il + collegamento automatico) e "Disattiva/Riattiva giocatore" per chi lascia la squadra — la + riga non viene mai eliminata, così presenze, voti, pagelle e badge della stagione restano + agganciati al suo id. L'email è anche modificabile dal pannello "Dati squadra" di ogni + giocatore già in rosa. **Da applicare solo a squadra collegata**, altrimenti chi non ha ancora fatto login vede l'app vuota. - Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica diff --git a/docs/modules/profilo-giocatore.md b/docs/modules/profilo-giocatore.md index 9782dce..2bbf1fb 100644 --- a/docs/modules/profilo-giocatore.md +++ b/docs/modules/profilo-giocatore.md @@ -26,9 +26,13 @@ Può: - scaricare documenti e certificati - esportare i dati necessari al tesseramento CSI - verificare lo stato di completamento dei profili -- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo) +- 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) +- 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 Non può caricare o sostituire i file altrui: documento, certificato e foto restano responsabilità del giocatore che li fornisce. @@ -163,6 +167,8 @@ Azioni disponibili. - Scarica foto tessera - Modifica dati squadra e dati personali del giocatore (DD-017) - Scollega account, per liberare uno slot assegnato per errore +- Aggiungi giocatore, per inserire un nuovo membro della squadra +- Disattiva/Riattiva giocatore, per chi lascia la squadra (o rientra) ## Esportazione CSI diff --git a/src/lib/giocatori-squadra.ts b/src/lib/giocatori-squadra.ts index 101d24a..2099ff4 100644 --- a/src/lib/giocatori-squadra.ts +++ b/src/lib/giocatori-squadra.ts @@ -100,8 +100,9 @@ export function useGiocatoriSquadra() { return { ...query, righe, daDatabase: !!query.data?.length }; } -/** Dati squadra: li gestisce solo un amministratore (DD-017). */ -export type DatiSquadra = Pick; +/** 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; /** * Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori): @@ -114,9 +115,19 @@ export function validaDatiSquadra(dati: DatiSquadra): string | null { if (!Number.isInteger(dati.numero) || dati.numero <= 0) return "Il numero di maglia deve essere maggiore di zero."; if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto."; + if (dati.email?.trim() && !dati.email.includes("@")) return "L'email non è valida."; return null; } +/** Il prossimo id libero nel formato `g` richiesto dal vincolo della tabella. */ +export function prossimoIdGiocatore(righe: GiocatoreSquadra[]): string { + const max = righe.reduce((acc, g) => { + const n = Number(g.id.slice(1)); + return Number.isFinite(n) && n > acc ? n : acc; + }, 0); + return `g${max + 1}`; +} + /** Numeri di maglia doppi: il database li accetta, la squadra no. */ export function numeroGiaUsato( righe: GiocatoreSquadra[], @@ -131,21 +142,71 @@ 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 { error } = await supabaseNuoveTabelle .from("giocatori_squadra") - .update({ - nome: input.dati.nome.trim(), - cognome: input.dati.cognome.trim(), - numero: input.dati.numero, - ruolo: input.dati.ruolo.trim(), - }) + .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)), + ); + }, + }); +} + +/** + * Aggiunge un giocatore alla rosa (DD-017). Solo un admin passa le policy di M1. + * L'id (`g`) non è generato dal database: va calcolato con `prossimoIdGiocatore` + * prima di chiamare questa mutazione. + */ +export function useAggiungiGiocatore() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { id: string; dati: DatiSquadra }) => { + const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert({ + 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, + }); + if (error) throw error; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: SQUADRA_KEY }); + }, + }); +} + +/** + * 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. + */ +export function useImpostaAttivo() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { giocatoreId: string; attivo: boolean }) => { + const { error } = await supabaseNuoveTabelle + .from("giocatori_squadra") + .update({ attivo: input.attivo }) .eq("id", input.giocatoreId); if (error) throw error; return input; }, onSuccess: (input) => { queryClient.setQueryData(SQUADRA_KEY, (prec) => - (prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)), + (prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, attivo: input.attivo } : g)), ); }, }); diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 8326c2e..147e88f 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -9,6 +9,9 @@ import { Loader2, Lock, Unlink, + UserCheck, + UserPlus, + UserX, } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -17,7 +20,10 @@ import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo"; import { nomeCompleto, numeroGiaUsato, + prossimoIdGiocatore, + useAggiungiGiocatore, useGiocatoriSquadra, + useImpostaAttivo, useSalvaDatiSquadra, useScollegaAccount, validaDatiSquadra, @@ -68,6 +74,15 @@ const statoClasse: Record = { 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 (