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:
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user