import { useEffect, useMemo, useRef, useState } from "react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { giocatori, formatData } from "@/lib/crapp-data"; import type { Evento } from "@/lib/eventi"; import { useGiocatoreCorrente } from "@/lib/user-store"; import { useIsAdmin } from "@/lib/ruoli"; import { usePresenzeEvento } from "@/lib/presenze"; import { statoIniziale, useCancellaStatoScout, useSalvaStatoScout, useStatoScout, type StatoScout, } from "@/lib/scout-stato"; import { SCADENZA_MINUTI, sessioneScaduta, useApriSessioneScout, useChiudiSessioneScout, useHeartbeatScout, usePartitaDiOggi, useSessioneScout, } from "@/lib/scout-live"; import { azioniMeta, salvaScoutMatch, totaliPerGiocatore, type Azione, type AzioneTipo, } from "@/lib/scout-store"; export const Route = createFileRoute("/scout")({ head: () => ({ meta: [ { title: "Scout live partita — CrAPP" }, { name: "description", content: "Registra punti, ace, muri ed errori del CRAP Volley in tempo reale durante la partita.", }, { property: "og:title", content: "Scout live partita — CrAPP" }, { property: "og:description", content: "Un tap per giocatore e azione: le stats finiscono subito nel database squadra.", }, ], }), component: Scout, }); const ordineAzioni: AzioneTipo[] = ["attacco", "ace", "muro", "errore"]; function Blocco({ icona, titolo, testo, children, }: { icona: React.ReactNode; titolo: string; testo: string; children?: React.ReactNode; }) { return (
{icona}

{titolo}

{testo}

{children}
); } function Scout() { const { pronto, partita } = usePartitaDiOggi(); const io = useGiocatoreCorrente(); const admin = useIsAdmin(); const sessione = useSessioneScout(partita?.id ?? null); const statoSalvato = useStatoScout(partita?.id ?? null); const apri = useApriSessioneScout(); const chiudi = useChiudiSessioneScout(); const [controllo, setControllo] = useState(false); useHeartbeatScout(partita?.id ?? null, io?.id ?? null, controllo); useEffect(() => { if (!controllo || !partita || !io) return; const rilascia = () => { void chiudi.mutateAsync({ eventoId: partita.id, giocatoreId: io.id }); }; window.addEventListener("pagehide", rilascia); return () => window.removeEventListener("pagehide", rilascia); // eslint-disable-next-line react-hooks/exhaustive-deps }, [controllo, partita?.id, io?.id]); if (io && !admin) { return ( } titolo="Scout riservato" testo="Lo scout live è uno strumento tecnico per allenatori e referenti della squadra." /> ); } if (!pronto || sessione.isLoading || statoSalvato.isLoading) { return ( } titolo="Scout live" testo="Caricamento…" /> ); } if (!partita) { return ( } titolo="Nessuna partita oggi" testo="Lo scout live si attiva automaticamente il giorno della partita." /> ); } const attiva = sessione.data && !sessioneScaduta(sessione.data) ? sessione.data : null; const occupataDaAltri = !!attiva && attiva.giocatore_id !== io?.id; const ripresa = !!statoSalvato.data && statoSalvato.data.azioni.length > 0; if (!controllo) { return ( : } titolo={occupataDaAltri ? "Scout occupato" : "Scout live disponibile"} testo={ occupataDaAltri ? `${attiva!.giocatore_nome} sta già scoutando questa partita. Si libera dopo ${SCADENZA_MINUTI} minuti di inattività.` : ripresa ? `${partita.titolo} · scout già avviato: riprendi da dove è stato lasciato.` : `${partita.titolo} · ${formatData(partita.data)} ore ${partita.ora}` } > {occupataDaAltri ? ( ) : ( )} ); } return ( { setControllo(false); if (io) void chiudi.mutateAsync({ eventoId: partita.id, giocatoreId: io.id }); }} /> ); } function ScoutBoard({ partita, iniziale, onFine, }: { partita: Evento; iniziale: StatoScout | null; onFine: () => void; }) { const navigate = useNavigate(); const base = iniziale ?? statoIniziale( partita.titolo .replace(/CRAP Volley/gi, "") .replace(/\s*vs\s*/i, "") .trim() || "Avversario", partita.titolo.trim().toLowerCase().startsWith("crap"), ); const [avversario, setAvversario] = useState(base.avversario); const [casa, setCasa] = useState(base.casa); const [setChiusi, setSetChiusi] = useState>(base.setChiusi); const [azioni, setAzioni] = useState(base.azioni); const [selezionato, setSelezionato] = useState(null); const salva = useSalvaStatoScout(); const cancella = useCancellaStatoScout(); const { risposte } = usePresenzeEvento(partita.id); const finito = useRef(false); /** In campo solo chi ha confermato la presenza (anche in ritardo). */ const convocati = useMemo(() => { const presenti = giocatori.filter( (g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo", ); return presenti.length > 0 ? presenti : giocatori; }, [risposte]); // Salvataggio condiviso: se chi scouta si disconnette, il prossimo riprende da qui. useEffect(() => { if (finito.current) return; const id = window.setTimeout(() => { void salva.mutateAsync({ eventoId: partita.id, stato: { azioni, setChiusi, avversario, casa }, }); }, 800); return () => window.clearTimeout(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [azioni, setChiusi, avversario, casa, partita.id]); const setCorrente = setChiusi.length + 1; const azioniSet = azioni.filter((a) => a.set === setCorrente); const puntiNoi = azioniSet.filter((a) => azioniMeta[a.tipo].nostro).length; const puntiLoro = azioniSet.length - puntiNoi; const setNostri = setChiusi.filter(([n, l]) => n > l).length; const setLoro = setChiusi.length - setNostri; const totali = useMemo(() => totaliPerGiocatore(azioni), [azioni]); function registra(tipo: AzioneTipo) { const meta = azioniMeta[tipo]; if (meta.richiedeGiocatore && !selezionato) { toast.error("Seleziona prima il giocatore"); return; } setAzioni((prev) => { const nuova: Azione = { id: `${Date.now()}-${prev.length}`, tipo, set: setCorrente, ts: Date.now(), ...(meta.richiedeGiocatore && selezionato ? { giocatoreId: selezionato } : {}), }; return [...prev, nuova]; }); } function annulla() { const ultima = azioni[azioni.length - 1]; if (!ultima || ultima.set !== setCorrente) { toast.error("Puoi annullare solo le azioni del set in corso"); return; } setAzioni((prev) => prev.slice(0, -1)); } function chiudiSet() { if (puntiNoi === 0 && puntiLoro === 0) { toast.error("Set senza azioni"); return; } setSetChiusi((prev) => [...prev, [puntiNoi, puntiLoro]]); toast.success(`Set ${setCorrente} chiuso ${puntiNoi}-${puntiLoro}`); } function finePartita() { const parziali: Array<[number, number]> = puntiNoi + puntiLoro > 0 ? [...setChiusi, [puntiNoi, puntiLoro]] : setChiusi; if (parziali.length === 0) { toast.error("Nessun set da salvare"); return; } const vinti = parziali.filter(([n, l]) => n > l).length; salvaScoutMatch({ id: `s${Date.now()}`, data: new Date().toISOString().slice(0, 10), avversario: avversario.trim() || "Avversario", casa, setNostri: vinti, setLoro: parziali.length - vinti, parziali, mvp: "", azioni, }); toast.success("Partita salvata: ora la squadra può votare l'MVP"); finito.current = true; void cancella.mutateAsync(partita.id); onFine(); navigate({ to: "/squadra" }); } const ultime = [...azioni].slice(-4).reverse(); return ( <>
Scout live · Set {setCorrente}

CRAP Volley

{puntiNoi}

{setNostri}-{setLoro}

{avversario || "Avversario"}

{puntiLoro}

{setChiusi.length > 0 ? (
{setChiusi.map((p, i) => ( {p[0]}-{p[1]} ))}
) : null}

1. Tocca il giocatore

{convocati.map((g) => { const attivo = selezionato === g.id; const t = totali.get(g.id); return ( ); })}

2. Tocca l'azione

{ordineAzioni.map((tipo) => ( ))}
{(["punto_avv", "errore_avv"] as AzioneTipo[]).map((tipo) => ( ))}

Ultime azioni

{ultime.length === 0 ? (

Nessuna azione registrata.

) : (
    {ultime.map((a, i) => { const g = giocatori.find((x) => x.id === a.giocatoreId); return (
  • {g ? `#${g.numero} ${g.nome}` : "Avversario"} {azioniMeta[a.tipo].short}
  • ); })}
)}
setAvversario(e.target.value)} className="mt-1 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm" />
{[true, false].map((v) => ( ))}
); }