Dashboard admin: aggiungi, disattiva e riattiva giocatori
- Aggiungi giocatore: nuovo form in /admin (nome, cognome, numero, ruolo, email opzionale), id g<N> calcolato in automatico. - Email modificabile anche per i giocatori già in rosa dal pannello Dati squadra, non solo alla creazione — completa quanto rimandato da DD-018. - Disattiva/Riattiva: un giocatore che lascia la squadra sparisce dalla rosa attiva senza che la riga venga eliminata, così presenze, voti, pagelle e badge della stagione restano agganciati al suo id. Nuova sezione "Giocatori disattivati" per riattivarli. - Messaggio d'errore leggibile per l'unico vincolo unique della tabella (email duplicata) invece del codice Postgres grezzo. Nessuna migration: sia l'inserimento sia la modifica passano dalla policy admin FOR ALL già esistente su giocatori_squadra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,11 @@ qui: sta in [ROADMAP.md](ROADMAP.md).
|
|||||||
confronta l'email dell'account Google con `giocatori_squadra.email` e collega da solo.
|
confronta l'email dell'account Google con `giocatori_squadra.email` e collega da solo.
|
||||||
Senza corrispondenza compare solo un messaggio d'errore, con un pulsante per uscire e
|
Senza corrispondenza compare solo un messaggio d'errore, con un pulsante per uscire e
|
||||||
riprovare con un altro account.
|
riprovare con un altro account.
|
||||||
|
- Dashboard admin: nuove azioni "Aggiungi giocatore" (con email opzionale per il
|
||||||
|
collegamento automatico) e "Disattiva/Riattiva giocatore" per chi lascia la squadra — la
|
||||||
|
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.
|
||||||
**Da applicare solo a squadra collegata**, altrimenti chi non ha ancora fatto login vede
|
**Da applicare solo a squadra collegata**, altrimenti chi non ha ancora fatto login vede
|
||||||
l'app vuota.
|
l'app vuota.
|
||||||
- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica
|
- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ Può:
|
|||||||
- scaricare documenti e certificati
|
- scaricare documenti e certificati
|
||||||
- esportare i dati necessari al tesseramento CSI
|
- esportare i dati necessari al tesseramento CSI
|
||||||
- verificare lo stato di completamento dei profili
|
- verificare lo stato di completamento dei profili
|
||||||
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo)
|
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email)
|
||||||
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
|
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
|
||||||
- scollegare un account da un profilo, liberando lo slot
|
- scollegare un account da un profilo, liberando lo slot
|
||||||
|
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale)
|
||||||
|
- disattivare un giocatore che ha lasciato la squadra, e riattivarlo in caso di errore: la
|
||||||
|
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
|
||||||
|
agganciati al suo id
|
||||||
|
|
||||||
Non può caricare o sostituire i file altrui: documento, certificato e foto restano
|
Non può caricare o sostituire i file altrui: documento, certificato e foto restano
|
||||||
responsabilità del giocatore che li fornisce.
|
responsabilità del giocatore che li fornisce.
|
||||||
@@ -163,6 +167,8 @@ Azioni disponibili.
|
|||||||
- Scarica foto tessera
|
- Scarica foto tessera
|
||||||
- Modifica dati squadra e dati personali del giocatore (DD-017)
|
- Modifica dati squadra e dati personali del giocatore (DD-017)
|
||||||
- Scollega account, per liberare uno slot assegnato per errore
|
- 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)
|
||||||
|
|
||||||
## Esportazione CSI
|
## Esportazione CSI
|
||||||
|
|
||||||
|
|||||||
@@ -100,8 +100,9 @@ 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). */
|
/** Dati squadra: li gestisce solo un amministratore (DD-017). L'email è quella usata per
|
||||||
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo">;
|
* il collegamento automatico al primo accesso (DD-018), non il dato personale del profilo. */
|
||||||
|
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo" | "email">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
|
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
|
||||||
@@ -114,9 +115,19 @@ export function validaDatiSquadra(dati: DatiSquadra): string | null {
|
|||||||
if (!Number.isInteger(dati.numero) || dati.numero <= 0)
|
if (!Number.isInteger(dati.numero) || dati.numero <= 0)
|
||||||
return "Il numero di maglia deve essere maggiore di zero.";
|
return "Il numero di maglia deve essere maggiore di zero.";
|
||||||
if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto.";
|
if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto.";
|
||||||
|
if (dati.email?.trim() && !dati.email.includes("@")) return "L'email non è valida.";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Il prossimo id libero nel formato `g<N>` richiesto dal vincolo della tabella. */
|
||||||
|
export function prossimoIdGiocatore(righe: GiocatoreSquadra[]): string {
|
||||||
|
const max = righe.reduce((acc, g) => {
|
||||||
|
const n = Number(g.id.slice(1));
|
||||||
|
return Number.isFinite(n) && n > acc ? n : acc;
|
||||||
|
}, 0);
|
||||||
|
return `g${max + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Numeri di maglia doppi: il database li accetta, la squadra no. */
|
/** Numeri di maglia doppi: il database li accetta, la squadra no. */
|
||||||
export function numeroGiaUsato(
|
export function numeroGiaUsato(
|
||||||
righe: GiocatoreSquadra[],
|
righe: GiocatoreSquadra[],
|
||||||
@@ -131,21 +142,71 @@ export function useSalvaDatiSquadra() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => {
|
mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => {
|
||||||
const { error } = await supabaseNuoveTabelle
|
const dati = {
|
||||||
.from("giocatori_squadra")
|
|
||||||
.update({
|
|
||||||
nome: input.dati.nome.trim(),
|
nome: input.dati.nome.trim(),
|
||||||
cognome: input.dati.cognome.trim(),
|
cognome: input.dati.cognome.trim(),
|
||||||
numero: input.dati.numero,
|
numero: input.dati.numero,
|
||||||
ruolo: input.dati.ruolo.trim(),
|
ruolo: input.dati.ruolo.trim(),
|
||||||
})
|
email: input.dati.email?.trim() || null,
|
||||||
|
};
|
||||||
|
const { error } = await supabaseNuoveTabelle
|
||||||
|
.from("giocatori_squadra")
|
||||||
|
.update(dati)
|
||||||
|
.eq("id", input.giocatoreId);
|
||||||
|
if (error) throw error;
|
||||||
|
return { giocatoreId: input.giocatoreId, dati };
|
||||||
|
},
|
||||||
|
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`
|
||||||
|
* prima di chiamare questa mutazione.
|
||||||
|
*/
|
||||||
|
export function useAggiungiGiocatore() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { id: string; dati: DatiSquadra }) => {
|
||||||
|
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert({
|
||||||
|
id: input.id,
|
||||||
|
nome: input.dati.nome.trim(),
|
||||||
|
cognome: input.dati.cognome.trim(),
|
||||||
|
numero: input.dati.numero,
|
||||||
|
ruolo: input.dati.ruolo.trim(),
|
||||||
|
email: input.dati.email?.trim() || null,
|
||||||
|
});
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: SQUADRA_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attiva o disattiva un giocatore (es. ha lasciato la squadra): non elimina la riga, così
|
||||||
|
* presenze, voti, pagelle e badge della stagione restano agganciati al suo id.
|
||||||
|
*/
|
||||||
|
export function useImpostaAttivo() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: { giocatoreId: string; attivo: boolean }) => {
|
||||||
|
const { error } = await supabaseNuoveTabelle
|
||||||
|
.from("giocatori_squadra")
|
||||||
|
.update({ attivo: input.attivo })
|
||||||
.eq("id", input.giocatoreId);
|
.eq("id", input.giocatoreId);
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return input;
|
return input;
|
||||||
},
|
},
|
||||||
onSuccess: (input) => {
|
onSuccess: (input) => {
|
||||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||||
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
|
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, attivo: input.attivo } : g)),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
+219
-2
@@ -9,6 +9,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
Unlink,
|
Unlink,
|
||||||
|
UserCheck,
|
||||||
|
UserPlus,
|
||||||
|
UserX,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -17,7 +20,10 @@ import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
|||||||
import {
|
import {
|
||||||
nomeCompleto,
|
nomeCompleto,
|
||||||
numeroGiaUsato,
|
numeroGiaUsato,
|
||||||
|
prossimoIdGiocatore,
|
||||||
|
useAggiungiGiocatore,
|
||||||
useGiocatoriSquadra,
|
useGiocatoriSquadra,
|
||||||
|
useImpostaAttivo,
|
||||||
useSalvaDatiSquadra,
|
useSalvaDatiSquadra,
|
||||||
useScollegaAccount,
|
useScollegaAccount,
|
||||||
validaDatiSquadra,
|
validaDatiSquadra,
|
||||||
@@ -68,6 +74,15 @@ const statoClasse: Record<StatoScadenza | "presente" | "assente", string> = {
|
|||||||
|
|
||||||
const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm";
|
const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm";
|
||||||
|
|
||||||
|
/** L'unico vincolo unique lato database sulla tabella è l'email: messaggio leggibile invece
|
||||||
|
* del codice Postgres (23505). */
|
||||||
|
function messaggioErrore(e: unknown, fallback: string): string {
|
||||||
|
if (e && typeof e === "object" && "code" in e && (e as { code?: unknown }).code === "23505") {
|
||||||
|
return "Email già usata da un altro giocatore.";
|
||||||
|
}
|
||||||
|
return e instanceof Error ? e.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
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">
|
||||||
@@ -88,6 +103,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
const salvaSquadra = useSalvaDatiSquadra();
|
const salvaSquadra = useSalvaDatiSquadra();
|
||||||
const salvaProfilo = useSalvaProfilo();
|
const salvaProfilo = useSalvaProfilo();
|
||||||
const scollega = useScollegaAccount();
|
const scollega = useScollegaAccount();
|
||||||
|
const impostaAttivo = useImpostaAttivo();
|
||||||
|
|
||||||
const [datiSquadra, setDatiSquadra] = useState<DatiSquadra | null>(null);
|
const [datiSquadra, setDatiSquadra] = useState<DatiSquadra | null>(null);
|
||||||
const [bozza, setBozza] = useState<Profilo | null>(null);
|
const [bozza, setBozza] = useState<Profilo | null>(null);
|
||||||
@@ -97,6 +113,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
cognome: g.cognome,
|
cognome: g.cognome,
|
||||||
numero: g.numero,
|
numero: g.numero,
|
||||||
ruolo: g.ruolo,
|
ruolo: g.ruolo,
|
||||||
|
email: g.email,
|
||||||
};
|
};
|
||||||
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
|
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
|
||||||
|
|
||||||
@@ -114,7 +131,20 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
setDatiSquadra(null);
|
setDatiSquadra(null);
|
||||||
toast.success("Dati squadra aggiornati");
|
toast.success("Dati squadra aggiornati");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : "Salvataggio non riuscito");
|
toast.error(messaggioErrore(e, "Salvataggio non riuscito"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confermaAttivo(nuovo: boolean) {
|
||||||
|
const messaggio = nuovo
|
||||||
|
? `Riattivare ${nomeCompleto(g)}?`
|
||||||
|
: `Disattivare ${nomeCompleto(g)}? Sparisce dalla rosa attiva ma i suoi dati (presenze, voti, badge) restano.`;
|
||||||
|
if (!confirm(messaggio)) return;
|
||||||
|
try {
|
||||||
|
await impostaAttivo.mutateAsync({ giocatoreId: g.id, attivo: nuovo });
|
||||||
|
toast.success(nuovo ? "Giocatore riattivato" : "Giocatore disattivato");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(messaggioErrore(e, "Operazione non riuscita"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +211,15 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
/>
|
/>
|
||||||
</Campo>
|
</Campo>
|
||||||
</div>
|
</div>
|
||||||
|
<Campo label="Email (collegamento automatico al login, DD-018)">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={squadraCorrente.email ?? ""}
|
||||||
|
placeholder="nome@esempio.com"
|
||||||
|
onChange={(e) => setDatiSquadra({ ...squadraCorrente, email: e.target.value || null })}
|
||||||
|
className={classiInput}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={confermaSquadra}
|
onClick={confermaSquadra}
|
||||||
@@ -208,16 +247,37 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
|||||||
<span className="min-w-0 text-muted-foreground">
|
<span className="min-w-0 text-muted-foreground">
|
||||||
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
|
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
{g.authUserId ? (
|
{g.authUserId ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={confermaScollega}
|
onClick={confermaScollega}
|
||||||
disabled={scollega.isPending}
|
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"
|
className="premi flex 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
|
<Unlink className="h-3.5 w-3.5" /> Scollega
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void confermaAttivo(!g.attivo)}
|
||||||
|
disabled={impostaAttivo.isPending}
|
||||||
|
className={cn(
|
||||||
|
"premi flex items-center gap-1.5 rounded-xl px-3 py-2 font-bold disabled:opacity-50",
|
||||||
|
g.attivo ? "bg-secondary text-foreground" : "bg-success text-success-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{g.attivo ? (
|
||||||
|
<>
|
||||||
|
<UserX className="h-3.5 w-3.5" /> Disattiva
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserCheck className="h-3.5 w-3.5" /> Riattiva
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -348,6 +408,151 @@ function SchedaGiocatore({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const datiVuoti: DatiSquadra = { nome: "", cognome: "", numero: 1, ruolo: "", email: null };
|
||||||
|
|
||||||
|
/** Form per aggiungere un giocatore alla rosa (DD-017): l'id `g<N>` è calcolato in app,
|
||||||
|
* il database non lo genera da solo. */
|
||||||
|
function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||||
|
const [aperto, setAperto] = useState(false);
|
||||||
|
const [dati, setDati] = useState<DatiSquadra>(datiVuoti);
|
||||||
|
const aggiungi = useAggiungiGiocatore();
|
||||||
|
|
||||||
|
async function conferma() {
|
||||||
|
const errore = validaDatiSquadra(dati);
|
||||||
|
if (errore) {
|
||||||
|
toast.error(errore);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (numeroGiaUsato(righe, "", dati.numero)) {
|
||||||
|
toast.warning(`Il numero ${dati.numero} è già assegnato a un altro giocatore.`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await aggiungi.mutateAsync({ id: prossimoIdGiocatore(righe), dati });
|
||||||
|
toast.success(`${dati.nome} ${dati.cognome} aggiunto alla rosa`);
|
||||||
|
setDati(datiVuoti);
|
||||||
|
setAperto(false);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(messaggioErrore(e, "Aggiunta non riuscita"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aperto) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAperto(true)}
|
||||||
|
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
||||||
|
>
|
||||||
|
<UserPlus className="h-4 w-4" /> Aggiungi giocatore
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-3 rounded-2xl bg-card p-4 shadow-card">
|
||||||
|
<h3 className="font-display text-sm uppercase tracking-wide">Nuovo giocatore</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Campo label="Nome">
|
||||||
|
<input
|
||||||
|
value={dati.nome}
|
||||||
|
maxLength={40}
|
||||||
|
onChange={(e) => setDati({ ...dati, nome: e.target.value })}
|
||||||
|
className={classiInput}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
<Campo label="Cognome">
|
||||||
|
<input
|
||||||
|
value={dati.cognome}
|
||||||
|
maxLength={40}
|
||||||
|
onChange={(e) => setDati({ ...dati, cognome: e.target.value })}
|
||||||
|
className={classiInput}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
<Campo label="Numero">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={dati.numero}
|
||||||
|
onChange={(e) => setDati({ ...dati, numero: Number(e.target.value) })}
|
||||||
|
className={classiInput}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
<Campo label="Ruolo">
|
||||||
|
<input
|
||||||
|
value={dati.ruolo}
|
||||||
|
maxLength={30}
|
||||||
|
onChange={(e) => setDati({ ...dati, ruolo: e.target.value })}
|
||||||
|
className={classiInput}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
</div>
|
||||||
|
<Campo label="Email (collegamento automatico al login, opzionale)">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={dati.email ?? ""}
|
||||||
|
placeholder="nome@esempio.com"
|
||||||
|
onChange={(e) => setDati({ ...dati, email: e.target.value || null })}
|
||||||
|
className={classiInput}
|
||||||
|
/>
|
||||||
|
</Campo>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAperto(false);
|
||||||
|
setDati(datiVuoti);
|
||||||
|
}}
|
||||||
|
className="premi flex-1 rounded-2xl bg-secondary py-2.5 text-xs font-bold uppercase"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void conferma()}
|
||||||
|
disabled={aggiungi.isPending}
|
||||||
|
className="premi flex-1 rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Aggiungi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Riga compatta per riattivare un giocatore che aveva lasciato la squadra. */
|
||||||
|
function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
|
||||||
|
const impostaAttivo = useImpostaAttivo();
|
||||||
|
|
||||||
|
async function riattiva() {
|
||||||
|
if (!confirm(`Riattivare ${nomeCompleto(g)}?`)) return;
|
||||||
|
try {
|
||||||
|
await impostaAttivo.mutateAsync({ giocatoreId: g.id, attivo: true });
|
||||||
|
toast.success("Giocatore riattivato");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(messaggioErrore(e, "Operazione non riuscita"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-2xl bg-card p-3 shadow-card">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-semibold leading-tight">{nomeCompleto(g)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
#{g.numero} · {g.ruolo}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void riattiva()}
|
||||||
|
disabled={impostaAttivo.isPending}
|
||||||
|
className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-success px-3 py-2 text-xs font-bold text-success-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<UserCheck className="h-3.5 w-3.5" /> Riattiva
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const admin = useIsAdmin();
|
const admin = useIsAdmin();
|
||||||
const { righe: squadra } = useGiocatoriSquadra();
|
const { righe: squadra } = useGiocatoriSquadra();
|
||||||
@@ -369,6 +574,7 @@ function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const attivi = squadra.filter((g) => g.attivo);
|
const attivi = squadra.filter((g) => g.attivo);
|
||||||
|
const disattivi = squadra.filter((g) => !g.attivo);
|
||||||
const completi = attivi.filter((g) => completamento(profili[g.id]) === 100).length;
|
const completi = attivi.filter((g) => completamento(profili[g.id]) === 100).length;
|
||||||
const certificatiOk = attivi.filter(
|
const certificatiOk = attivi.filter(
|
||||||
(g) =>
|
(g) =>
|
||||||
@@ -399,6 +605,7 @@ function Dashboard() {
|
|||||||
>
|
>
|
||||||
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
||||||
</button>
|
</button>
|
||||||
|
<AggiungiGiocatore righe={squadra} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section titolo="Profili" indice={1}>
|
<Section titolo="Profili" indice={1}>
|
||||||
@@ -414,6 +621,16 @@ function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{disattivi.length > 0 ? (
|
||||||
|
<Section titolo="Giocatori disattivati" indice={2}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{disattivi.map((g) => (
|
||||||
|
<GiocatoreDisattivato key={g.id} g={g} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
dividiNome,
|
dividiNome,
|
||||||
nomeCompleto,
|
nomeCompleto,
|
||||||
numeroGiaUsato,
|
numeroGiaUsato,
|
||||||
|
prossimoIdGiocatore,
|
||||||
rosaFallback,
|
rosaFallback,
|
||||||
slotDi,
|
slotDi,
|
||||||
slotPerEmail,
|
slotPerEmail,
|
||||||
@@ -84,7 +85,7 @@ assert.equal(
|
|||||||
assert.equal(slotPerEmail(rosaEmail, ""), null, "stringa vuota trattata come nessuna email");
|
assert.equal(slotPerEmail(rosaEmail, ""), null, "stringa vuota trattata come nessuna email");
|
||||||
|
|
||||||
// --- validaDatiSquadra ----------------------------------------------------------------
|
// --- validaDatiSquadra ----------------------------------------------------------------
|
||||||
const datiOk = { nome: "Mario", cognome: "Rossi", numero: 7, ruolo: "schiacciatore" };
|
const datiOk = { nome: "Mario", cognome: "Rossi", numero: 7, ruolo: "schiacciatore", email: null };
|
||||||
assert.equal(validaDatiSquadra(datiOk), null, "dati validi: nessun errore");
|
assert.equal(validaDatiSquadra(datiOk), null, "dati validi: nessun errore");
|
||||||
assert.match(validaDatiSquadra({ ...datiOk, nome: "" })!, /nome/i);
|
assert.match(validaDatiSquadra({ ...datiOk, nome: "" })!, /nome/i);
|
||||||
assert.match(
|
assert.match(
|
||||||
@@ -105,6 +106,29 @@ assert.match(
|
|||||||
/numero/i,
|
/numero/i,
|
||||||
"il numero deve essere intero",
|
"il numero deve essere intero",
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
validaDatiSquadra({ ...datiOk, email: "foo@bar.com" }),
|
||||||
|
null,
|
||||||
|
"email valida: nessun errore",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
validaDatiSquadra({ ...datiOk, email: "" }),
|
||||||
|
null,
|
||||||
|
"email vuota è ammessa: non ancora nota",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
validaDatiSquadra({ ...datiOk, email: "non-e-una-email" })!,
|
||||||
|
/email/i,
|
||||||
|
"senza @ l'email non è valida",
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- prossimoIdGiocatore ----------------------------------------------------------------
|
||||||
|
assert.equal(
|
||||||
|
prossimoIdGiocatore([riga({ id: "g1" }), riga({ id: "g3" }), riga({ id: "g2" })]),
|
||||||
|
"g4",
|
||||||
|
"il prossimo id segue il numero più alto già in uso",
|
||||||
|
);
|
||||||
|
assert.equal(prossimoIdGiocatore([]), "g1", "rosa vuota: si parte da g1");
|
||||||
|
|
||||||
// --- numeroGiaUsato ----------------------------------------------------------------
|
// --- numeroGiaUsato ----------------------------------------------------------------
|
||||||
const rosaNumeri: GiocatoreSquadra[] = [
|
const rosaNumeri: GiocatoreSquadra[] = [
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ assert.equal(slotDi(squadra, "u2")?.id, "g2");
|
|||||||
assert.equal(slotDi(squadra, "sconosciuto"), null);
|
assert.equal(slotDi(squadra, "sconosciuto"), null);
|
||||||
|
|
||||||
// --- dati squadra modificabili dall'admin (DD-017) ---------------------------
|
// --- dati squadra modificabili dall'admin (DD-017) ---------------------------
|
||||||
const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda" };
|
const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda", email: null };
|
||||||
assert.equal(validaDatiSquadra(datiOk), null);
|
assert.equal(validaDatiSquadra(datiOk), null);
|
||||||
assert.match(validaDatiSquadra({ ...datiOk, nome: " " }) ?? "", /nome/i);
|
assert.match(validaDatiSquadra({ ...datiOk, nome: " " }) ?? "", /nome/i);
|
||||||
assert.match(validaDatiSquadra({ ...datiOk, cognome: "" }) ?? "", /cognome/i);
|
assert.match(validaDatiSquadra({ ...datiOk, cognome: "" }) ?? "", /cognome/i);
|
||||||
|
|||||||
Reference in New Issue
Block a user