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
+198 -151
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,
@@ -105,6 +120,144 @@ function CampoFile({
); );
} }
/**
* I campi del profilo, condivisi tra il giocatore e la dashboard amministratore (DD-017).
* Gli upload arrivano come slot: l'admin non carica file al posto di altri, quindi da lì
* quelle righe semplicemente non compaiono.
*/
export function CampiProfilo({
corrente,
aggiorna,
sezioni,
fileDocumento,
fileCertificato,
fileFoto,
}: {
corrente: Profilo;
aggiorna: (patch: Partial<Profilo>) => void;
sezioni: Record<Sezione, boolean>;
fileDocumento?: React.ReactNode;
fileCertificato?: React.ReactNode;
fileFoto?: React.ReactNode;
}) {
return (
<>
<Intestazione titolo="Dati personali" completa={sezioni.dati} />
<div className="grid grid-cols-2 gap-3">
<Campo label="Data di nascita">
<input
type="date"
value={corrente.dataNascita ?? ""}
onChange={(e) => aggiorna({ dataNascita: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Luogo di nascita">
<input
value={corrente.luogoNascita ?? ""}
maxLength={80}
onChange={(e) => aggiorna({ luogoNascita: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<Campo label="Indirizzo di residenza">
<input
value={corrente.indirizzo ?? ""}
maxLength={120}
onChange={(e) => aggiorna({ indirizzo: e.target.value })}
className={classiInput}
/>
</Campo>
<div className="grid grid-cols-2 gap-3">
<Campo label="Telefono">
<input
type="tel"
value={corrente.telefono ?? ""}
maxLength={20}
onChange={(e) => aggiorna({ telefono: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Email">
<input
type="email"
value={corrente.email ?? ""}
maxLength={120}
onChange={(e) => aggiorna({ email: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<Intestazione titolo="Documento di identità" completa={sezioni.documento} />
<div className="grid grid-cols-2 gap-3">
<Campo label="Tipo">
<select
value={corrente.documentoTipo ?? ""}
onChange={(e) => aggiorna({ documentoTipo: e.target.value })}
className={classiInput}
>
<option value=""></option>
{TIPI_DOCUMENTO.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</Campo>
<Campo label="Numero">
<input
value={corrente.documentoNumero ?? ""}
maxLength={40}
onChange={(e) => aggiorna({ documentoNumero: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<Campo label="Rilasciato da">
<input
value={corrente.documentoRilasciatoDa ?? ""}
maxLength={80}
onChange={(e) => aggiorna({ documentoRilasciatoDa: e.target.value })}
className={classiInput}
/>
</Campo>
<div className="grid grid-cols-2 gap-3">
<Campo label="Data emissione">
<input
type="date"
value={corrente.documentoEmissione ?? ""}
onChange={(e) => aggiorna({ documentoEmissione: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Data scadenza">
<input
type="date"
value={corrente.documentoScadenza ?? ""}
onChange={(e) => aggiorna({ documentoScadenza: e.target.value })}
className={classiInput}
/>
</Campo>
</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. * 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. * Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente.
@@ -152,7 +305,7 @@ export function ProfiloAmministrativo({
<Section <Section
titolo="Dati per il tesseramento" titolo="Dati per il tesseramento"
indice={indice} indice={indice}
azione={<span className="text-xs font-bold text-muted-foreground tabular-nums">{perc}%</span>} 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="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-1.5 overflow-hidden rounded-full bg-secondary">
@@ -166,146 +319,49 @@ export function ProfiloAmministrativo({
Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro. Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro.
</p> </p>
<Intestazione titolo="Dati personali" completa={sezioni.dati} /> <CampiProfilo
<div className="grid grid-cols-2 gap-3"> corrente={corrente}
<Campo label="Data di nascita"> aggiorna={aggiorna}
<input sezioni={sezioni}
type="date" fileDocumento={
value={corrente.dataNascita ?? ""} <div className="divide-y divide-border">
onChange={(e) => aggiorna({ dataNascita: e.target.value })} <CampoFile
className={classiInput} label="Foto fronte"
path={corrente.documentoFrontePath}
sezione="documento-fronte"
giocatoreId={giocatoreId}
onCaricato={caricato("documentoFrontePath")}
/>
<CampoFile
label="Foto retro"
path={corrente.documentoRetroPath}
sezione="documento-retro"
giocatoreId={giocatoreId}
onCaricato={caricato("documentoRetroPath")}
/>
</div>
}
fileCertificato={
<CampoFile
label="Certificato medico"
path={corrente.certificatoPath}
sezione="certificato"
giocatoreId={giocatoreId}
onCaricato={caricato("certificatoPath")}
/> />
</Campo> }
<Campo label="Luogo di nascita"> fileFoto={
<input <>
value={corrente.luogoNascita ?? ""} <Intestazione titolo="Foto tessera" completa={sezioni.foto} />
maxLength={80} <CampoFile
onChange={(e) => aggiorna({ luogoNascita: e.target.value })} label="Foto tessera"
className={classiInput} path={corrente.fotoPath}
/> sezione="foto"
</Campo> giocatoreId={giocatoreId}
</div> onCaricato={caricato("fotoPath")}
<Campo label="Indirizzo di residenza"> />
<input </>
value={corrente.indirizzo ?? ""} }
maxLength={120}
onChange={(e) => aggiorna({ indirizzo: e.target.value })}
className={classiInput}
/>
</Campo>
<div className="grid grid-cols-2 gap-3">
<Campo label="Telefono">
<input
type="tel"
value={corrente.telefono ?? ""}
maxLength={20}
onChange={(e) => aggiorna({ telefono: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Email">
<input
type="email"
value={corrente.email ?? ""}
maxLength={120}
onChange={(e) => aggiorna({ email: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<Intestazione titolo="Documento di identità" completa={sezioni.documento} />
<div className="grid grid-cols-2 gap-3">
<Campo label="Tipo">
<select
value={corrente.documentoTipo ?? ""}
onChange={(e) => aggiorna({ documentoTipo: e.target.value })}
className={classiInput}
>
<option value=""></option>
{TIPI_DOCUMENTO.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</Campo>
<Campo label="Numero">
<input
value={corrente.documentoNumero ?? ""}
maxLength={40}
onChange={(e) => aggiorna({ documentoNumero: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<Campo label="Rilasciato da">
<input
value={corrente.documentoRilasciatoDa ?? ""}
maxLength={80}
onChange={(e) => aggiorna({ documentoRilasciatoDa: e.target.value })}
className={classiInput}
/>
</Campo>
<div className="grid grid-cols-2 gap-3">
<Campo label="Data emissione">
<input
type="date"
value={corrente.documentoEmissione ?? ""}
onChange={(e) => aggiorna({ documentoEmissione: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Data scadenza">
<input
type="date"
value={corrente.documentoScadenza ?? ""}
onChange={(e) => aggiorna({ documentoScadenza: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<div className="divide-y divide-border">
<CampoFile
label="Foto fronte"
path={corrente.documentoFrontePath}
sezione="documento-fronte"
giocatoreId={giocatoreId}
onCaricato={caricato("documentoFrontePath")}
/>
<CampoFile
label="Foto retro"
path={corrente.documentoRetroPath}
sezione="documento-retro"
giocatoreId={giocatoreId}
onCaricato={caricato("documentoRetroPath")}
/>
</div>
<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>
<CampoFile
label="Certificato medico"
path={corrente.certificatoPath}
sezione="certificato"
giocatoreId={giocatoreId}
onCaricato={caricato("certificatoPath")}
/>
<Intestazione titolo="Foto tessera" completa={sezioni.foto} />
<CampoFile
label="Foto tessera"
path={corrente.fotoPath}
sezione="foto"
giocatoreId={giocatoreId}
onCaricato={caricato("fotoPath")}
/> />
<button <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>
)} )}