Aggiunge il tracciamento del tesseramento CSI in giocatori_squadra

Numero e data della tessera arrivano dal comitato dopo l'iscrizione, quindi
li scrive solo un admin (come numero/ruolo): estende il trigger di M1/M5,
aggiunge il pannello dedicato in /admin con badge e conteggio in dashboard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 15:27:12 +02:00
co-authored by Claude Sonnet 5
parent ff45b2379c
commit c7445cfab1
10 changed files with 202 additions and 5 deletions
+45 -1
View File
@@ -16,6 +16,8 @@ export type GiocatoreSquadra = {
authUserId: string | null;
attivo: boolean;
email: string | null;
numeroTessera: string | null;
dataTessera: string | null;
};
type RigaGiocatoreSquadra = {
@@ -27,6 +29,8 @@ type RigaGiocatoreSquadra = {
auth_user_id: string | null;
attivo: boolean;
email: string | null;
numero_tessera: string | null;
data_tessera: string | null;
};
export const SQUADRA_KEY = ["giocatori-squadra"] as const;
@@ -41,6 +45,8 @@ export function rosaFallback(): GiocatoreSquadra[] {
authUserId: null,
attivo: true,
email: null,
numeroTessera: null,
dataTessera: null,
}));
}
@@ -70,7 +76,9 @@ export function slotPerEmail(
async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
const { data, error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.select("id, nome, cognome, numero, ruolo, auth_user_id, attivo, email")
.select(
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera",
)
.order("cognome")
.order("nome");
if (error) throw error;
@@ -84,6 +92,8 @@ async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
authUserId: r.auth_user_id,
attivo: r.attivo,
email: r.email,
numeroTessera: r.numero_tessera,
dataTessera: r.data_tessera,
}));
}
@@ -158,6 +168,40 @@ export function useSalvaDatiSquadra() {
});
}
/** Numero e data della tessera CSI, note solo dopo il tesseramento effettivo. */
export type DatiTesseramento = Pick<GiocatoreSquadra, "numeroTessera" | "dataTessera">;
/**
* Registra numero e data della tessera CSI (roadmap v1.1). Campo puramente amministrativo:
* il trigger di M8 lo rende scrivibile solo da un admin, il giocatore non può autodichiararsi
* tesserato.
*/
export function useSalvaTesseramento() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; dati: DatiTesseramento }) => {
const dati = {
numero_tessera: input.dati.numeroTessera?.trim() || null,
data_tessera: input.dati.dataTessera || null,
};
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update(dati)
.eq("id", input.giocatoreId);
if (error) throw error;
return {
giocatoreId: input.giocatoreId,
dati: { numeroTessera: dati.numero_tessera, dataTessera: dati.data_tessera },
};
},
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`
+65 -1
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import {
BadgeCheck,
ChevronDown,
Download,
FileText,
@@ -25,9 +26,11 @@ import {
useGiocatoriSquadra,
useImpostaAttivo,
useSalvaDatiSquadra,
useSalvaTesseramento,
useScollegaAccount,
validaDatiSquadra,
type DatiSquadra,
type DatiTesseramento,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
@@ -102,11 +105,18 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
const { righe } = useGiocatoriSquadra();
const salvaSquadra = useSalvaDatiSquadra();
const salvaProfilo = useSalvaProfilo();
const salvaTesseramento = useSalvaTesseramento();
const scollega = useScollegaAccount();
const impostaAttivo = useImpostaAttivo();
const [datiSquadra, setDatiSquadra] = useState<DatiSquadra | null>(null);
const [bozza, setBozza] = useState<Profilo | null>(null);
const [tesseramento, setTesseramento] = useState<DatiTesseramento | null>(null);
const tesseramentoCorrente: DatiTesseramento = tesseramento ?? {
numeroTessera: g.numeroTessera,
dataTessera: g.dataTessera,
};
const squadraCorrente: DatiSquadra = datiSquadra ?? {
nome: g.nome,
@@ -148,6 +158,16 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
}
}
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);
@@ -229,6 +249,39 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
Salva dati squadra
</button>
<h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3>
<div className="grid grid-cols-2 gap-3">
<Campo label="Numero tessera">
<input
value={tesseramentoCorrente.numeroTessera ?? ""}
maxLength={40}
placeholder="Non ancora tesserato"
onChange={(e) =>
setTesseramento({ ...tesseramentoCorrente, numeroTessera: e.target.value || null })
}
className={classiInput}
/>
</Campo>
<Campo label="Data tessera">
<input
type="date"
value={tesseramentoCorrente.dataTessera ?? ""}
onChange={(e) =>
setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null })
}
className={classiInput}
/>
</Campo>
</div>
<button
type="button"
onClick={confermaTesseramento}
disabled={!tesseramento || salvaTesseramento.isPending}
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Salva tesseramento
</button>
<CampiProfilo
corrente={profiloCorrente}
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
@@ -401,6 +454,15 @@ function SchedaGiocatore({
stato={sezioni.foto ? "presente" : "assente"}
path={profilo?.fotoPath ?? null}
/>
<span
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-bold uppercase",
statoClasse[g.numeroTessera ? "presente" : "assente"],
)}
>
<BadgeCheck className="h-3.5 w-3.5" />
{g.numeroTessera ? "Tesserato" : "Da tesserare"}
</span>
</div>
{aperta ? <ModificaGiocatore g={g} profilo={profilo} /> : null}
@@ -581,13 +643,14 @@ function Dashboard() {
statoScadenza(profili[g.id]?.certificatoScadenza, profili[g.id]?.certificatoPath, oggi) ===
"valido",
).length;
const tesserati = attivi.filter((g) => g.numeroTessera).length;
return (
<>
<PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" />
<Section titolo="Squadra">
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-2 gap-2">
<StatTile valore={attivi.length} label="Giocatori" />
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
<StatTile
@@ -595,6 +658,7 @@ function Dashboard() {
label="Certificati validi"
hint="non scaduti"
/>
<StatTile valore={`${tesserati}/${attivi.length}`} label="Tesserati" hint="CSI" />
</div>
<button
type="button"