L'amministratore può modificare i dati dei giocatori

Attua DD-017. La scheda della dashboard era di sola lettura: ora l'admin apre il
giocatore e modifica.

- Dati squadra (nome, cognome, numero, ruolo): le docs li assegnavano già agli
  amministratori, ma non esisteva nessuna schermata per cambiarli.
- Dati personali e del documento: compilabili al posto del giocatore, perché un
  export CSI incompleto rimanda il lavoro in chat.
- Scollega account: libera uno slot assegnato per errore, come previsto da
  DD-016 regola 2.

I file restano fuori: l'admin li scarica ma non li carica al posto di altri.

Nessuna migration: le policy di M1 e M2 riconoscevano già l'admin. I campi del
profilo diventano un componente condiviso (CampiProfilo) tra la schermata del
giocatore e la dashboard, con gli upload passati come slot: da admin quelle
righe non compaiono.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 18:15:16 +02:00
co-authored by Claude Opus 5
parent 3f52e9cc54
commit 718ef09dfa
3 changed files with 453 additions and 193 deletions
+125 -78
View File
@@ -12,10 +12,18 @@ import {
useSalvaProfilo, useSalvaProfilo,
type SezioneFile, type SezioneFile,
} from "@/lib/profili"; } from "@/lib/profili";
import { completamento, profiloVuoto, sezioniComplete, type Profilo } from "@/lib/profili-core"; import {
completamento,
profiloVuoto,
sezioniComplete,
type Profilo,
type Sezione,
} from "@/lib/profili-core";
const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"]; const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"];
const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm";
function Campo({ label, children }: { label: string; children: React.ReactNode }) { function Campo({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<label className="block"> <label className="block">
@@ -27,7 +35,14 @@ function Campo({ label, children }: { label: string; children: React.ReactNode }
); );
} }
const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"; function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) {
return (
<div className="flex items-center gap-2 pt-2">
<h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3>
{completa ? <Check className="h-4 w-4 text-success" /> : null}
</div>
);
}
function CampoFile({ function CampoFile({
label, label,
@@ -106,66 +121,27 @@ function CampoFile({
} }
/** /**
* Dati amministrativi del giocatore: quello che la dashboard amministratore poi legge. * I campi del profilo, condivisi tra il giocatore e la dashboard amministratore (DD-017).
* Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente. * Gli upload arrivano come slot: l'admin non carica file al posto di altri, quindi da lì
* quelle righe semplicemente non compaiono.
*/ */
export function ProfiloAmministrativo({ export function CampiProfilo({
giocatoreId, corrente,
indice = 0, aggiorna,
sezioni,
fileDocumento,
fileCertificato,
fileFoto,
}: { }: {
giocatoreId: string; corrente: Profilo;
indice?: number; aggiorna: (patch: Partial<Profilo>) => void;
sezioni: Record<Sezione, boolean>;
fileDocumento?: React.ReactNode;
fileCertificato?: React.ReactNode;
fileFoto?: React.ReactNode;
}) { }) {
const { profili } = useProfili();
const salva = useSalvaProfilo();
const [bozza, setBozza] = useState<Profilo | null>(null);
const salvato = profili[giocatoreId];
const corrente = bozza ?? salvato ?? profiloVuoto(giocatoreId);
const sporco = bozza !== null;
const perc = completamento(corrente);
const sezioni = sezioniComplete(corrente);
function aggiorna(patch: Partial<Profilo>) {
setBozza({ ...corrente, ...patch });
}
async function scrivi(profilo: Profilo) {
await salva.mutateAsync(profilo);
setBozza(null);
}
async function salvaBozza() {
try {
await scrivi(corrente);
toast.success("Profilo aggiornato");
} catch (errore) {
toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito");
}
}
// Un file caricato va persistito subito, insieme a quello che si stava scrivendo.
const caricato = (campo: keyof Profilo) => async (path: string) =>
scrivi({ ...corrente, [campo]: path });
return ( return (
<Section <>
titolo="Dati per il tesseramento"
indice={indice}
azione={<span className="text-xs font-bold text-muted-foreground tabular-nums">{perc}%</span>}
>
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
<div className="h-1.5 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-accent-grad transition-all"
style={{ width: `${perc}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro.
</p>
<Intestazione titolo="Dati personali" completa={sezioni.dati} /> <Intestazione titolo="Dati personali" completa={sezioni.dati} />
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Campo label="Data di nascita"> <Campo label="Data di nascita">
@@ -265,6 +241,89 @@ export function ProfiloAmministrativo({
/> />
</Campo> </Campo>
</div> </div>
{fileDocumento}
<Intestazione titolo="Certificato medico" completa={sezioni.certificato} />
<Campo label="Data di scadenza">
<input
type="date"
value={corrente.certificatoScadenza ?? ""}
onChange={(e) => aggiorna({ certificatoScadenza: e.target.value })}
className={classiInput}
/>
</Campo>
{fileCertificato}
{fileFoto}
</>
);
}
/**
* Dati amministrativi del giocatore: quello che la dashboard amministratore poi legge.
* Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente.
*/
export function ProfiloAmministrativo({
giocatoreId,
indice = 0,
}: {
giocatoreId: string;
indice?: number;
}) {
const { profili } = useProfili();
const salva = useSalvaProfilo();
const [bozza, setBozza] = useState<Profilo | null>(null);
const salvato = profili[giocatoreId];
const corrente = bozza ?? salvato ?? profiloVuoto(giocatoreId);
const sporco = bozza !== null;
const perc = completamento(corrente);
const sezioni = sezioniComplete(corrente);
function aggiorna(patch: Partial<Profilo>) {
setBozza({ ...corrente, ...patch });
}
async function scrivi(profilo: Profilo) {
await salva.mutateAsync(profilo);
setBozza(null);
}
async function salvaBozza() {
try {
await scrivi(corrente);
toast.success("Profilo aggiornato");
} catch (errore) {
toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito");
}
}
// Un file caricato va persistito subito, insieme a quello che si stava scrivendo.
const caricato = (campo: keyof Profilo) => async (path: string) =>
scrivi({ ...corrente, [campo]: path });
return (
<Section
titolo="Dati per il tesseramento"
indice={indice}
azione={<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>}
>
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
<div className="h-1.5 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-accent-grad transition-all"
style={{ width: `${perc}%` }}
/>
</div>
<p className="text-xs text-muted-foreground">
Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro.
</p>
<CampiProfilo
corrente={corrente}
aggiorna={aggiorna}
sezioni={sezioni}
fileDocumento={
<div className="divide-y divide-border"> <div className="divide-y divide-border">
<CampoFile <CampoFile
label="Foto fronte" label="Foto fronte"
@@ -281,16 +340,8 @@ export function ProfiloAmministrativo({
onCaricato={caricato("documentoRetroPath")} onCaricato={caricato("documentoRetroPath")}
/> />
</div> </div>
}
<Intestazione titolo="Certificato medico" completa={sezioni.certificato} /> fileCertificato={
<Campo label="Data di scadenza">
<input
type="date"
value={corrente.certificatoScadenza ?? ""}
onChange={(e) => aggiorna({ certificatoScadenza: e.target.value })}
className={classiInput}
/>
</Campo>
<CampoFile <CampoFile
label="Certificato medico" label="Certificato medico"
path={corrente.certificatoPath} path={corrente.certificatoPath}
@@ -298,7 +349,9 @@ export function ProfiloAmministrativo({
giocatoreId={giocatoreId} giocatoreId={giocatoreId}
onCaricato={caricato("certificatoPath")} onCaricato={caricato("certificatoPath")}
/> />
}
fileFoto={
<>
<Intestazione titolo="Foto tessera" completa={sezioni.foto} /> <Intestazione titolo="Foto tessera" completa={sezioni.foto} />
<CampoFile <CampoFile
label="Foto tessera" label="Foto tessera"
@@ -307,6 +360,9 @@ export function ProfiloAmministrativo({
giocatoreId={giocatoreId} giocatoreId={giocatoreId}
onCaricato={caricato("fotoPath")} onCaricato={caricato("fotoPath")}
/> />
</>
}
/>
<button <button
type="button" type="button"
@@ -359,12 +415,3 @@ export function CompletaProfilo({
</Reveal> </Reveal>
); );
} }
function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) {
return (
<div className="flex items-center gap-2 pt-2">
<h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3>
{completa ? <Check className="h-4 w-4 text-success" /> : null}
</div>
);
}
+74
View File
@@ -90,6 +90,80 @@ export function useGiocatoriSquadra() {
return { ...query, righe, daDatabase: !!query.data?.length }; 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">;
/**
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
* meglio dirlo qui che far tornare un errore Postgres all'utente.
* Restituisce il messaggio da mostrare, oppure `null` se va bene.
*/
export function validaDatiSquadra(dati: DatiSquadra): string | null {
if (!dati.nome.trim()) return "Il nome non può essere vuoto.";
if (!dati.cognome.trim()) return "Il cognome non può essere vuoto.";
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.";
return null;
}
/** Numeri di maglia doppi: il database li accetta, la squadra no. */
export function numeroGiaUsato(
righe: GiocatoreSquadra[],
giocatoreId: string,
numero: number,
): boolean {
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
}
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
export function useSalvaDatiSquadra() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => {
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(),
})
.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)),
);
},
});
}
/**
* Libera uno slot occupato per errore (DD-016 regola 2, DD-017). Il giocatore
* potrà ricollegarsi al primo accesso; i dati del profilo restano dove sono.
*/
export function useScollegaAccount() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (giocatoreId: string) => {
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update({ auth_user_id: null })
.eq("id", giocatoreId);
if (error) throw error;
return giocatoreId;
},
onSuccess: (giocatoreId) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
(prec ?? []).map((g) => (g.id === giocatoreId ? { ...g, authUserId: null } : g)),
);
},
});
}
/** /**
* Collega l'account al giocatore scelto. Il trigger di M1 accetta l'operazione solo se * Collega l'account al giocatore scelto. Il trigger di M1 accetta l'operazione solo se
* lo slot è libero e se nessun altro campo cambia (DD-016 regola 2): il vincolo vive nel * lo slot è libero e se nessun altro campo cambia (DD-016 regola 2): il vincolo vive nel
+181 -42
View File
@@ -1,14 +1,34 @@
import { useState } from "react"; import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { ChevronDown, Download, FileText, IdCard, Image, Loader2, Lock } from "lucide-react"; import {
ChevronDown,
Download,
FileText,
IdCard,
Image,
Loader2,
Lock,
Unlink,
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits"; import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits";
import { useGiocatoriSquadra, nomeCompleto } from "@/lib/giocatori-squadra"; import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
import { useProfili, scaricaFile } from "@/lib/profili"; import {
nomeCompleto,
numeroGiaUsato,
useGiocatoriSquadra,
useSalvaDatiSquadra,
useScollegaAccount,
validaDatiSquadra,
type DatiSquadra,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
import { import {
completamento, completamento,
csvTesseramento, csvTesseramento,
profiloVuoto,
sezioniComplete, sezioniComplete,
statoScadenza, statoScadenza,
type Profilo, type Profilo,
@@ -46,11 +66,159 @@ const statoClasse: Record<StatoScadenza | "presente" | "assente", string> = {
assente: "bg-secondary text-muted-foreground", assente: "bg-secondary text-muted-foreground",
}; };
function Riga({ etichetta, valore }: { etichetta: string; valore: string | null }) { const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm";
function Campo({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="flex items-baseline justify-between gap-3 py-1 text-sm"> <label className="block">
<span className="shrink-0 text-muted-foreground">{etichetta}</span> <span className="text-[10px] font-bold uppercase tracking-wide text-muted-foreground">
<span className="truncate text-right font-medium">{valore || "—"}</span> {label}
</span>
<span className="mt-1 block">{children}</span>
</label>
);
}
/**
* Pannello di modifica dell'admin (DD-017): dati squadra, dati personali e collegamento
* all'account. I file restano fuori: l'admin li scarica, non li carica al posto di altri.
*/
function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profilo | undefined }) {
const { righe } = useGiocatoriSquadra();
const salvaSquadra = useSalvaDatiSquadra();
const salvaProfilo = useSalvaProfilo();
const scollega = useScollegaAccount();
const [datiSquadra, setDatiSquadra] = useState<DatiSquadra | null>(null);
const [bozza, setBozza] = useState<Profilo | null>(null);
const squadraCorrente: DatiSquadra = datiSquadra ?? {
nome: g.nome,
cognome: g.cognome,
numero: g.numero,
ruolo: g.ruolo,
};
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
async function confermaSquadra() {
const errore = validaDatiSquadra(squadraCorrente);
if (errore) {
toast.error(errore);
return;
}
if (numeroGiaUsato(righe, g.id, squadraCorrente.numero)) {
toast.warning(`Il numero ${squadraCorrente.numero} è già assegnato a un altro giocatore.`);
}
try {
await salvaSquadra.mutateAsync({ giocatoreId: g.id, dati: squadraCorrente });
setDatiSquadra(null);
toast.success("Dati squadra aggiornati");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Salvataggio non riuscito");
}
}
async function confermaProfilo() {
try {
await salvaProfilo.mutateAsync(profiloCorrente);
setBozza(null);
toast.success("Profilo aggiornato");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Salvataggio non riuscito");
}
}
async function confermaScollega() {
if (
!confirm(
`Scollegare l'account di ${nomeCompleto(g)}? Potrà ricollegarsi al prossimo accesso.`,
)
)
return;
try {
await scollega.mutateAsync(g.id);
toast.success("Account scollegato");
} catch (e) {
toast.error(e instanceof Error ? e.message : "Operazione non riuscita");
}
}
return (
<div className="mt-3 space-y-3 border-t border-border pt-3">
<h3 className="font-display text-sm uppercase tracking-wide">Dati squadra</h3>
<div className="grid grid-cols-2 gap-3">
<Campo label="Nome">
<input
value={squadraCorrente.nome}
maxLength={40}
onChange={(e) => setDatiSquadra({ ...squadraCorrente, nome: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Cognome">
<input
value={squadraCorrente.cognome}
maxLength={40}
onChange={(e) => setDatiSquadra({ ...squadraCorrente, cognome: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Numero">
<input
type="number"
min={1}
value={squadraCorrente.numero}
onChange={(e) => setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) })}
className={classiInput}
/>
</Campo>
<Campo label="Ruolo">
<input
value={squadraCorrente.ruolo}
maxLength={30}
onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<button
type="button"
onClick={confermaSquadra}
disabled={!datiSquadra || salvaSquadra.isPending}
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Salva dati squadra
</button>
<CampiProfilo
corrente={profiloCorrente}
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
sezioni={sezioniComplete(profiloCorrente)}
/>
<button
type="button"
onClick={confermaProfilo}
disabled={!bozza || salvaProfilo.isPending}
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Salva dati personali
</button>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3 text-xs">
<span className="min-w-0 text-muted-foreground">
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
</span>
{g.authUserId ? (
<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"
>
<Unlink className="h-3.5 w-3.5" /> Scollega
</button>
) : null}
</div>
</div> </div>
); );
} }
@@ -98,21 +266,19 @@ function Documento({
} }
function SchedaGiocatore({ function SchedaGiocatore({
nome, g,
ruolo,
numero,
profilo, profilo,
oggi, oggi,
indice, indice,
}: { }: {
nome: string; g: GiocatoreSquadra;
ruolo: string;
numero: number;
profilo: Profilo | undefined; profilo: Profilo | undefined;
oggi: string; oggi: string;
indice: number; indice: number;
}) { }) {
const [aperta, setAperta] = useState(false); const [aperta, setAperta] = useState(false);
const nome = nomeCompleto(g);
const { ruolo, numero } = g;
const perc = completamento(profilo); const perc = completamento(profilo);
const sezioni = sezioniComplete(profilo); const sezioni = sezioniComplete(profilo);
const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi); const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi);
@@ -177,26 +343,7 @@ function SchedaGiocatore({
/> />
</div> </div>
{aperta ? ( {aperta ? <ModificaGiocatore g={g} profilo={profilo} /> : null}
<div className="mt-3 border-t border-border pt-3">
<Riga etichetta="Data di nascita" valore={profilo?.dataNascita ?? null} />
<Riga etichetta="Luogo di nascita" valore={profilo?.luogoNascita ?? null} />
<Riga etichetta="Indirizzo" valore={profilo?.indirizzo ?? null} />
<Riga etichetta="Telefono" valore={profilo?.telefono ?? null} />
<Riga etichetta="Email" valore={profilo?.email ?? null} />
<Riga
etichetta="Documento"
valore={
profilo?.documentoNumero
? `${profilo.documentoTipo ?? ""} ${profilo.documentoNumero}`.trim()
: null
}
/>
<Riga etichetta="Rilasciato da" valore={profilo?.documentoRilasciatoDa ?? null} />
<Riga etichetta="Scadenza documento" valore={profilo?.documentoScadenza ?? null} />
<Riga etichetta="Scadenza certificato" valore={profilo?.certificatoScadenza ?? null} />
</div>
) : null}
</Reveal> </Reveal>
); );
} }
@@ -262,15 +409,7 @@ function Dashboard() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{attivi.map((g, i) => ( {attivi.map((g, i) => (
<SchedaGiocatore <SchedaGiocatore key={g.id} g={g} profilo={profili[g.id]} oggi={oggi} indice={i} />
key={g.id}
nome={nomeCompleto(g)}
ruolo={g.ruolo}
numero={g.numero}
profilo={profili[g.id]}
oggi={oggi}
indice={i}
/>
))} ))}
</div> </div>
)} )}