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
+4
View File
@@ -28,6 +28,10 @@ qui: sta in [ROADMAP.md](ROADMAP.md).
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.
- Tracciamento tesseramento CSI (migration `m8_tesseramento_csi`): numero e data di tessera
in `giocatori_squadra`, come gli altri campi che gestisce solo l'admin (DD-016/DD-018). La
dashboard mostra chi è già tesserato (badge sulla scheda, contatore in "Squadra") e un
pannello per registrare numero e data una volta arrivata la tessera dal CSI.
**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
+1 -1
View File
@@ -9,7 +9,7 @@ non in questo file.
| Tabella | Scopo | Note |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1``gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, già popolata (17 giocatori) ma **non ancora letta dal codice**: la rosa arriva tuttora da `src/lib/crapp-data.ts`, che resta il fallback anche dopo il passaggio. Destinata a diventare la source of truth. Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata solo per 2 dei 17 giocatori. |
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1``gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo), collegamento all'account (`auth_user_id`) ed email registrata (`email`). | Introdotta dalla migration `m1_giocatori_squadra`, già popolata (17 giocatori) ma **non ancora letta dal codice**: la rosa arriva tuttora da `src/lib/crapp-data.ts`, che resta il fallback anche dopo il passaggio. Destinata a diventare la source of truth. Vedi DD-015 e DD-016. La colonna `email` (migration `m5_email_giocatori_squadra`) è la chiave del collegamento automatico account↔giocatore al primo accesso (DD-018): NULL finché non nota, oggi impostata solo per 2 dei 17 giocatori. Le colonne `numero_tessera`/`data_tessera` (migration `m8_tesseramento_csi`) tracciano chi è già tesserato al CSI; come `numero`/`ruolo` le scrive solo un admin, il trigger di M1/M5 le include tra i campi bloccati per chi reclama il proprio slot. |
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
+2 -2
View File
@@ -24,8 +24,8 @@ di attivazione sta in [PROJECT_STATE.md](../PROJECT_STATE.md).
- [x] Certificati medici — caricamento, scadenza, stato e download; lo storico dei
certificati resta un'estensione futura
- [ ] Gestione tesseramenti CSI — raccolta dati ed export CSV pronti, manca il tracciamento
di chi è già tesserato (numero e data di tessera)
- [x] Gestione tesseramenti CSI — raccolta dati, export CSV e tracciamento di chi è già
tesserato (numero e data di tessera)
- [x] Dashboard amministratore
- [x] Download CSV dati
+11
View File
@@ -158,6 +158,7 @@ Per ogni giocatore vengono mostrati.
- Certificato medico
- Documento di identità
- Foto tessera
- Stato tesseramento CSI (tesserato / da tesserare)
Azioni disponibili.
@@ -166,6 +167,7 @@ Azioni disponibili.
- Scarica documento
- Scarica foto tessera
- Modifica dati squadra e dati personali del giocatore (DD-017)
- Registra numero e data della tessera CSI, una volta arrivata dal comitato
- 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)
@@ -189,6 +191,15 @@ Campi esportati.
- Data emissione
- Data scadenza
## Tracciamento tesseramento
Numero e data della tessera CSI non sono dati che il giocatore conosce in anticipo: arrivano
dal comitato dopo l'iscrizione effettiva. Per questo, a differenza dei dati personali del
profilo, li scrive solo un amministratore — come nome, cognome, numero di maglia e ruolo
(DD-017), il trigger sulla tabella li rende non modificabili dal giocatore stesso. La
dashboard mostra un badge "Tesserato"/"Da tesserare" su ogni scheda e il conteggio
complessivo della squadra.
## Completamento profilo
Ogni sezione contribuisce alla percentuale di completamento.
+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"
@@ -0,0 +1,52 @@
-- M8 — Tracciamento tesseramento CSI (roadmap v1.1)
--
-- Fin qui il modulo Profilo Giocatore raccoglie solo i dati per compilare la domanda di
-- tesseramento (profili_giocatore) e li esporta in CSV; manca lo stato di chi è già
-- tesserato. Numero e data della tessera non sono un dato che il giocatore conosce in
-- anticipo o può autodichiarare: arrivano dal CSI dopo l'iscrizione effettiva, quindi vanno
-- in giocatori_squadra insieme agli altri campi che gestisce solo l'admin (numero, ruolo),
-- non in profili_giocatore che il giocatore scrive di sé stesso.
ALTER TABLE public.giocatori_squadra
ADD COLUMN numero_tessera text,
ADD COLUMN data_tessera date;
COMMENT ON COLUMN public.giocatori_squadra.numero_tessera IS
'Numero della tessera CSI assegnata dal comitato, valorizzato solo a tesseramento avvenuto.';
COMMENT ON COLUMN public.giocatori_squadra.data_tessera IS
'Data di rilascio della tessera CSI.';
-- Estende il trigger di DD-016/DD-018 (m5_email_giocatori_squadra): i due nuovi campi
-- restano scrivibili solo dall'admin, come numero/ruolo/attivo — un giocatore che reclama
-- il proprio slot non può includerli nello stesso UPDATE.
CREATE OR REPLACE FUNCTION public.enforce_giocatori_squadra_update()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF public.has_role(auth.uid(), 'admin'::public.app_role) THEN
RETURN NEW;
END IF;
IF OLD.auth_user_id IS NULL
AND NEW.auth_user_id = auth.uid()
AND OLD.email IS NOT NULL
AND lower(OLD.email) = lower(coalesce(auth.jwt() ->> 'email', ''))
AND NEW.id IS NOT DISTINCT FROM OLD.id
AND NEW.nome IS NOT DISTINCT FROM OLD.nome
AND NEW.cognome IS NOT DISTINCT FROM OLD.cognome
AND NEW.numero IS NOT DISTINCT FROM OLD.numero
AND NEW.ruolo IS NOT DISTINCT FROM OLD.ruolo
AND NEW.attivo IS NOT DISTINCT FROM OLD.attivo
AND NEW.email IS NOT DISTINCT FROM OLD.email
AND NEW.numero_tessera IS NOT DISTINCT FROM OLD.numero_tessera
AND NEW.data_tessera IS NOT DISTINCT FROM OLD.data_tessera
AND NEW.creato_il IS NOT DISTINCT FROM OLD.creato_il THEN
RETURN NEW;
END IF;
RAISE EXCEPTION 'Aggiornamento non autorizzato su giocatori_squadra';
END;
$$;
+16
View File
@@ -73,6 +73,22 @@ if (!URL_BASE || !CHIAVE_SERVIZIO || !CHIAVE_PUBBLICA) {
assert.equal(righe[0]?.auth_user_id ?? null, null, "lo slot g1 è rimasto libero");
});
// --- M8: tracciamento tesseramento CSI --------------------------------------
const m8 = await rest(
"giocatori_squadra?select=numero_tessera,data_tessera&limit=1",
CHIAVE_SERVIZIO,
);
if (!m8.ok) {
salta(
"colonne di tesseramento in giocatori_squadra",
"M8 non applicata (npx supabase db push)",
);
} else {
await prova("giocatori_squadra espone numero e data della tessera CSI", async () => {
assert.equal(m8.status, 200);
});
}
// --- M2: tabella dei profili -----------------------------------------------
const m2 = await rest("profili_giocatore?select=giocatore_id&limit=1", CHIAVE_SERVIZIO);
if (!m2.ok) {
+2
View File
@@ -21,6 +21,8 @@ const riga = (parziale: Partial<GiocatoreSquadra> = {}): GiocatoreSquadra => ({
authUserId: null,
attivo: true,
email: null,
numeroTessera: null,
dataTessera: null,
...parziale,
});
+4
View File
@@ -112,6 +112,8 @@ const squadra: GiocatoreSquadra[] = [
authUserId: null,
attivo: true,
email: null,
numeroTessera: null,
dataTessera: null,
},
{
id: "g2",
@@ -122,6 +124,8 @@ const squadra: GiocatoreSquadra[] = [
authUserId: "u2",
attivo: true,
email: null,
numeroTessera: null,
dataTessera: null,
},
];
const csv = csvTesseramento(squadra, { g1: completo });