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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
$$;
|
||||
@@ -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) {
|
||||
|
||||
@@ -21,6 +21,8 @@ const riga = (parziale: Partial<GiocatoreSquadra> = {}): GiocatoreSquadra => ({
|
||||
authUserId: null,
|
||||
attivo: true,
|
||||
email: null,
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
...parziale,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user