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:
@@ -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
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user