Scheda di dettaglio per gli eventi extra-campo, senza infortunato tra le risposte

Gli eventi generici (es. cena di squadra) avevano solo il conteggio presenze in
card, senza modo di vedere chi ha risposto. Aggiunta la route /evento/$id sul
modello di allenamento/partita, con RosaPresenze per l'elenco nominativo.

Lo stato "infortunato" non è una risposta pertinente per questi eventi: escluso
da badge, gruppi e bottoni di risposta sia in RosaPresenze che in EventoCard,
tramite la funzione condivisa includeInfortunato() in presenze.ts.

Documentazione dei moduli (calendario, presenze, palloni, badge) allineata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5 committed 2026-09-28 11:01:46 +02:00
1 parent a72eb087d1
commit 39918f2eb7
13 files changed
+220 -41

No files matched your search

+9 -12
View File
@@ -15,7 +15,7 @@ import { Card } from "@/components/crapp/ui-bits";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { includeInfortunato, usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { dataOggi } from "@/lib/scout-live";
@@ -26,9 +26,7 @@ const tipoMeta = {
compleanno: { label: "Compleanno", className: "bg-success text-success-foreground" },
} as const;
const statiSportivi: Stato[] = ["presente", "forse", "ritardo", "assente", "infortunato"];
/** Eventi extra-campo (pizze, uscite…): l'infortunio non è una risposta pertinente. */
const statiEventoExtra: Stato[] = ["presente", "forse", "ritardo", "assente"];
const statiBase: Stato[] = ["presente", "forse", "ritardo", "assente"];
/** Icone compatte per la riga unica dei controlli presenza (mockup). */
const iconeStato: Record<Stato, typeof Check> = {
@@ -51,6 +49,9 @@ export function linkPerEvento(e: Evento) {
label: "Dettaglio allenamento",
};
}
if (e.tipo === "evento") {
return { to: "/evento/$id" as const, params: { id: e.id }, label: "Dettaglio evento" };
}
return undefined;
}
@@ -87,15 +88,11 @@ export function EventoCard({
const isCompleanno = evento.tipo === "compleanno";
const passato = evento.data < dataOggi();
const cliccabile = Boolean(linkTo);
/** Solo gli eventi extra-campo non hanno scheda dedicata: le note restano sulla card. */
/** Anteprima delle note anche in card per gli eventi extra-campo (oltre al dettaglio). */
const noteCard = evento.tipo === "evento" ? evento.note.trim() : "";
const stati =
evento.tipo === "evento"
? // Se resta un vecchio "infortunato", mostra il bottone solo per poterlo togliere.
stato === "infortunato"
? [...statiEventoExtra, "infortunato" as const]
: statiEventoExtra
: statiSportivi;
const stati = includeInfortunato(evento.tipo, stato)
? [...statiBase, "infortunato" as const]
: statiBase;
if (isCompleanno) {
return (
+15 -4
View File
@@ -6,6 +6,7 @@ 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 type { EventoTipo } from "@/lib/eventi";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
@@ -15,7 +16,16 @@ import { dataOggi } from "@/lib/scout-live";
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
export function RosaPresenze({ eventoId, data }: { eventoId: string; data: string }) {
export function RosaPresenze({
eventoId,
data,
tipo = "allenamento",
}: {
eventoId: string;
data: 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,
@@ -32,6 +42,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
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);
@@ -67,7 +78,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
<Barra percentuale={perc} altezza="h-1.5" trackClassName="mt-2" />
<div className="mt-3 flex flex-wrap gap-1.5">
{ordine.map((s) => {
{ordineVisibile.map((s) => {
const n = rosa.filter((g) => risposte[g.id] === s).length;
return (
<span
@@ -92,7 +103,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
La tua risposta
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{ordine.map((s) => {
{ordineVisibile.map((s) => {
const attivo = risposte[io.id] === s;
return (
<button
@@ -140,7 +151,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
</p>
) : null}
{ordine.map((s) => {
{ordineVisibile.map((s) => {
const lista = rosa.filter((g) => risposte[g.id] === s);
if (lista.length === 0) return null;
return (
+11 -1
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import type { Stato } from "./crapp-data";
import type { Evento } from "./eventi";
import type { Evento, EventoTipo } from "./eventi";
import { inRosa, type TipoMembro } from "./giocatori-squadra";
import { aggiornaSerie } from "./serie";
import { dataOggi } from "./scout-live";
@@ -71,6 +71,16 @@ export function totaliEventiGiocatore(
return eventiContanoPresenze(eventi, giocatoreId, oggi).length;
}
/**
* Se "infortunato" va offerto come risposta: sì per allenamenti e partite, no per gli
* eventi extra-campo (`"evento"`, es. cena di squadra), dove l'infortunio non è una
* risposta pertinente. Resta comunque visibile a chi ce l'ha già (dati storici, o un
* evento cambiato tipo dopo la risposta), così può ancora toglierla.
*/
export function includeInfortunato(tipo: EventoTipo, statoAttuale?: Stato | null): boolean {
return tipo !== "evento" || statoAttuale === "infortunato";
}
/**
* Chi va sollecitato per un evento: i giocatori attivi (non gli allenatori) che non hanno ancora risposto, più
* quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i
+21
View File
@@ -19,6 +19,7 @@ import { Route as ProfiloRouteImport } from './routes/profilo'
import { Route as ScoutRouteImport } from './routes/scout'
import { Route as SquadraRouteImport } from './routes/squadra'
import { Route as AllenamentoIdRouteImport } from './routes/allenamento.$id'
import { Route as EventoIdRouteImport } from './routes/evento.$id'
import { Route as PartitaCsiIdRouteImport } from './routes/partita-csi.$id'
import { Route as PartitaIdRouteImport } from './routes/partita.$id'
import { Route as ApiPublicApriSondaggioRouteImport } from './routes/api/public/apri-sondaggio'
@@ -82,6 +83,11 @@ const AllenamentoIdRoute = AllenamentoIdRouteImport.update({
path: '/allenamento/$id',
getParentRoute: () => rootRouteImport,
} as any)
const EventoIdRoute = EventoIdRouteImport.update({
id: '/evento/$id',
path: '/evento/$id',
getParentRoute: () => rootRouteImport,
} as any)
const PartitaCsiIdRoute = PartitaCsiIdRouteImport.update({
id: '/partita-csi/$id',
path: '/partita-csi/$id',
@@ -158,6 +164,7 @@ export interface FileRoutesByFullPath {
'/scout': typeof ScoutRoute
'/squadra': typeof SquadraRoute
'/allenamento/$id': typeof AllenamentoIdRoute
'/evento/$id': typeof EventoIdRoute
'/partita-csi/$id': typeof PartitaCsiIdRoute
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
@@ -182,6 +189,7 @@ export interface FileRoutesByTo {
'/scout': typeof ScoutRoute
'/squadra': typeof SquadraRoute
'/allenamento/$id': typeof AllenamentoIdRoute
'/evento/$id': typeof EventoIdRoute
'/partita-csi/$id': typeof PartitaCsiIdRoute
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
@@ -207,6 +215,7 @@ export interface FileRoutesById {
'/scout': typeof ScoutRoute
'/squadra': typeof SquadraRoute
'/allenamento/$id': typeof AllenamentoIdRoute
'/evento/$id': typeof EventoIdRoute
'/partita-csi/$id': typeof PartitaCsiIdRoute
'/partita/$id': typeof PartitaIdRoute
'/api/public/apri-sondaggio': typeof ApiPublicApriSondaggioRoute
@@ -233,6 +242,7 @@ export interface FileRouteTypes {
| '/scout'
| '/squadra'
| '/allenamento/$id'
| '/evento/$id'
| '/partita-csi/$id'
| '/partita/$id'
| '/api/public/apri-sondaggio'
@@ -257,6 +267,7 @@ export interface FileRouteTypes {
| '/scout'
| '/squadra'
| '/allenamento/$id'
| '/evento/$id'
| '/partita-csi/$id'
| '/partita/$id'
| '/api/public/apri-sondaggio'
@@ -281,6 +292,7 @@ export interface FileRouteTypes {
| '/scout'
| '/squadra'
| '/allenamento/$id'
| '/evento/$id'
| '/partita-csi/$id'
| '/partita/$id'
| '/api/public/apri-sondaggio'
@@ -306,6 +318,7 @@ export interface RootRouteChildren {
ScoutRoute: typeof ScoutRoute
SquadraRoute: typeof SquadraRoute
AllenamentoIdRoute: typeof AllenamentoIdRoute
EventoIdRoute: typeof EventoIdRoute
PartitaCsiIdRoute: typeof PartitaCsiIdRoute
PartitaIdRoute: typeof PartitaIdRoute
ApiPublicApriSondaggioRoute: typeof ApiPublicApriSondaggioRoute
@@ -392,6 +405,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AllenamentoIdRouteImport
parentRoute: typeof rootRouteImport
}
'/evento/$id': {
id: '/evento/$id'
path: '/evento/$id'
fullPath: '/evento/$id'
preLoaderRoute: typeof EventoIdRouteImport
parentRoute: typeof rootRouteImport
}
'/partita-csi/$id': {
id: '/partita-csi/$id'
path: '/partita-csi/$id'
@@ -490,6 +510,7 @@ const rootRouteChildren: RootRouteChildren = {
ScoutRoute: ScoutRoute,
SquadraRoute: SquadraRoute,
AllenamentoIdRoute: AllenamentoIdRoute,
EventoIdRoute: EventoIdRoute,
PartitaCsiIdRoute: PartitaCsiIdRoute,
PartitaIdRoute: PartitaIdRoute,
ApiPublicApriSondaggioRoute: ApiPublicApriSondaggioRoute,
+1 -1
View File
@@ -91,7 +91,7 @@ function AllenamentoDetail() {
</Section>
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} />
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
</Section>
</>
);
+95
View File
@@ -0,0 +1,95 @@
import { createFileRoute } from "@tanstack/react-router";
import { MapPin, Clock, Users, PartyPopper } from "lucide-react";
import { PageHeader, Section } from "@/components/crapp/ui-bits";
import { formatData } from "@/lib/crapp-data";
import { convocatiEvento, useEvento } from "@/lib/eventi";
import { useRosa } from "@/lib/rosa";
import { RosaPresenze } from "@/components/crapp/RosaPresenze";
import { usePresenzeEvento } from "@/lib/presenze";
export const Route = createFileRoute("/evento/$id")({
head: () => {
const titolo = "Dettaglio evento";
return {
meta: [
{ title: `${titolo} — CrAPP` },
{
name: "description",
content: "Dettaglio evento extra-campo, orario, luogo e presenze del CRAP Volley.",
},
{ property: "og:title", content: `${titolo} — CrAPP` },
{
property: "og:description",
content: "Dettaglio evento extra-campo, orario, luogo e presenze del CRAP Volley.",
},
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary" },
],
};
},
component: EventoDetail,
});
function EventoDetail() {
const { id } = Route.useParams();
const { evento } = useEvento(id);
const { risposte } = usePresenzeEvento(id);
const rosa = useRosa();
const presentiVeri = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
).length;
if (!evento) {
return (
<div className="px-5 pt-8">
<p className="mt-8 text-center text-sm text-muted-foreground">Evento non trovato</p>
</div>
);
}
return (
<>
<PageHeader titolo="Evento" sottotitolo={formatData(evento.data)} />
<Section titolo={evento.titolo}>
<div className="rounded-3xl bg-card p-5 shadow-card">
<div className="flex items-center gap-3">
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-2xl bg-warning/15 text-warning">
<PartyPopper className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
Evento extra-campo
</p>
<p className="truncate text-lg font-bold leading-tight">{evento.titolo}</p>
</div>
</div>
<div className="mt-4 space-y-2 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-2">
<Clock className="h-4 w-4" /> {evento.ora}
</span>
<span className="ml-4 inline-flex items-center gap-2">
<MapPin className="h-4 w-4" /> {evento.luogo}
</span>
</div>
{evento.note ? (
<p className="mt-3 whitespace-pre-line rounded-2xl bg-secondary px-3 py-2 text-sm">
{evento.note}
</p>
) : null}
<div className="mt-4 inline-flex items-center gap-2 rounded-full bg-secondary px-3 py-1.5 text-xs font-semibold">
<Users className="h-4 w-4" />
Conferme: {presentiVeri}/{convocatiEvento(evento, rosa).length}
</div>
</div>
</Section>
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
</Section>
</>
);
}
+1 -1
View File
@@ -295,7 +295,7 @@ function PartitaDetail() {
) : null}
<Section titolo="Rosa e presenze">
<RosaPresenze eventoId={evento.id} data={evento.data} />
<RosaPresenze eventoId={evento.id} data={evento.data} tipo={evento.tipo} />
</Section>
</>
);