squadra: mediaPresenzeRosa, obiettiviCompletati, mediaProgressoObiettivi, quantiAlGrado; scout: percentualePiu; utils: percentuale (usata da profilo, RosaPresenze, CollezioneBadge). Test unitari per ciascuna. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
555 lines
22 KiB
TypeScript
555 lines
22 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { createFileRoute } from "@tanstack/react-router";
|
|
import { Cake, ChevronDown, ChevronRight, Crown, Trophy } from "lucide-react";
|
|
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, inizialiDa } from "@/lib/crapp-data";
|
|
import { ObiettivoDrawer } from "@/components/crapp/ObiettivoDrawer";
|
|
import {
|
|
mediaPresenzeRosa,
|
|
mediaProgressoObiettivi,
|
|
microcopyObiettivo,
|
|
obiettiviCompletati,
|
|
progressoObiettivo,
|
|
} from "@/lib/obiettivi";
|
|
import {
|
|
useRosa,
|
|
useObiettivi,
|
|
classificaRank,
|
|
dettaglioClassifica,
|
|
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 { totaliSquadraValutazioni, useScoutMatches } from "@/lib/scout-store";
|
|
import { useCsi } from "@/lib/csi";
|
|
import { partiteGiocate } from "@/lib/csi-core";
|
|
import { mediaSquadra, usePagelle } from "@/lib/pagelle";
|
|
import { Reveal } from "@/components/motion/Reveal";
|
|
import { Barra } from "@/components/motion/Barra";
|
|
import { Numero } from "@/components/motion/Numero";
|
|
import { BadgeDrawer } from "@/components/crapp/BadgeDrawer";
|
|
import {
|
|
Drawer,
|
|
DrawerClose,
|
|
DrawerContent,
|
|
DrawerHeader,
|
|
DrawerTitle,
|
|
} from "@/components/ui/drawer";
|
|
import {
|
|
badgeDefs,
|
|
badgeGiocatore,
|
|
badgeSegretiSbloccati,
|
|
descrizioneSoglie,
|
|
gradiOrdine,
|
|
gradoMeta,
|
|
quantiAlGrado,
|
|
collezioneBadge,
|
|
} from "@/lib/badges";
|
|
|
|
function RuoloBadge({ ruolo }: { ruolo: string }) {
|
|
return (
|
|
<span className="inline-flex items-center rounded-full bg-accent px-2 py-0.5 text-[13px] font-bold uppercase tracking-wide text-accent-foreground">
|
|
{ruolo}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export const Route = createFileRoute("/squadra")({
|
|
head: () => ({
|
|
meta: [
|
|
{ title: "Squadra CRAP Volley — CrAPP" },
|
|
{
|
|
name: "description",
|
|
content:
|
|
"Rosa completa del CRAP Volley: giocatori, dati anagrafici, statistiche stagionali e badge sbloccati.",
|
|
},
|
|
{ property: "og:title", content: "Squadra CRAP Volley — CrAPP" },
|
|
{
|
|
property: "og:description",
|
|
content: "Tutti i giocatori della rosa con dati, statistiche e obiettivi raggiunti.",
|
|
},
|
|
],
|
|
}),
|
|
component: Squadra,
|
|
});
|
|
|
|
const criteri = [
|
|
{ id: "presenze", label: "Presenze" },
|
|
{ id: "mediaVoto", label: "Media voto" },
|
|
{ id: "mvp", label: "MVP" },
|
|
{ id: "palloni", label: "Palloni" },
|
|
{ id: "cacchePartita", label: "Cacche" },
|
|
] as const;
|
|
|
|
type Criterio = CriterioClassifica;
|
|
|
|
function valore(
|
|
g: { presenze: number; mediaVoto: number; mvp: number; palloni: number; cacchePartita: number },
|
|
c: Criterio,
|
|
) {
|
|
return g[c] ?? 0;
|
|
}
|
|
|
|
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();
|
|
const [aperto, setAperto] = useState<string | null>(null);
|
|
const [criterio, setCriterio] = useState<Criterio>("presenze");
|
|
const [filtroAperto, setFiltroAperto] = useState(false);
|
|
const obiettivi = useObiettivi();
|
|
const team = totaliSquadraValutazioni(scoutMatches);
|
|
const mediaPresenze = mediaPresenzeRosa(rosa);
|
|
const ordinati = [...rosa].sort((a, b) => valore(b, criterio) - valore(a, criterio));
|
|
const rank = classificaRank(ordinati.map((g) => valore(g, criterio)));
|
|
const max = ordinati[0] ? valore(ordinati[0], criterio) || 1 : 1;
|
|
const { data: csi } = useCsi();
|
|
const matchGiocati = csi ? partiteGiocate(csi.partite).length : scoutMatches.length;
|
|
const completati = obiettiviCompletati(obiettivi);
|
|
const mediaObiettivi = mediaProgressoObiettivi(obiettivi);
|
|
|
|
// Ogni tab è memoizzata: aprire/chiudere una card giocatore (stato `aperto`)
|
|
// non deve ricalcolare badge e obiettivi delle altre sezioni.
|
|
const contenutoRosa = useMemo(
|
|
() => (
|
|
<div className="space-y-2">
|
|
{allenatori.map((a) => {
|
|
const isOpen = aperto === a.id;
|
|
return (
|
|
<article key={a.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
|
|
<button
|
|
type="button"
|
|
onClick={() => setAperto(isOpen ? null : a.id)}
|
|
className="flex min-h-11 w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
|
|
aria-expanded={isOpen}
|
|
>
|
|
<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-[15px] font-bold leading-tight">
|
|
{nomeCompleto(a)}
|
|
</span>
|
|
<span className="mt-1 flex items-center gap-2">
|
|
<RuoloBadge ruolo={ruoloVisibile(a)} />
|
|
</span>
|
|
</span>
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
|
isOpen && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
{isOpen && a.nascita ? (
|
|
<div className="border-t border-border px-4 pb-4 pt-3">
|
|
<p className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground">
|
|
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</article>
|
|
);
|
|
})}
|
|
{rosa.map((g) => {
|
|
const stati = badgeGiocatore(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">
|
|
<button
|
|
type="button"
|
|
onClick={() => setAperto(isOpen ? null : g.id)}
|
|
className="flex min-h-11 w-full items-center gap-3 p-3 text-left active:scale-[0.99]"
|
|
aria-expanded={isOpen}
|
|
>
|
|
<Avatar
|
|
id={g.id}
|
|
fallback={g.numero ? String(g.numero) : g.iniziali}
|
|
className="h-11 w-11 text-lg"
|
|
/>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block truncate text-[15px] font-bold leading-tight">
|
|
{g.nome}
|
|
</span>
|
|
<span className="mt-1 flex items-center gap-2">
|
|
<RuoloBadge ruolo={g.ruolo} />
|
|
<span className="text-[13px] text-muted-foreground">
|
|
{g.numero ? `#${g.numero}` : "n° da definire"}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
{sbloccati.length > 0 ? (
|
|
<span className="flex max-w-[74px] shrink-0 flex-wrap items-center justify-end gap-0.5">
|
|
{sbloccati.map((b) => {
|
|
const Icon = b.def.icon;
|
|
return (
|
|
<span key={b.def.id} title={`${b.def.nome} — ${gradoMeta[b.grado!].label}`}>
|
|
<Icon
|
|
className={cn("h-3.5 w-3.5", gradoMeta[b.grado!].text)}
|
|
aria-hidden="true"
|
|
/>
|
|
<span className="sr-only">
|
|
{b.def.nome}: {gradoMeta[b.grado!].label}
|
|
</span>
|
|
</span>
|
|
);
|
|
})}
|
|
</span>
|
|
) : null}
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
|
isOpen && "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
{isOpen ? (
|
|
<div className="border-t border-border px-4 pb-4 pt-3">
|
|
{g.nascita ? (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<p className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground">
|
|
<Cake className="h-3.5 w-3.5" /> {formatData(g.nascita)}{" "}
|
|
{g.nascita.slice(0, 4)}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
|
{[
|
|
{ l: "Presenze", v: `${g.presenze}/${g.totaliEventi}` },
|
|
{ l: "Presenze 30gg", v: `${mese[g.id]?.percentuale ?? 0}%` },
|
|
{ l: "Presenze di fila", v: g.streak },
|
|
{ l: "Media voto", v: g.mediaVoto || "—" },
|
|
{ l: "MVP", v: g.mvp },
|
|
...(sonoAllenatore ? [] : [{ l: "Cacche 💩", 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>
|
|
<p className="mt-1 text-[13px] font-semibold uppercase text-muted-foreground">
|
|
{s.l}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{sonoAllenatore ? null : (
|
|
<>
|
|
<p className="mt-4 text-[13px] 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-[13px] 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-[13px] font-bold leading-tight">
|
|
{b.def.nome}
|
|
</p>
|
|
<p className="text-[13px] 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-[13px] font-bold uppercase",
|
|
meta.text,
|
|
)}
|
|
>
|
|
{meta.label}
|
|
</p>
|
|
</div>
|
|
</BadgeDrawer>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
),
|
|
[rosa, aperto, mese, allenatori, sonoAllenatore],
|
|
);
|
|
|
|
const contenutoStats = useMemo(
|
|
() => (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<StatTile valore={`${mediaPresenze}%`} label="Media presenze" />
|
|
<StatTile valore={matchGiocati} label="Match giocati" />
|
|
<StatTile valore={mediaSquadra(pagelle) || "—"} label="Media pagelle" />
|
|
<StatTile valore={team.punti} label="Punti squadra" />
|
|
<StatTile valore={team.ace} label="Ace squadra" />
|
|
<StatTile valore={team.muri} label="Muri squadra" />
|
|
</div>
|
|
|
|
<div className="border-t border-border pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFiltroAperto(true)}
|
|
aria-haspopup="dialog"
|
|
className="flex w-full min-w-0 items-center gap-2 rounded-2xl bg-card px-3 py-2.5 text-left shadow-card"
|
|
>
|
|
<Trophy className="h-5 w-5 shrink-0 text-warning" aria-hidden />
|
|
<span className="shrink-0 text-[15px] text-foreground/80">Classifica per</span>
|
|
<span className="min-w-0 flex-1 truncate text-right text-[15px] font-bold">
|
|
{criteri.find((c) => c.id === criterio)?.label}
|
|
</span>
|
|
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
</button>
|
|
|
|
<Drawer open={filtroAperto} onOpenChange={setFiltroAperto}>
|
|
<DrawerContent>
|
|
<DrawerHeader>
|
|
<DrawerTitle>Classifica per</DrawerTitle>
|
|
</DrawerHeader>
|
|
<div className="flex flex-col gap-1 px-4 pb-6">
|
|
{criteriVisibili.map((c) => (
|
|
<DrawerClose key={c.id} asChild>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCriterio(c.id)}
|
|
className={cn(
|
|
"flex min-h-11 w-full items-center rounded-xl px-3 text-left text-[15px] font-bold",
|
|
criterio === c.id
|
|
? "bg-accent text-accent-foreground"
|
|
: "bg-secondary text-foreground",
|
|
)}
|
|
>
|
|
{c.label}
|
|
</button>
|
|
</DrawerClose>
|
|
))}
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<div className="mt-3 space-y-2">
|
|
{ordinati.map((g, i) => (
|
|
<div key={g.id} className="rounded-2xl bg-card p-3 shadow-card">
|
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-secondary font-display text-lg">
|
|
{rank[i]}
|
|
</span>
|
|
<div className="min-w-0">
|
|
<p className="truncate text-[15px] font-bold">
|
|
{g.nome}
|
|
{rank[i] === 1 ? (
|
|
<Crown className="ml-1 inline h-3.5 w-3.5 text-warning" />
|
|
) : null}
|
|
</p>
|
|
<p className="truncate text-[13px] text-muted-foreground">
|
|
#{g.numero} · {g.ruolo} · {dettaglioClassifica(g, criterio)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<span className="font-display text-xl tabular-nums">
|
|
{valore(g, criterio) || "—"}
|
|
</span>
|
|
</div>
|
|
<Barra
|
|
percentuale={Math.max(6, (valore(g, criterio) / max) * 100)}
|
|
altezza="h-1.5"
|
|
trackClassName="mt-2"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
),
|
|
[
|
|
mediaPresenze,
|
|
matchGiocati,
|
|
pagelle,
|
|
team,
|
|
filtroAperto,
|
|
criterio,
|
|
ordinati,
|
|
rank,
|
|
max,
|
|
criteriVisibili,
|
|
],
|
|
);
|
|
|
|
const contenutoObiettivi = useMemo(
|
|
() => (
|
|
<>
|
|
<div className="mb-3 rounded-3xl bg-hero p-4 text-primary-foreground shadow-card">
|
|
<div className="flex items-end justify-between gap-3">
|
|
<div>
|
|
<p className="text-[13px] font-semibold uppercase tracking-wide text-primary-foreground/60">
|
|
Progresso collettivo
|
|
</p>
|
|
<p className="font-display text-4xl leading-none">
|
|
<Numero valore={mediaObiettivi} suffisso="%" />
|
|
</p>
|
|
</div>
|
|
<p className="text-[13px] text-primary-foreground/70">
|
|
{completati}/{obiettivi.length} completati
|
|
</p>
|
|
</div>
|
|
<Barra percentuale={mediaObiettivi} trackClassName="mt-3 bg-primary-foreground/15" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
{obiettivi.map((o, i) => {
|
|
const pct = progressoObiettivo(o);
|
|
const fatto = pct >= 100;
|
|
return (
|
|
<Reveal key={o.id} indice={i}>
|
|
<ObiettivoDrawer obiettivo={o}>
|
|
<button
|
|
type="button"
|
|
aria-label={`Dettaglio obiettivo: ${o.titolo}`}
|
|
className={cn(
|
|
"premi block w-full rounded-3xl bg-card p-4 text-left shadow-card ring-1",
|
|
fatto ? "ring-success/40" : pct >= 90 ? "ring-accent/40" : "ring-transparent",
|
|
)}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<span className="text-lg leading-none">{o.emoji}</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-[15px] font-bold leading-tight">{o.titolo}</p>
|
|
<p className="text-[13px] text-muted-foreground">{o.descrizione}</p>
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
"rounded-full px-2 py-0.5 text-[13px] font-bold uppercase tracking-wide",
|
|
fatto
|
|
? "bg-success text-success-foreground"
|
|
: "bg-secondary text-muted-foreground",
|
|
)}
|
|
>
|
|
{fatto ? "Completato" : `${pct}%`}
|
|
</span>
|
|
</div>
|
|
<Barra percentuale={pct} trackClassName="mt-3" />
|
|
<p className="mt-2 text-[13px] text-muted-foreground">
|
|
{o.valore}/{o.target} {o.unita} · {pct}%
|
|
{o.scadenza ? ` · entro il ${formatData(o.scadenza)}` : ""}
|
|
</p>
|
|
<p className="mt-1 text-[13px] font-semibold text-accent">
|
|
{microcopyObiettivo(o)}
|
|
</p>
|
|
<p className="mt-0.5 text-[13px] text-muted-foreground">{o.impatto}</p>
|
|
<p className="mt-2 inline-flex items-center text-[13px] font-semibold text-accent">
|
|
Come funziona <ChevronRight className="h-3.5 w-3.5" />
|
|
</p>
|
|
</button>
|
|
</ObiettivoDrawer>
|
|
</Reveal>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
),
|
|
[obiettivi, mediaObiettivi, completati],
|
|
);
|
|
|
|
const contenutoBadge = useMemo(
|
|
() => (
|
|
<div className="space-y-2">
|
|
{badgeDefs.map((b) => {
|
|
const Icon = b.icon;
|
|
return (
|
|
<BadgeDrawer key={b.id} def={b}>
|
|
<div className="rounded-3xl bg-card p-3 shadow-card">
|
|
<div className="flex items-center gap-2">
|
|
<Icon className="h-5 w-5 text-accent" />
|
|
<p className="text-[15px] font-bold leading-tight">{b.nome}</p>
|
|
<p className="ml-auto text-[13px] text-muted-foreground">
|
|
{descrizioneSoglie(b)}
|
|
</p>
|
|
</div>
|
|
<div className="mt-2 grid grid-cols-3 gap-1.5">
|
|
{gradiOrdine.map((grado) => {
|
|
const meta = gradoMeta[grado];
|
|
const quanti = quantiAlGrado(b, rosa, grado);
|
|
return (
|
|
<div
|
|
key={grado}
|
|
className={cn("rounded-2xl p-2 text-center ring-1", meta.bg, meta.ring)}
|
|
>
|
|
<p className={cn("text-[13px] font-bold uppercase", meta.text)}>
|
|
{meta.label}
|
|
</p>
|
|
<p className="font-display text-lg leading-none">{b.soglie[grado]}</p>
|
|
<p className="text-[13px] text-muted-foreground">
|
|
{quanti}/{rosa.length}
|
|
</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</BadgeDrawer>
|
|
);
|
|
})}
|
|
</div>
|
|
),
|
|
[rosa],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<PageHeader titolo="Squadra" sottotitolo={`${rosa.length} giocatori · Stagione 2026/27`} />
|
|
|
|
<BarraSottosezioni
|
|
defaultId="rosa"
|
|
variante="sottolineatura"
|
|
riempiLarghezza
|
|
voci={[
|
|
{ id: "rosa", label: "Rosa", contenuto: contenutoRosa },
|
|
{ id: "stats", label: "Stats", contenuto: contenutoStats },
|
|
...(sonoAllenatore
|
|
? []
|
|
: [
|
|
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
|
|
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
|
|
]),
|
|
]}
|
|
/>
|
|
</>
|
|
);
|
|
}
|