Files
CRAPP/src/routes/admin.tsx
T
davideandClaude Sonnet 5.5 77e5daaa7a Caratteri più grandi in tutta l'app (DD-049); etichetta «Cacche» nella rosa
Ogni testo sotto i 16 px sale di un passo in tutte le pagine (12→13, 13→14,
14→15, 15→16); restano invariate le barre a pillola dei menu (navigazione in
basso e sottosezioni) e i badge da 10-11 px. Nella scheda giocatore di Squadra
l'etichetta «Cacche/partita» diventa «Cacche» (il valore resta la media).
Documentazione: DD-049, CHANGELOG, ARCHITECTURE, PROJECT_STATE, moduli squadra
e allenatore.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:50:28 +02:00

864 lines
28 KiB
TypeScript

import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import {
Bell,
BellOff,
ChevronDown,
Download,
FileText,
IdCard,
Loader2,
Lock,
Send,
Unlink,
UserCheck,
UserPlus,
UserX,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Campo, classiInput, PageHeader, Select, StatTile } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { CampiAllenatore, CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
import {
inRosa,
isAllenatore,
nomeCompleto,
numeroGiaUsato,
prossimoIdGiocatore,
RUOLI,
ruoloVisibile,
useAggiungiGiocatore,
useGiocatoriSquadra,
useImpostaAttivo,
useSalvaDatiSquadra,
useScollegaAccount,
validaDatiSquadra,
type DatiSquadra,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
import {
completamento,
completamentoAllenatore,
csvTesseramento,
profiloVuoto,
sezioniComplete,
statoScadenza,
type Profilo,
type StatoScadenza,
} from "@/lib/profili-core";
import { oggiISO } from "@/lib/palloni-core";
import { scaricaCsv } from "@/lib/scout-export";
import { useIsAdmin } from "@/lib/ruoli";
import { useInviaNotifica, useNotificheAttive } from "@/lib/notifiche-admin";
import { Reveal } from "@/components/motion/Reveal";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
export const Route = createFileRoute("/admin")({
head: () => ({
meta: [
{ title: "Dashboard amministratore — CrAPP" },
{
name: "description",
content:
"Area riservata: stato dei profili, documenti e certificati della squadra, ed export dei dati per il tesseramento CSI.",
},
{ property: "og:title", content: "Dashboard amministratore — CrAPP" },
{
property: "og:description",
content: "Stato dei profili della squadra ed export per il tesseramento CSI.",
},
],
}),
component: Dashboard,
});
const statoClasse: Record<StatoScadenza | "presente" | "assente", string> = {
valido: "bg-success text-success-foreground",
presente: "bg-success text-success-foreground",
scaduto: "bg-destructive text-destructive-foreground",
mancante: "bg-secondary text-muted-foreground",
assente: "bg-secondary text-muted-foreground",
};
/** 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;
}
/**
* 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 impostaAttivo = useImpostaAttivo();
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,
email: g.email,
tipo: g.tipo,
};
// L'allenatore non ha numero né ruolo in campo (DD-034).
const allenatore = isAllenatore(g);
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
async function confermaSquadra() {
const errore = validaDatiSquadra(squadraCorrente);
if (errore) {
toast.error(errore);
return;
}
if (!allenatore && numeroGiaUsato(righe, g.id, squadraCorrente.numero)) {
toast.error(`Il numero ${squadraCorrente.numero} è già assegnato a un altro giocatore.`);
return;
}
try {
await salvaSquadra.mutateAsync({ giocatoreId: g.id, dati: squadraCorrente });
setDatiSquadra(null);
toast.success("Dati squadra aggiornati");
} catch (e) {
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"));
}
}
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-[15px] 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>
{allenatore ? null : (
<>
<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">
<Select
value={squadraCorrente.ruolo}
onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })}
>
<option value="">—</option>
{RUOLI.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select>
</Campo>
</>
)}
</div>
<Campo label="Email">
<input
type="email"
value={squadraCorrente.email ?? ""}
placeholder="nome@esempio.com"
onChange={(e) => setDatiSquadra({ ...squadraCorrente, email: e.target.value || null })}
className={classiInput}
/>
</Campo>
<button
type="button"
onClick={confermaSquadra}
disabled={!datiSquadra || salvaSquadra.isPending}
className="premi w-full rounded-2xl bg-primary py-2.5 text-[13px] font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Salva dati squadra
</button>
{allenatore ? (
<>
<h3 className="font-display text-[15px] uppercase tracking-wide">Dati personali</h3>
<CampiAllenatore
corrente={profiloCorrente}
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
/>
</>
) : (
<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-[13px] 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-[13px]">
<span className="min-w-0 text-muted-foreground">
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
</span>
<div className="flex shrink-0 items-center gap-2">
{g.authUserId ? (
<button
type="button"
onClick={confermaScollega}
disabled={scollega.isPending}
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
</button>
) : 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>
);
}
function Documento({
icona,
label,
stato,
path,
}: {
icona: React.ReactNode;
label: string;
stato: StatoScadenza | "presente" | "assente";
path: string | null;
}) {
const [inCorso, setInCorso] = useState(false);
async function scarica() {
if (!path || inCorso) return;
setInCorso(true);
try {
await scaricaFile(path);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Download non riuscito");
} finally {
setInCorso(false);
}
}
return (
<button
type="button"
onClick={scarica}
disabled={!path || inCorso}
className={cn(
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase disabled:opacity-60",
statoClasse[stato],
)}
>
{inCorso ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : icona}
{label}
{path ? <Download className="h-3 w-3" /> : null}
</button>
);
}
function SchedaGiocatore({
g,
profilo,
oggi,
indice,
aperta,
onToggle,
}: {
g: GiocatoreSquadra;
profilo: Profilo | undefined;
oggi: string;
indice: number;
aperta: boolean;
onToggle: () => void;
}) {
const nome = nomeCompleto(g);
const { numero } = g;
const allenatore = isAllenatore(g);
const perc = allenatore ? completamentoAllenatore(profilo) : completamento(profilo);
const sezioni = sezioniComplete(profilo);
const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi);
const fronte = statoScadenza(profilo?.documentoScadenza, profilo?.documentoFrontePath, oggi);
const retro = statoScadenza(profilo?.documentoScadenza, profilo?.documentoRetroPath, oggi);
return (
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 text-left">
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-[15px] tabular-nums">
{allenatore ? "All" : numero}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold leading-tight">{nome}</p>
<p className="text-[13px] text-muted-foreground">
{ruoloVisibile(g)} · profilo {perc}%
</p>
</div>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
aperta && "rotate-180",
)}
/>
</button>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-accent-grad transition-all"
style={{ width: `${perc}%` }}
/>
</div>
{allenatore ? null : (
<div className="mt-3 flex flex-wrap gap-2">
<Documento
icona={<IdCard className="h-3.5 w-3.5" />}
label="Doc fronte"
stato={fronte}
path={profilo?.documentoFrontePath ?? null}
/>
<Documento
icona={<IdCard className="h-3.5 w-3.5" />}
label="Doc retro"
stato={retro}
path={profilo?.documentoRetroPath ?? null}
/>
<Documento
icona={<FileText className="h-3.5 w-3.5" />}
label="Certificato"
stato={certificato}
path={profilo?.certificatoPath ?? null}
/>
</div>
)}
{aperta ? <ModificaGiocatore g={g} profilo={profilo} /> : null}
</Reveal>
);
}
const datiVuoti: DatiSquadra = {
nome: "",
cognome: "",
numero: 1,
ruolo: "",
email: null,
tipo: "giocatore",
};
/** Form per aggiungere un giocatore alla rosa (DD-017), o un allenatore (DD-034): 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();
const allenatore = dati.tipo === "allenatore";
async function conferma() {
const errore = validaDatiSquadra(dati);
if (errore) {
toast.error(errore);
return;
}
if (!allenatore && numeroGiaUsato(righe, "", dati.numero)) {
toast.error(`Il numero ${dati.numero} è già assegnato a un altro giocatore.`);
return;
}
try {
await aggiungi.mutateAsync({ id: prossimoIdGiocatore(righe), dati });
toast.success(
allenatore
? `${dati.nome} ${dati.cognome} aggiunto come allenatore`
: `${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-[15px] font-bold uppercase"
>
<UserPlus className="h-4 w-4" /> Aggiungi giocatore o allenatore
</button>
);
}
return (
<div className="mt-3 space-y-3 rounded-2xl bg-card p-4 shadow-card">
<h3 className="font-display text-[15px] uppercase tracking-wide">
{allenatore ? "Nuovo allenatore" : "Nuovo giocatore"}
</h3>
<Campo label="Tipo">
<Select
value={dati.tipo}
onChange={(e) =>
setDati({ ...dati, tipo: e.target.value === "allenatore" ? "allenatore" : "giocatore" })
}
>
<option value="giocatore">Giocatore</option>
<option value="allenatore">Allenatore</option>
</Select>
</Campo>
<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>
{allenatore ? null : (
<>
<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">
<Select
value={dati.ruolo}
onChange={(e) => setDati({ ...dati, ruolo: e.target.value })}
>
<option value="">—</option>
{RUOLI.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select>
</Campo>
</>
)}
</div>
<Campo label="Email">
<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-[13px] 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-[13px] 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-[13px] text-muted-foreground">
{isAllenatore(g) ? ruoloVisibile(g) : `#${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-[13px] font-bold text-success-foreground disabled:opacity-50"
>
<UserCheck className="h-3.5 w-3.5" /> Riattiva
</button>
</div>
);
}
function Dashboard() {
const admin = useIsAdmin();
const { righe: squadra } = useGiocatoriSquadra();
const { profili, isPending } = useProfili();
const { data: notificheAttive } = useNotificheAttive();
const [schedaAperta, setSchedaAperta] = useState<string | null>(null);
const oggi = oggiISO();
const inviaNotifica = useInviaNotifica();
const [destinatarioMessaggio, setDestinatarioMessaggio] = useState<{
id?: string;
nome: string;
} | null>(null);
const [messaggio, setMessaggio] = useState("");
async function inviaMessaggio() {
if (!destinatarioMessaggio || !messaggio.trim()) return;
try {
const dati = await inviaNotifica.mutateAsync(
destinatarioMessaggio.id
? { messaggio: messaggio.trim(), giocatoreId: destinatarioMessaggio.id }
: { messaggio: messaggio.trim() },
);
toast.success(
dati.inviate > 0
? `Notifica inviata a ${dati.inviate} dispositivi`
: "Nessun dispositivo con notifiche attive per l'invio",
);
setDestinatarioMessaggio(null);
setMessaggio("");
} catch {
toast.error("Non sono riuscito a inviare la notifica");
}
}
if (!admin) {
return (
<>
<PageHeader titolo="Dashboard" sottotitolo="Area riservata" />
<div className="px-5 pt-4">
<p className="flex items-center justify-center gap-2 rounded-3xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
<Lock className="h-4 w-4 shrink-0" />
Riservata agli amministratori della squadra.
</p>
</div>
</>
);
}
// Conteggi, tesseramento ed export CSI riguardano solo chi gioca (DD-034).
const attivi = squadra.filter(inRosa);
const allenatori = squadra.filter((g) => g.attivo && isAllenatore(g));
// Le notifiche arrivano anche agli allenatori: nella tab Notifiche ci sono tutti.
const membri = [...attivi, ...allenatori];
const disattivi = squadra.filter((g) => !g.attivo);
const completi = attivi.filter((g) => completamento(profili[g.id]) === 100).length;
const certificatiOk = attivi.filter(
(g) =>
statoScadenza(profili[g.id]?.certificatoScadenza, profili[g.id]?.certificatoPath, oggi) ===
"valido",
).length;
const contenutoSquadra = (
<>
<div className="grid grid-cols-2 gap-2">
<StatTile valore={attivi.length} label="Giocatori" />
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
<StatTile
valore={`${certificatiOk}/${attivi.length}`}
label="Certificati validi"
hint="non scaduti"
/>
</div>
<button
type="button"
onClick={() => scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
>
<Download className="h-4 w-4" /> Esporta CSV tesseramento
</button>
<AggiungiGiocatore righe={squadra} />
</>
);
const contenutoProfili = isPending ? (
<p className="rounded-2xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
Caricamento…
</p>
) : (
<div className="space-y-3">
{attivi.map((g, i) => (
<SchedaGiocatore
key={g.id}
g={g}
profilo={profili[g.id]}
oggi={oggi}
indice={i}
aperta={schedaAperta === g.id}
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
/>
))}
{allenatori.length > 0 ? (
<h3 className="pt-2 font-display text-[15px] uppercase tracking-wide">Allenatori</h3>
) : null}
{allenatori.map((g, i) => (
<SchedaGiocatore
key={g.id}
g={g}
profilo={profili[g.id]}
oggi={oggi}
indice={attivi.length + i}
aperta={schedaAperta === g.id}
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
/>
))}
</div>
);
const contenutoDisattivati = (
<div className="space-y-2">
{disattivi.map((g) => (
<GiocatoreDisattivato key={g.id} g={g} />
))}
</div>
);
const contenutoNotifiche = notificheAttive ? (
<>
<StatTile
valore={`${membri.filter((g) => notificheAttive.has(g.id)).length}/${membri.length}`}
label="Notifiche attive"
/>
<button
type="button"
onClick={() => setDestinatarioMessaggio({ nome: "tutta la squadra" })}
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop"
>
<Send className="h-4 w-4" /> Invia messaggio a tutti
</button>
<div className="mt-3 space-y-2">
{membri.map((g) => {
const attiva = notificheAttive.has(g.id);
return (
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
{attiva ? (
<Bell className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<BellOff className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<p
className={cn(
"min-w-0 flex-1 truncate text-[15px] font-semibold leading-tight",
!attiva && "text-muted-foreground",
)}
>
{nomeCompleto(g)}
</p>
<button
type="button"
onClick={() => setDestinatarioMessaggio({ id: g.id, nome: nomeCompleto(g) })}
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-secondary text-foreground active:scale-95"
aria-label={`Invia messaggio a ${nomeCompleto(g)}`}
>
<Send className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
</>
) : (
<p className="rounded-2xl bg-card p-5 text-center text-[15px] text-muted-foreground shadow-card">
Caricamento…
</p>
);
return (
<>
<PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" />
<BarraSottosezioni
defaultId="squadra"
variante="sottolineatura"
riempiLarghezza
voci={[
{ id: "squadra", label: "Squadra", contenuto: contenutoSquadra },
{ id: "profili", label: "Profili", contenuto: contenutoProfili },
...(disattivi.length > 0
? [{ id: "disattivati", label: "Disattivati", contenuto: contenutoDisattivati }]
: []),
{ id: "notifiche", label: "Notifiche", contenuto: contenutoNotifiche },
]}
/>
<Drawer
open={!!destinatarioMessaggio}
onOpenChange={(aperto) => {
if (!aperto) {
setDestinatarioMessaggio(null);
setMessaggio("");
}
}}
>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Invia messaggio</DrawerTitle>
<DrawerDescription>
{destinatarioMessaggio ? `Destinatario: ${destinatarioMessaggio.nome}.` : ""}
</DrawerDescription>
</DrawerHeader>
<div className="px-4">
<textarea
value={messaggio}
maxLength={300}
rows={3}
onChange={(e) => setMessaggio(e.target.value)}
placeholder="Scrivi il messaggio da inviare come notifica push…"
className={cn(classiInput, "h-auto")}
/>
</div>
<DrawerFooter>
<button
type="button"
onClick={inviaMessaggio}
disabled={inviaNotifica.isPending || !messaggio.trim()}
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-[15px] font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{inviaNotifica.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}{" "}
Invia
</button>
<DrawerClose asChild>
<button
type="button"
className="w-full rounded-2xl bg-secondary py-3 text-[15px] font-bold uppercase text-foreground"
>
Annulla
</button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
);
}