Implementa il ruolo allenatore nell'app

- Modello: tipo sugli slot, inRosa() al posto del filtro su attivo in tutte le
  letture della rosa di gioco (presenze, convocati, voti, palloni, classifiche,
  badge, sollecito); gli allenatori restano solo nei compleanni e in Squadra.
- Permessi: user_roles letto una volta per tutti i ruoli; usePuoGestireEventi()
  apre /eventi e il sollecito presenze anche all'allenatore, lato server con
  richiediGestoreEventi(). Palloni, sondaggio e messaggi restano all'admin.
- Identità: useGiocatoreInCampo() è null per l'allenatore, così non risponde alle
  presenze e non vota; useSonoAllenatore() decide cosa si vede.
- Profilo: per l'allenatore solo Docs (nome, cognome, nascita, telefono, email) e
  Opzioni; card avatar e tab Opzioni estratte per riusarle.
- Home, Squadra e partita: niente statistiche personali, badge, cacche e
  obiettivi per l'allenatore; in Rosa compare con la dicitura «Allenatore».
- Admin: tipo in «Aggiungi», allenatori a parte in Profili, fuori da conteggi ed
  export CSI.

Test unit su rosa di gioco e completamento, route test sull'allenatore.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
davideandClaude Opus 5.5 committed 2026-09-24 14:54:35 +02:00
1 parent 15a010855e
commit 2ec72fd08f
35 files changed
+1215 -577

No files matched your search

+213 -130
View File
@@ -21,12 +21,15 @@ 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 { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
import { CampiAllenatore, CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
import {
inRosa,
isAllenatore,
nomeCompleto,
numeroGiaUsato,
prossimoIdGiocatore,
RUOLI,
ruoloVisibile,
useAggiungiGiocatore,
useGiocatoriSquadra,
useImpostaAttivo,
@@ -41,6 +44,7 @@ import {
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
import {
completamento,
completamentoAllenatore,
csvTesseramento,
profiloVuoto,
sezioniComplete,
@@ -126,7 +130,10 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
numero: g.numero,
ruolo: g.ruolo,
email: g.email,
tipo: g.tipo,
};
// L'allenatore non ha numero, ruolo in campo né tesseramento (DD-034).
const allenatore = isAllenatore(g);
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
async function confermaSquadra() {
@@ -135,7 +142,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
toast.error(errore);
return;
}
if (numeroGiaUsato(righe, g.id, squadraCorrente.numero)) {
if (!allenatore && numeroGiaUsato(righe, g.id, squadraCorrente.numero)) {
toast.error(`Il numero ${squadraCorrente.numero} è già assegnato a un altro giocatore.`);
return;
}
@@ -216,28 +223,34 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
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">
<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>
{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
@@ -257,44 +270,61 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
Salva dati squadra
</button>
<h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3>
<div className="grid grid-cols-2 gap-3">
<Campo label="Numero tessera">
<input
value={tesseramentoCorrente.numeroTessera ?? ""}
maxLength={40}
placeholder="Non ancora tesserato"
onChange={(e) =>
setTesseramento({ ...tesseramentoCorrente, numeroTessera: e.target.value || null })
}
className={classiInput}
/>
</Campo>
<Campo label="Data tessera">
<input
type="date"
value={tesseramentoCorrente.dataTessera ?? ""}
onChange={(e) =>
setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null })
}
className={classiInput}
/>
</Campo>
</div>
<button
type="button"
onClick={confermaTesseramento}
disabled={!tesseramento || salvaTesseramento.isPending}
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Salva tesseramento
</button>
{allenatore ? null : (
<>
<h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3>
<div className="grid grid-cols-2 gap-3">
<Campo label="Numero tessera">
<input
value={tesseramentoCorrente.numeroTessera ?? ""}
maxLength={40}
placeholder="Non ancora tesserato"
onChange={(e) =>
setTesseramento({
...tesseramentoCorrente,
numeroTessera: e.target.value || null,
})
}
className={classiInput}
/>
</Campo>
<Campo label="Data tessera">
<input
type="date"
value={tesseramentoCorrente.dataTessera ?? ""}
onChange={(e) =>
setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null })
}
className={classiInput}
/>
</Campo>
</div>
<button
type="button"
onClick={confermaTesseramento}
disabled={!tesseramento || salvaTesseramento.isPending}
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
>
Salva tesseramento
</button>
</>
)}
<CampiProfilo
corrente={profiloCorrente}
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
sezioni={sezioniComplete(profiloCorrente)}
/>
{allenatore ? (
<>
<h3 className="font-display text-sm 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}
@@ -402,8 +432,9 @@ function SchedaGiocatore({
onToggle: () => void;
}) {
const nome = nomeCompleto(g);
const { ruolo, numero } = g;
const perc = completamento(profilo);
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);
@@ -413,12 +444,12 @@ function SchedaGiocatore({
<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-sm tabular-nums">
{numero}
{allenatore ? "All" : numero}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold leading-tight">{nome}</p>
<p className="text-xs text-muted-foreground">
{ruolo} · profilo {perc}%
{ruoloVisibile(g)} · profilo {perc}%
</p>
</div>
<ChevronDown
@@ -436,55 +467,65 @@ function SchedaGiocatore({
/>
</div>
<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}
/>
<Documento
icona={<Image className="h-3.5 w-3.5" />}
label="Foto"
stato={sezioni.foto ? "presente" : "assente"}
path={profilo?.fotoPath ?? null}
/>
<span
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
statoClasse[g.numeroTessera ? "presente" : "assente"],
)}
>
<BadgeCheck className="h-3.5 w-3.5" />
{g.numeroTessera ? "Tesserato" : "Da tesserare"}
</span>
</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}
/>
<Documento
icona={<Image className="h-3.5 w-3.5" />}
label="Foto"
stato={sezioni.foto ? "presente" : "assente"}
path={profilo?.fotoPath ?? null}
/>
<span
className={cn(
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
statoClasse[g.numeroTessera ? "presente" : "assente"],
)}
>
<BadgeCheck className="h-3.5 w-3.5" />
{g.numeroTessera ? "Tesserato" : "Da tesserare"}
</span>
</div>
)}
{aperta ? <ModificaGiocatore g={g} profilo={profilo} /> : null}
</Reveal>
);
}
const datiVuoti: DatiSquadra = { nome: "", cognome: "", numero: 1, ruolo: "", email: null };
const datiVuoti: DatiSquadra = {
nome: "",
cognome: "",
numero: 1,
ruolo: "",
email: null,
tipo: "giocatore",
};
/** Form per aggiungere un giocatore alla rosa (DD-017): l'id `g<N>` è calcolato in app,
* il database non lo genera da solo. */
/** 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);
@@ -492,13 +533,17 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
toast.error(errore);
return;
}
if (numeroGiaUsato(righe, "", dati.numero)) {
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(`${dati.nome} ${dati.cognome} aggiunto alla rosa`);
toast.success(
allenatore
? `${dati.nome} ${dati.cognome} aggiunto come allenatore`
: `${dati.nome} ${dati.cognome} aggiunto alla rosa`,
);
setDati(datiVuoti);
setAperto(false);
} catch (e) {
@@ -513,14 +558,27 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
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
<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-sm uppercase tracking-wide">Nuovo giocatore</h3>
<h3 className="font-display text-sm 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
@@ -538,25 +596,32 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
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">
<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>
{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
@@ -610,7 +675,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
<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}
{isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}
</p>
</div>
<button
@@ -673,7 +738,11 @@ function Dashboard() {
);
}
const attivi = squadra.filter((g) => g.attivo);
// 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(
@@ -723,6 +792,20 @@ function Dashboard() {
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
/>
))}
{allenatori.length > 0 ? (
<h3 className="pt-2 font-display text-sm 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>
);
@@ -737,7 +820,7 @@ function Dashboard() {
const contenutoNotifiche = notificheAttive ? (
<>
<StatTile
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
valore={`${membri.filter((g) => notificheAttive.has(g.id)).length}/${membri.length}`}
label="Notifiche attive"
/>
<button
@@ -748,7 +831,7 @@ function Dashboard() {
<Send className="h-4 w-4" /> Invia messaggio a tutti
</button>
<div className="mt-3 space-y-2">
{attivi.map((g) => {
{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">
+11 -4
View File
@@ -3,6 +3,8 @@ import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { leggiEventi } from "@/lib/eventi.server";
import { inviaPush } from "@/lib/webpush.server";
import { isAllenatore } from "@/lib/giocatori-squadra";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
const schema = z.object({ eventoId: z.string().min(1).max(50) });
@@ -22,15 +24,20 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
if (!partita) return new Response("Evento non trovato", { status: 404 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: iscrizioni } = await supabaseAdmin
const { data: tutte } = await supabaseAdmin
.from("push_subscriptions")
.select("endpoint, p256dh, auth");
.select("endpoint, p256dh, auth, giocatore_id");
// Il sondaggio cacche non riguarda l'allenatore, che non le vede (DD-034).
const allenatori = new Set(
(await leggiGiocatoriSquadra()).filter(isAllenatore).map((g) => g.id),
);
const iscrizioni = (tutte ?? []).filter((i) => !allenatori.has(i.giocatore_id));
const titolo = "💩 Sondaggio pre-partita aperto";
const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`;
let inviate = 0;
for (const iscrizione of iscrizioni ?? []) {
for (const iscrizione of iscrizioni) {
try {
const { stato } = await inviaPush(iscrizione, titolo, testo);
if (stato === 404 || stato === 410) {
@@ -46,7 +53,7 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
}
}
return Response.json({ inviate, destinatari: (iscrizioni ?? []).length });
return Response.json({ inviate, destinatari: iscrizioni.length });
},
},
},
+2 -4
View File
@@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { nomeCompleto } from "@/lib/giocatori-squadra";
import { inRosa, nomeCompleto } from "@/lib/giocatori-squadra";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
import { avvisiPalloniEvento, completaTurni } from "@/lib/palloni-core";
import { inviaPush } from "@/lib/webpush.server";
@@ -37,9 +37,7 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
const squadra = await leggiGiocatoriSquadra();
const rosa = squadra
.filter((g) => g.attivo)
.map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
const rosa = squadra.filter(inRosa).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
const turni = completaTurni(salvati, eventi, rosa);
const avvisi = avvisiPalloniEvento(turni, eventi, evento.id);
+2 -2
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server";
import { richiediGestoreEventi } from "@/lib/auth-route.server";
import { formatData } from "@/lib/crapp-data";
import { leggiEventi } from "@/lib/eventi.server";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
@@ -17,7 +17,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
server: {
handlers: {
POST: async ({ request }) => {
const negato = await richiediAdmin(request);
const negato = await richiediGestoreEventi(request);
if (negato) return negato;
const parsed = schema.safeParse(await request.json());
+4 -4
View File
@@ -10,7 +10,7 @@ import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useIsAdmin } from "@/lib/ruoli";
import { usePuoGestireEventi } from "@/lib/ruoli";
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
import {
Drawer,
@@ -55,9 +55,9 @@ function Calendario() {
}, []);
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
const [drawerAperto, setDrawerAperto] = useState(false);
const admin = useIsAdmin();
const puoGestire = usePuoGestireEventi();
const { eventi } = useEventi();
const rosa = useAnagraficaRosa();
const rosa = useAnagraficaRosa({ conAllenatori: true });
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
const ridotto = useMotoRidotto();
@@ -244,7 +244,7 @@ function Calendario() {
</Card>
</Section>
{admin ? (
{puoGestire ? (
<div className="px-5 pt-4">
<Link
to="/eventi"
+7 -6
View File
@@ -16,7 +16,7 @@ import {
} from "@/components/ui/drawer";
import { formatData } from "@/lib/crapp-data";
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import {
categoriaEvento,
daCategoria,
@@ -28,7 +28,7 @@ import {
type Evento,
} from "@/lib/eventi";
import { useGiocatoreBase } from "@/lib/user-store";
import { useIsAdmin } from "@/lib/ruoli";
import { usePuoGestireEventi } from "@/lib/ruoli";
import { molla, proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion";
@@ -61,9 +61,10 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [
];
function GestioneEventi() {
// Solo verità (`!io`, gate admin): `useGiocatoreBase` basta, niente statistiche.
// Solo verità (`!io`, gate admin o allenatore): `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
const admin = useIsAdmin();
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
const puoGestire = usePuoGestireEventi();
const { eventi, isPending, isError, error, refetch } = useEventi();
const { righe: squadra } = useGiocatoriSquadra();
const salva = useSalvaEvento();
@@ -72,7 +73,7 @@ function GestioneEventi() {
const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
const [confermaModifica, setConfermaModifica] = useState(false);
const [cambiaGiorno, setCambiaGiorno] = useState(false);
const rosa = squadra.filter((g) => g.attivo);
const rosa = squadra.filter(inRosa);
// SSR-safe: la data di oggi arriva solo dopo il mount.
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
@@ -85,7 +86,7 @@ function GestioneEventi() {
const { anno, mese, direzione, precedente, successivo } = useMeseNav();
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
if (!io || !admin) {
if (!io || !puoGestire) {
return (
<>
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
+78 -61
View File
@@ -1,4 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { Flame, ChevronRight } from "lucide-react";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
@@ -11,6 +12,8 @@ import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useEventi, type Evento } from "@/lib/eventi";
import { useRispostePresenze } from "@/lib/presenze";
import { useIo, useObiettivi } from "@/lib/rosa";
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { useCsi } from "@/lib/csi";
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
import { useScoutMatches } from "@/lib/scout-store";
@@ -38,7 +41,10 @@ export const Route = createFileRoute("/")({
});
function Index() {
// `useIo` c'è solo per i giocatori; l'allenatore ha lo slot ma non statistiche (DD-034).
const base = useGiocatoreBase();
const giocatore = useIo();
const allenatore = isAllenatore(base);
const { eventi } = useEventi();
const { presenze } = useRispostePresenze();
const oggi = new Date().toISOString().slice(0, 10);
@@ -65,7 +71,8 @@ function Index() {
? { ...ultimaBase, mvp: (eventoUltima && mvpPerMatch[eventoUltima.id]) ?? "" }
: null;
if (!giocatore) return null;
if (!base || (!giocatore && !allenatore)) return null;
const nome = giocatore?.nome ?? nomeCompleto(base);
return (
<>
@@ -78,7 +85,7 @@ function Index() {
/>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
Ciao {giocatore.nome.split(" ")[0]}
Ciao {nome.split(" ")[0]}
</p>
<h1 className="font-display-lg text-4xl uppercase leading-none">CRAP Volley</h1>
</div>
@@ -86,7 +93,9 @@ function Index() {
<LinkProfilo />
</div>
<div className="mt-6 grid grid-cols-3 gap-2 text-center">
<div
className={cn("mt-6 grid gap-2 text-center", giocatore ? "grid-cols-3" : "grid-cols-2")}
>
<div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="font-display text-2xl leading-none">{noi ? `${noi.pos}º` : "—"}</p>
<p className="text-xs uppercase text-primary-foreground/80">In classifica</p>
@@ -97,19 +106,21 @@ function Index() {
</p>
<p className="text-xs uppercase text-primary-foreground/80">Bilancio W-L</p>
</div>
<div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="inline-flex items-center gap-1 font-display text-2xl leading-none">
<Flame className="h-4 w-4 text-accent" />
{giocatore.streak}
</p>
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
</div>
{giocatore ? (
<div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="inline-flex items-center gap-1 font-display text-2xl leading-none">
<Flame className="h-4 w-4 text-accent" />
{giocatore.streak}
</p>
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
</div>
) : null}
</div>
</Reveal>
<PromemoriaPalloni />
<CompletaProfilo giocatoreId={giocatore.id} indice={1} />
<CompletaProfilo giocatoreId={base.id} allenatore={allenatore} indice={1} />
<Section
titolo="Prossimo impegno"
@@ -132,20 +143,22 @@ function Index() {
)}
</Section>
<Section titolo="Da confermare" indice={3}>
<div className="space-y-3">
{daConfermare.length > 0 ? (
daConfermare.map((e) => {
const link = linkPerEvento(e);
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})
) : (
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
Nient'altro da confermare: sei in pari.
</p>
)}
</div>
</Section>
{giocatore ? (
<Section titolo="Da confermare" indice={3}>
<div className="space-y-3">
{daConfermare.length > 0 ? (
daConfermare.map((e) => {
const link = linkPerEvento(e);
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
})
) : (
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
Nient'altro da confermare: sei in pari.
</p>
)}
</div>
</Section>
) : null}
<Section
titolo="Ultima partita"
@@ -207,43 +220,47 @@ function Index() {
)}
</Section>
<Section
titolo="Obiettivo di squadra"
indice={5}
azione={
<Link
to="/squadra"
className="inline-flex items-center text-[14px] font-semibold text-accent"
>
Tutti <ChevronRight className="h-4 w-4" />
</Link>
}
>
{obiettivo ? (
<Card>
<div className="flex items-center gap-2 text-sm font-bold">
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
</div>
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
</p>
<p className="mt-1 text-xs font-semibold text-accent">
{microcopyObiettivo(obiettivo)}
</p>
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
</Card>
) : null}
</Section>
{allenatore ? null : (
<Section
titolo="Obiettivo di squadra"
indice={5}
azione={
<Link
to="/squadra"
className="inline-flex items-center text-[14px] font-semibold text-accent"
>
Tutti <ChevronRight className="h-4 w-4" />
</Link>
}
>
{obiettivo ? (
<Card>
<div className="flex items-center gap-2 text-sm font-bold">
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
</div>
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
<p className="mt-2 text-xs text-muted-foreground">
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
</p>
<p className="mt-1 text-xs font-semibold text-accent">
{microcopyObiettivo(obiettivo)}
</p>
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
</Card>
) : null}
</Section>
)}
<Section titolo="Colpo d'occhio" indice={6}>
<div className="grid grid-cols-3 gap-2">
<StatTile valore={giocatore.presenze} label="Presenze" />
<StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" />
<StatTile valore={giocatore.mvp} label="MVP" />
</div>
</Section>
{giocatore ? (
<Section titolo="Colpo d'occhio" indice={6}>
<div className="grid grid-cols-3 gap-2">
<StatTile valore={giocatore.presenze} label="Presenze" />
<StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" />
<StatTile valore={giocatore.mvp} label="MVP" />
</div>
</Section>
) : null}
</>
);
}
+13 -6
View File
@@ -14,6 +14,7 @@ import { ScoutEntry } from "@/components/crapp/ScoutEntry";
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
import { useIsAdmin } from "@/lib/ruoli";
import { useSonoAllenatore } from "@/lib/user-store";
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
import { VotoSocial } from "@/components/crapp/VotoSocial";
import { TurnoPalloni } from "@/components/crapp/TurnoPalloni";
@@ -47,6 +48,7 @@ function PartitaDetail() {
const { id } = Route.useParams();
const { evento } = useEvento(id);
const admin = useIsAdmin();
const sonoAllenatore = useSonoAllenatore();
const scoutMatches = useScoutMatches();
const { data: csi } = useCsi();
const { risposte } = usePresenzeEvento(id);
@@ -203,9 +205,12 @@ function PartitaDetail() {
</Section>
{match ? (
<Section titolo="Badge votati dai compagni">
<VotoSocial matchId={match.id} />
</Section>
// Badge e cacche non si mostrano all'allenatore (DD-034).
sonoAllenatore ? null : (
<Section titolo="Badge votati dai compagni">
<VotoSocial matchId={match.id} />
</Section>
)
) : (
<Section titolo="In programma">
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
@@ -219,9 +224,11 @@ function PartitaDetail() {
<ScoutEntry eventoId={evento.id} />
</Section>
<Section titolo="Sondaggio pre-partita">
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
</Section>
{sonoAllenatore ? null : (
<Section titolo="Sondaggio pre-partita">
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
</Section>
)}
{match ? (
<Section titolo="Pagelle di fine partita">
+232 -181
View File
@@ -14,8 +14,9 @@ import {
} from "@/lib/avatar-store";
import { SerieGriglia } from "@/components/crapp/SerieCard";
import { CollezioneBadge } from "@/components/crapp/CollezioneBadge";
import { ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
import { ProfiloAllenatore, ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
import { useVotiSocial } from "@/lib/badge-social";
import { inizialiDa } from "@/lib/crapp-data";
import { useIo } from "@/lib/rosa";
import { usePresenzeUltimoMese } from "@/lib/presenze-mese";
import {
@@ -24,7 +25,13 @@ import {
pushSupportato,
statoNotifiche,
} from "@/lib/push-client";
import { resetGiocatore } from "@/lib/user-store";
import { resetGiocatore, useGiocatoreBase } from "@/lib/user-store";
import {
isAllenatore,
nomeCompleto,
ruoloVisibile,
type GiocatoreSquadra,
} from "@/lib/giocatori-squadra";
import { esci } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { Reveal } from "@/components/motion/Reveal";
@@ -60,130 +67,29 @@ export const Route = createFileRoute("/profilo")({
});
function Profilo() {
// L'allenatore ha un profilo ridotto: solo Docs e Opzioni, niente stagione né badge (DD-034).
const base = useGiocatoreBase();
if (base && isAllenatore(base)) return <ProfiloAllenatorePagina g={base} />;
return <ProfiloGiocatore />;
}
function ProfiloGiocatore() {
const { tab } = Route.useSearch();
const tabIniziale = tab ?? "stagione";
const votiSocial = useVotiSocial();
const g = useIo();
const admin = useIsAdmin();
const ultimoMese = usePresenzeUltimoMese(g?.id);
const inputRef = useRef<HTMLInputElement>(null);
const fotoEsiste = useAvatarEsiste(g?.id);
const impostaAvatarEsiste = useImpostaAvatarEsiste();
const [bust, setBust] = useState(0);
const [notifiche, setNotifiche] = useState(false);
const [inCorso, setInCorso] = useState(false);
const [supportate, setSupportate] = useState(true);
useEffect(() => {
setSupportate(pushSupportato());
statoNotifiche()
.then(setNotifiche)
.catch(() => setNotifiche(false));
}, []);
if (!g) return null;
async function cambiaNotifiche() {
if (!g || inCorso) return;
setInCorso(true);
try {
if (notifiche) {
await disattivaNotifiche();
setNotifiche(false);
toast.success("Notifiche disattivate");
} else {
await attivaNotifiche(g.id);
setNotifiche(true);
toast.success("Notifiche attive");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Notifiche non disponibili");
} finally {
setInCorso(false);
}
}
async function logout() {
try {
await esci();
resetGiocatore();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
}
}
const percPresenze = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0;
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file || !g) return;
try {
await caricaAvatar(g.id, file);
setBust(Date.now());
impostaAvatarEsiste(g.id, true);
toast.success("Immagine profilo aggiornata");
} catch {
toast.error("Non sono riuscito a caricare l'immagine");
}
}
return (
<>
{/* Sul profilo il logo torna in home al posto del link al profilo. */}
<PageHeader
titolo={g.nome}
sottotitolo={`#${g.numero} · ${g.ruolo}`}
azione={
<Link to="/" aria-label="Vai alla home" className="premi shrink-0 rounded-2xl">
<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />
</Link>
}
/>
<PageHeader titolo={g.nome} sottotitolo={`#${g.numero} · ${g.ruolo}`} azione={<LinkHome />} />
<Reveal className="-mt-6 px-5">
<Card>
<div className="flex flex-col items-center gap-4">
<Avatar id={g.id} fallback={g.iniziali} className="h-20 w-20 text-2xl" bust={bust} />
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={onFile}
/>
</div>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
>
Cambia immagine profilo
</button>
{fotoEsiste.data ? (
<button
type="button"
onClick={async () => {
if (!confirm("Rimuovere l'immagine profilo?")) return;
try {
await rimuoviAvatar(g.id);
setBust(Date.now());
impostaAvatarEsiste(g.id, false);
toast.success("Immagine rimossa");
} catch {
toast.error("Non sono riuscito a rimuovere l'immagine");
}
}}
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/15 text-destructive"
aria-label="Rimuovi immagine"
>
<Trash2 className="h-4 w-4" />
</button>
) : null}
</div>
</Card>
</Reveal>
<CardAvatar id={g.id} fallback={g.iniziali} />
<BarraSottosezioni
key={tabIniziale}
@@ -236,78 +142,223 @@ function Profilo() {
{
id: "impostazioni",
label: "Opzioni",
contenuto: (
<div className="space-y-3">
<div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card">
<button
type="button"
onClick={cambiaNotifiche}
disabled={!supportate || inCorso}
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
>
<span className="min-w-0">
<span className="block truncate">Notifiche</span>
<span className="block text-xs text-muted-foreground">
{supportate
? notifiche
? "Attive su questo dispositivo"
: "Palloni, solleciti presenze e avvisi in app"
: "Non supportate su questo dispositivo"}
</span>
</span>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
notifiche
? "bg-accent-grad text-accent-foreground"
: "bg-secondary text-muted-foreground",
)}
>
<Bell className="h-4 w-4" />
</span>
</button>
{admin ? (
<Link
to="/admin"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Dashboard amministratore</span>
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
</Link>
) : null}
<a
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Segnala un bug</span>
<Bug className="h-4 w-4 text-muted-foreground" />
</a>
<a
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
target="_blank"
rel="noopener noreferrer"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
<Lightbulb className="h-4 w-4 text-muted-foreground" />
</a>
</div>
<p className="text-center text-xs text-muted-foreground">CrAPP v{APP_VERSION}</p>
<button
type="button"
onClick={logout}
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
>
<LogOut className="h-4 w-4" />
Esci
</button>
</div>
),
contenuto: <TabOpzioni giocatoreId={g.id} />,
},
]}
/>
</>
);
}
function ProfiloAllenatorePagina({ g }: { g: GiocatoreSquadra }) {
const { tab } = Route.useSearch();
// Stagione e Badge non esistono per l'allenatore: un link diretto apre Docs.
const tabIniziale = tab === "impostazioni" ? "impostazioni" : "documenti";
const nome = nomeCompleto(g);
return (
<>
<PageHeader titolo={nome} sottotitolo={ruoloVisibile(g)} azione={<LinkHome />} />
<CardAvatar id={g.id} fallback={inizialiDa(nome)} />
<BarraSottosezioni
key={tabIniziale}
defaultId={tabIniziale}
riempiLarghezza
voci={[
{ id: "documenti", label: "Docs", contenuto: <ProfiloAllenatore g={g} /> },
{
id: "impostazioni",
label: "Opzioni",
contenuto: <TabOpzioni giocatoreId={g.id} />,
},
]}
/>
</>
);
}
function LinkHome() {
return (
<Link to="/" aria-label="Vai alla home" className="premi shrink-0 rounded-2xl">
<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />
</Link>
);
}
/** Foto profilo: stessa per giocatore e allenatore (bucket `avatar-giocatori`). */
function CardAvatar({ id, fallback }: { id: string; fallback: string }) {
const inputRef = useRef<HTMLInputElement>(null);
const fotoEsiste = useAvatarEsiste(id);
const impostaAvatarEsiste = useImpostaAvatarEsiste();
const [bust, setBust] = useState(0);
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
await caricaAvatar(id, file);
setBust(Date.now());
impostaAvatarEsiste(id, true);
toast.success("Immagine profilo aggiornata");
} catch {
toast.error("Non sono riuscito a caricare l'immagine");
}
}
return (
<Reveal className="-mt-6 px-5">
<Card>
<div className="flex flex-col items-center gap-4">
<Avatar id={id} fallback={fallback} className="h-20 w-20 text-2xl" bust={bust} />
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={onFile} />
</div>
<div className="mt-4 flex gap-2">
<button
type="button"
onClick={() => inputRef.current?.click()}
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
>
Cambia immagine profilo
</button>
{fotoEsiste.data ? (
<button
type="button"
onClick={async () => {
if (!confirm("Rimuovere l'immagine profilo?")) return;
try {
await rimuoviAvatar(id);
setBust(Date.now());
impostaAvatarEsiste(id, false);
toast.success("Immagine rimossa");
} catch {
toast.error("Non sono riuscito a rimuovere l'immagine");
}
}}
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/15 text-destructive"
aria-label="Rimuovi immagine"
>
<Trash2 className="h-4 w-4" />
</button>
) : null}
</div>
</Card>
</Reveal>
);
}
/** Tab Opzioni: notifiche, dashboard admin, segnalazioni, uscita. Uguale per tutti. */
function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
const admin = useIsAdmin();
const [notifiche, setNotifiche] = useState(false);
const [inCorso, setInCorso] = useState(false);
const [supportate, setSupportate] = useState(true);
useEffect(() => {
setSupportate(pushSupportato());
statoNotifiche()
.then(setNotifiche)
.catch(() => setNotifiche(false));
}, []);
async function cambiaNotifiche() {
if (inCorso) return;
setInCorso(true);
try {
if (notifiche) {
await disattivaNotifiche();
setNotifiche(false);
toast.success("Notifiche disattivate");
} else {
await attivaNotifiche(giocatoreId);
setNotifiche(true);
toast.success("Notifiche attive");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Notifiche non disponibili");
} finally {
setInCorso(false);
}
}
async function logout() {
try {
await esci();
resetGiocatore();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
}
}
return (
<div className="space-y-3">
<div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card">
<button
type="button"
onClick={cambiaNotifiche}
disabled={!supportate || inCorso}
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
>
<span className="min-w-0">
<span className="block truncate">Notifiche</span>
<span className="block text-xs text-muted-foreground">
{supportate
? notifiche
? "Attive su questo dispositivo"
: "Palloni, solleciti presenze e avvisi in app"
: "Non supportate su questo dispositivo"}
</span>
</span>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
notifiche
? "bg-accent-grad text-accent-foreground"
: "bg-secondary text-muted-foreground",
)}
>
<Bell className="h-4 w-4" />
</span>
</button>
{admin ? (
<Link
to="/admin"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Dashboard amministratore</span>
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
</Link>
) : null}
<a
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
target="_blank"
rel="noopener noreferrer"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Segnala un bug</span>
<Bug className="h-4 w-4 text-muted-foreground" />
</a>
<a
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
target="_blank"
rel="noopener noreferrer"
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
>
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
<Lightbulb className="h-4 w-4 text-muted-foreground" />
</a>
</div>
<p className="text-center text-xs text-muted-foreground">CrAPP v{APP_VERSION}</p>
<button
type="button"
onClick={logout}
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
>
<LogOut className="h-4 w-4" />
Esci
</button>
</div>
);
}
+106 -39
View File
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
import { PageHeader, StatTile } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { Avatar } from "@/components/crapp/Avatar";
import { formatData } from "@/lib/crapp-data";
import { formatData, inizialiDa } from "@/lib/crapp-data";
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import {
useRosa,
@@ -15,6 +15,13 @@ import {
type CriterioClassifica,
} from "@/lib/rosa";
import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese";
import {
isAllenatore,
nomeCompleto,
ruoloVisibile,
useGiocatoriSquadra,
} from "@/lib/giocatori-squadra";
import { useSonoAllenatore } from "@/lib/user-store";
import { totaliSquadra, useScoutMatches } from "@/lib/scout-store";
import { useCsi } from "@/lib/csi";
import { partiteGiocate } from "@/lib/csi-core";
@@ -87,6 +94,14 @@ function valore(
function Squadra() {
const rosa = useRosa();
// Gli allenatori stanno in Rosa con i giocatori, senza numero né statistiche (DD-034).
const { righe: squadra } = useGiocatoriSquadra();
const allenatori = useMemo(() => squadra.filter((g) => g.attivo && isAllenatore(g)), [squadra]);
// Chi allena non vede badge, cacche né obiettivi (DD-034).
const sonoAllenatore = useSonoAllenatore();
const criteriVisibili = sonoAllenatore
? criteri.filter((c) => c.id !== "cacchePartita")
: criteri;
const mese = usePresenzeUltimoMeseTutti();
const scoutMatches = useScoutMatches();
const { voti: pagelle } = usePagelle();
@@ -115,9 +130,36 @@ function Squadra() {
const contenutoRosa = useMemo(
() => (
<div className="space-y-2">
{allenatori.map((a) => (
<article
key={a.id}
className="flex min-h-11 items-center gap-3 rounded-3xl bg-card p-3 shadow-card"
>
<Avatar
id={a.id}
fallback={inizialiDa(nomeCompleto(a))}
className="h-11 w-11 text-lg"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold leading-tight">
{nomeCompleto(a)}
</span>
<span className="mt-1 flex items-center gap-2">
<RuoloBadge ruolo={ruoloVisibile(a)} />
{a.nascita ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
</span>
) : null}
</span>
</span>
</article>
))}
{rosa.map((g) => {
const stati = badgeGiocatore(g);
const sbloccati = [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
const sbloccati = sonoAllenatore
? []
: [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
const isOpen = aperto === g.id;
return (
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
@@ -185,7 +227,9 @@ function Squadra() {
{ l: "Presenze di fila", v: g.streak },
{ l: "Media voto", v: g.mediaVoto || "—" },
{ l: "MVP", v: g.mvp },
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" },
...(sonoAllenatore
? []
: [{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" }]),
].map((s) => (
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
<p className="font-display text-xl leading-none">{s.v}</p>
@@ -196,37 +240,45 @@ function Squadra() {
))}
</div>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
</p>
{sbloccati.length === 0 ? (
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
Nessun badge sbloccato per ora.
</p>
) : (
<div className="mt-2 grid grid-cols-2 gap-2">
{sbloccati.map((b) => {
const Icon = b.def.icon;
const meta = gradoMeta[b.grado!];
return (
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
<Icon className={cn("h-4 w-4", meta.text)} />
<p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p>
<p className="text-xs text-muted-foreground">
{b.valore} {b.def.unita}
{b.prossimaSoglia
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
: ""}
</p>
<p className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}>
{meta.label}
</p>
</div>
</BadgeDrawer>
);
})}
</div>
{sonoAllenatore ? null : (
<>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
</p>
{sbloccati.length === 0 ? (
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
Nessun badge sbloccato per ora.
</p>
) : (
<div className="mt-2 grid grid-cols-2 gap-2">
{sbloccati.map((b) => {
const Icon = b.def.icon;
const meta = gradoMeta[b.grado!];
return (
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
<Icon className={cn("h-4 w-4", meta.text)} />
<p className="mt-1 text-xs font-bold leading-tight">
{b.def.nome}
</p>
<p className="text-xs text-muted-foreground">
{b.valore} {b.def.unita}
{b.prossimaSoglia
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
: ""}
</p>
<p
className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}
>
{meta.label}
</p>
</div>
</BadgeDrawer>
);
})}
</div>
)}
</>
)}
</div>
) : null}
@@ -235,7 +287,7 @@ function Squadra() {
})}
</div>
),
[rosa, aperto, mese],
[rosa, aperto, mese, allenatori, sonoAllenatore],
);
const contenutoStats = useMemo(
@@ -271,7 +323,7 @@ function Squadra() {
<DrawerTitle>Classifica per</DrawerTitle>
</DrawerHeader>
<div className="flex flex-col gap-1 px-4 pb-6">
{criteri.map((c) => (
{criteriVisibili.map((c) => (
<DrawerClose key={c.id} asChild>
<button
type="button"
@@ -326,7 +378,18 @@ function Squadra() {
</div>
</div>
),
[mediaPresenze, matchGiocati, pagelle, team, filtroAperto, criterio, ordinati, rank, max],
[
mediaPresenze,
matchGiocati,
pagelle,
team,
filtroAperto,
criterio,
ordinati,
rank,
max,
criteriVisibili,
],
);
const contenutoObiettivi = useMemo(
@@ -450,8 +513,12 @@ function Squadra() {
voci={[
{ id: "rosa", label: "Rosa", contenuto: contenutoRosa },
{ id: "stats", label: "Stats", contenuto: contenutoStats },
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
...(sonoAllenatore
? []
: [
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
]),
]}
/>
</>