Files
CRAPP/src/components/crapp/RosaPresenze.tsx
T
davideandClaude Sonnet 5.5 5ee148b88b Card evento filtrata sui convocati + copertura test per numero di convocati
- La card (Calendario e Home) conta «presenti/convocati» e mostra i pulsanti di presenza solo
  ai convocati; «Da confermare» in Home esclude gli eventi a cui non si è convocati.
- Nuove eConvocato() e rosaConvocata(): il filtro, prima ripetuto in tre punti, è una funzione
  pura usata da card, «Rosa e presenze» e convocatiEvento.
- Test per 0, 1, 2, 3, tutti e id sconosciuti su visibilità, convocazione e solleciti.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 08:29:36 +02:00

217 lines
7.7 KiB
TypeScript

import { useState } from "react";
import { BellRing, HelpCircle, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar";
import { Barra } from "@/components/motion/Barra";
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import { rosaConvocata, type EventoTipo } from "@/lib/eventi";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { usePuoGestireEventi } from "@/lib/ruoli";
import { dataOggi } from "@/lib/scout-live";
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
export function RosaPresenze({
eventoId,
data,
tipo = "allenamento",
convocati = [],
}: {
eventoId: string;
data: string;
/** Id dei convocati: vuoto = tutta la rosa (come `convocatiEvento()`). */
convocati?: string[];
/** Negli eventi extra-campo "infortunato" non è pertinente: niente badge né opzione. */
tipo?: EventoTipo;
}) {
const { risposte, isPending } = usePresenzeEvento(eventoId);
const salva = useSalvaPresenza();
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
// niente statistiche di squadra.
const io = useGiocatoreInCampo();
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
const mittente = useGiocatoreBase();
const puoSollecitare = usePuoGestireEventi();
const anagrafica = useAnagraficaRosa();
const rosa = rosaConvocata(anagrafica, convocati);
const [sollecito, setSollecito] = useState(false);
const passato = data < dataOggi();
const mancanti = rosa.filter((g) => !risposte[g.id]);
const risposteN = rosa.length - mancanti.length;
const perc = rosa.length ? Math.round((risposteN / rosa.length) * 100) : 0;
const daSollecitare = mancanti.length + rosa.filter((g) => risposte[g.id] === "forse").length;
const ordineVisibile = tipo === "evento" ? ordine.filter((s) => s !== "infortunato") : ordine;
async function sollecita() {
setSollecito(true);
try {
const res = await fetch("/api/public/sollecita-presenze", {
method: "POST",
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ eventoId, da: mittente?.nome }),
});
if (!res.ok) throw new Error();
const dati = (await res.json()) as { inviate: number; destinatari: number };
toast.success(
dati.inviate > 0
? `Sollecito inviato a ${dati.inviate} giocatori`
: `Nessuna notifica attiva tra i ${dati.destinatari} da sollecitare`,
);
} catch {
toast.error("Non sono riuscito a inviare il sollecito");
} finally {
setSollecito(false);
}
}
return (
<div className="space-y-3">
<Card>
<div className="flex items-baseline justify-between">
<p className="text-[15px] font-bold">
Hanno risposto {risposteN}/{rosa.length}
</p>
<span className="font-display text-xl leading-none">{perc}%</span>
</div>
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-2" />
<div className="mt-3 flex flex-wrap gap-1.5">
{ordineVisibile.map((s) => {
const n = rosa.filter((g) => risposte[g.id] === s).length;
return (
<span
key={s}
className={cn(
"rounded-full px-2.5 py-1 text-[13px] font-bold",
n > 0 ? statoMeta[s].className : "bg-secondary text-muted-foreground",
)}
>
{statoMeta[s].emoji} {n}
</span>
);
})}
<span className="rounded-full bg-secondary px-2.5 py-1 text-[13px] font-bold text-muted-foreground">
❔ {mancanti.length} da rispondere
</span>
</div>
{io && rosa.some((g) => g.id === io.id) ? (
<div className="mt-4 border-t border-border pt-3">
<p className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
La tua risposta
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{ordineVisibile.map((s) => {
const attivo = risposte[io.id] === s;
return (
<button
key={s}
type="button"
disabled={salva.isPending || passato}
onClick={() =>
salva.mutate({ eventoId, giocatoreId: io.id, stato: attivo ? null : s })
}
className={cn(
"rounded-full border border-border px-2.5 py-1.5 text-[13px] font-semibold transition-all active:scale-95 disabled:opacity-50",
attivo
? cn(statoMeta[s].className, "border-transparent shadow-card")
: "bg-background text-muted-foreground",
)}
>
{statoMeta[s].label}
</button>
);
})}
</div>
</div>
) : null}
{puoSollecitare ? (
<button
type="button"
onClick={sollecita}
disabled={sollecito || daSollecitare === 0 || passato}
className="premi mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-[15px] font-bold text-primary-foreground disabled:opacity-50"
>
{sollecito ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<BellRing className="h-4 w-4" />
)}
Sollecita {daSollecitare} giocatori
</button>
) : null}
</Card>
{isPending ? (
<p aria-busy="true" className="text-center text-[13px] text-muted-foreground">
Carico le risposte…
</p>
) : null}
{ordineVisibile.map((s) => {
const lista = rosa.filter((g) => risposte[g.id] === s);
if (lista.length === 0) return null;
return (
<Gruppo
key={s}
titolo={`${statoMeta[s].emoji} ${statoMeta[s].label}`}
n={lista.length}
lista={lista}
/>
);
})}
{mancanti.length > 0 ? (
<Gruppo
titolo="❔ Non hanno ancora risposto"
n={mancanti.length}
lista={mancanti}
attenzione
/>
) : null}
</div>
);
}
function Gruppo({
titolo,
n,
lista,
attenzione,
}: {
titolo: string;
n: number;
lista: Array<Pick<Giocatore, "id" | "nome" | "ruolo" | "numero">>;
attenzione?: boolean;
}) {
return (
<div
className={cn(
"rounded-3xl bg-card p-4 shadow-card",
attenzione && "border border-dashed border-warning",
)}
>
<p className="flex items-center gap-1.5 text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{titolo} · {n}
{attenzione ? <HelpCircle className="h-3.5 w-3.5 text-warning" /> : null}
</p>
<div className="mt-3 space-y-2">
{lista.map((g) => (
<div key={g.id} className="flex items-center gap-3">
<Avatar id={g.id} fallback={String(g.numero)} className="h-8 w-8 text-[13px]" />
<span className="min-w-0 flex-1 truncate text-[15px] font-semibold">{g.nome}</span>
<span className="shrink-0 text-[13px] text-muted-foreground">{g.ruolo}</span>
</div>
))}
</div>
</div>
);
}