Dashboard admin: aggiungi, disattiva e riattiva giocatori

- Aggiungi giocatore: nuovo form in /admin (nome, cognome, numero,
  ruolo, email opzionale), id g<N> 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 11:29:41 +02:00
co-authored by Claude Sonnet 5
parent 13e5c3bd23
commit d813dee282
6 changed files with 332 additions and 19 deletions
+70 -9
View File
@@ -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<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo">;
/** 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<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo" | "email">;
/**
* 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<N>` 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<GiocatoreSquadra[]>(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<N>`) 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<GiocatoreSquadra[]>(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)),
);
},
});
+224 -7
View File
@@ -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<StatoScadenza | "presente" | "assente", string> = {
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 (
<label className="block">
@@ -88,6 +103,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
const salvaSquadra = useSalvaDatiSquadra();
const salvaProfilo = useSalvaProfilo();
const scollega = useScollegaAccount();
const impostaAttivo = useImpostaAttivo();
const [datiSquadra, setDatiSquadra] = useState<DatiSquadra | null>(null);
const [bozza, setBozza] = useState<Profilo | null>(null);
@@ -97,6 +113,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
cognome: g.cognome,
numero: g.numero,
ruolo: g.ruolo,
email: g.email,
};
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
@@ -114,7 +131,20 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
setDatiSquadra(null);
toast.success("Dati squadra aggiornati");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Salvataggio non riuscito");
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"));
}
}
@@ -181,6 +211,15 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
/>
</Campo>
</div>
<Campo label="Email (collegamento automatico al login, DD-018)">
<input
type="email"
value={squadraCorrente.email ?? ""}
placeholder="nome@esempio.com"
onChange={(e) => setDatiSquadra({ ...squadraCorrente, email: e.target.value || null })}
className={classiInput}
/>
</Campo>
<button
type="button"
onClick={confermaSquadra}
@@ -208,16 +247,37 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
<span className="min-w-0 text-muted-foreground">
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
</span>
{g.authUserId ? (
<div className="flex shrink-0 items-center gap-2">
{g.authUserId ? (
<button
type="button"
onClick={confermaScollega}
disabled={scollega.isPending}
className="premi flex items-center gap-1.5 rounded-xl bg-destructive px-3 py-2 font-bold text-destructive-foreground disabled:opacity-50"
>
<Unlink className="h-3.5 w-3.5" /> Scollega
</button>
) : null}
<button
type="button"
onClick={confermaScollega}
disabled={scollega.isPending}
className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-destructive px-3 py-2 font-bold text-destructive-foreground disabled:opacity-50"
onClick={() => void confermaAttivo(!g.attivo)}
disabled={impostaAttivo.isPending}
className={cn(
"premi flex items-center gap-1.5 rounded-xl px-3 py-2 font-bold disabled:opacity-50",
g.attivo ? "bg-secondary text-foreground" : "bg-success text-success-foreground",
)}
>
<Unlink className="h-3.5 w-3.5" /> Scollega
{g.attivo ? (
<>
<UserX className="h-3.5 w-3.5" /> Disattiva
</>
) : (
<>
<UserCheck className="h-3.5 w-3.5" /> Riattiva
</>
)}
</button>
) : null}
</div>
</div>
</div>
);
@@ -348,6 +408,151 @@ function SchedaGiocatore({
);
}
const datiVuoti: DatiSquadra = { nome: "", cognome: "", numero: 1, ruolo: "", email: null };
/** Form per aggiungere un giocatore alla rosa (DD-017): l'id `g<N>` è calcolato in app,
* il database non lo genera da solo. */
function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
const [aperto, setAperto] = useState(false);
const [dati, setDati] = useState<DatiSquadra>(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 (
<button
type="button"
onClick={() => setAperto(true)}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
>
<UserPlus className="h-4 w-4" /> Aggiungi giocatore
</button>
);
}
return (
<div className="mt-3 space-y-3 rounded-2xl bg-card p-4 shadow-card">
<h3 className="font-display text-sm uppercase tracking-wide">Nuovo giocatore</h3>
<div className="grid grid-cols-2 gap-3">
<Campo label="Nome">
<input
value={dati.nome}
maxLength={40}
onChange={(e) => setDati({ ...dati, nome: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Cognome">
<input
value={dati.cognome}
maxLength={40}
onChange={(e) => setDati({ ...dati, cognome: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Numero">
<input
type="number"
min={1}
value={dati.numero}
onChange={(e) => setDati({ ...dati, numero: Number(e.target.value) })}
className={classiInput}
/>
</Campo>
<Campo label="Ruolo">
<input
value={dati.ruolo}
maxLength={30}
onChange={(e) => setDati({ ...dati, ruolo: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<Campo label="Email (collegamento automatico al login, opzionale)">
<input
type="email"
value={dati.email ?? ""}
placeholder="nome@esempio.com"
onChange={(e) => setDati({ ...dati, email: e.target.value || null })}
className={classiInput}
/>
</Campo>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setAperto(false);
setDati(datiVuoti);
}}
className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-xs font-bold uppercase"
>
Annulla
</button>
<button
type="button"
onClick={() => void conferma()}
disabled={aggiungi.isPending}
className="premi flex-1 rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Aggiungi
</button>
</div>
</div>
);
}
/** 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 (
<div className="flex items-center justify-between gap-3 rounded-2xl bg-card p-3 shadow-card">
<div className="min-w-0">
<p className="truncate font-semibold leading-tight">{nomeCompleto(g)}</p>
<p className="text-xs text-muted-foreground">
#{g.numero} · {g.ruolo}
</p>
</div>
<button
type="button"
onClick={() => void riattiva()}
disabled={impostaAttivo.isPending}
className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-success px-3 py-2 text-xs font-bold text-success-foreground disabled:opacity-50"
>
<UserCheck className="h-3.5 w-3.5" /> Riattiva
</button>
</div>
);
}
function Dashboard() {
const admin = useIsAdmin();
const { righe: squadra } = useGiocatoriSquadra();
@@ -369,6 +574,7 @@ function Dashboard() {
}
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) =>
@@ -399,6 +605,7 @@ function Dashboard() {
>
<Download className="h-4 w-4" /> Esporta CSV tesseramento
</button>
<AggiungiGiocatore righe={squadra} />
</Section>
<Section titolo="Profili" indice={1}>
@@ -414,6 +621,16 @@ function Dashboard() {
</div>
)}
</Section>
{disattivi.length > 0 ? (
<Section titolo="Giocatori disattivati" indice={2}>
<div className="space-y-2">
{disattivi.map((g) => (
<GiocatoreDisattivato key={g.id} g={g} />
))}
</div>
</Section>
) : null}
</>
);
}