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

+7 -5
View File
@@ -3,7 +3,7 @@
**Stato:** implementato
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
`src/routes/eventi.tsx` (creazione/modifica, solo admin), `src/components/crapp/EventoCard.tsx`
`src/routes/eventi.tsx` (creazione/modifica, admin e allenatori), `src/components/crapp/EventoCard.tsx`
(card condivisa)
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
@@ -20,12 +20,14 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
- **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()`
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa()`, non righe
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
true })` — ci sono anche gli allenatori, DD-034 — non righe
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
di evento, i giorni con più eventi si dividono lo spazio.
- **`/eventi`** — "Gestione eventi", riservata agli amministratori (`useIsAdmin()`): crea,
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
(`usePuoGestireEventi()`, [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)):
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
(`convocatiEvento()`, vuoto = tutta la rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). La pagina mostra
solo una griglia mensile (stessa logica di `/calendario`, tramite le funzioni condivise di
+8 -1
View File
@@ -14,6 +14,9 @@ L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al
## Utenti
L'allenatore ha un profilo ridotto (solo Docs con i dati personali essenziali e Opzioni):
la specifica sta in [allenatore.md](allenatore.md).
### Giocatore
Può:
@@ -35,7 +38,8 @@ Può:
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email)
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
- scollegare un account da un profilo, liberando lo slot
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale)
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale),
oppure un allenatore (tipo «Allenatore»: niente numero né ruolo, DD-034)
- disattivare un giocatore che ha lasciato la squadra, e riattivarlo in caso di errore: la
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
agganciati al suo id
@@ -179,6 +183,9 @@ alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
di altri.
Gli allenatori stanno nella stessa tab in un gruppo «Allenatori» a parte, senza documenti né
tesseramento, e non entrano nei conteggi della tab Squadra né nell'export CSI (DD-034).
Per ogni giocatore, nella tab Profili, vengono mostrati.
- Stato del profilo
+18 -8
View File
@@ -39,19 +39,29 @@ giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
Entrambe filtrano solo i giocatori `attivo`: chi ha lasciato la squadra resta nel database
(presenze, voti, pagelle e badge della stagione restano agganciati al suo id) ma sparisce
dagli elenchi correnti.
dagli elenchi correnti. Il filtro è `inRosa()` (`giocatori-squadra.ts`), che esclude anche
gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano.
`useAnagraficaRosa({ conAllenatori: true })` li include, per i compleanni.
## Allenatori in Squadra
Nella tab Rosa gli allenatori attivi compaiono in cima, con foto, nome e la dicitura
«Allenatore» dove i giocatori hanno il ruolo in campo (`ruoloVisibile()`); niente numero,
statistiche né badge, e niente posto nella tab Stats. Chi usa l'app da allenatore vede solo
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). La
specifica completa sta in [allenatore.md](allenatore.md).
## Gestione dati squadra (solo amministratore)
Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)):
| Azione | Hook | Effetto |
| ---------------------- | ------------------------ | ------------------------------------------------------------- |
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database |
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
| Azione | Hook | Effetto |
| ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) |
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
accesso con Google, per corrispondenza email
+6 -6
View File
@@ -14,9 +14,9 @@ import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi";
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreBase } from "@/lib/user-store";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { dataOggi } from "@/lib/scout-live";
const tipoMeta = {
@@ -69,12 +69,12 @@ export function EventoCard({
}) {
const { risposte } = usePresenzeEvento(evento.id);
const salva = useSalvaPresenza();
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta): `useGiocatoreBase`
// legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta, `null` per l'allenatore
// che non risponde, DD-034): `useGiocatoreInCampo` legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
const io = useGiocatoreBase();
const io = useGiocatoreInCampo();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const rosa = squadra.filter(inRosa);
const stato = io ? risposte[io.id] : undefined;
const presentiVeri = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
+3 -3
View File
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar";
import type { Giocatore } from "@/lib/crapp-data";
import { useGiocatoreBase } from "@/lib/user-store";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
@@ -20,8 +20,8 @@ export function Pagelle({
convocati: Giocatore[];
chiuse?: boolean;
}) {
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo();
const { voti: tutti, isPending } = usePagelle();
const vota = useVotaPagella();
const [apertoPer, setApertoPer] = useState<string | null>(null);
+143 -2
View File
@@ -12,8 +12,10 @@ import {
useSalvaProfilo,
type SezioneFile,
} from "@/lib/profili";
import { useSalvaNomeAllenatore, type GiocatoreSquadra } from "@/lib/giocatori-squadra";
import {
completamento,
completamentoAllenatore,
profiloVuoto,
sezioniComplete,
type Profilo,
@@ -244,6 +246,138 @@ export function CampiProfilo({
);
}
/**
* I dati personali dell'allenatore (DD-034): gli stessi campi di `profili_giocatore`, meno
* indirizzo, documento, certificato e foto tessera. Condivisi tra Profilo e dashboard admin.
*/
export function CampiAllenatore({
corrente,
aggiorna,
}: {
corrente: Profilo;
aggiorna: (patch: Partial<Profilo>) => void;
}) {
return (
<>
<div className="grid grid-cols-2 gap-3">
<Campo label="Data di nascita">
<input
type="date"
value={corrente.dataNascita ?? ""}
onChange={(e) => aggiorna({ dataNascita: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Luogo di nascita">
<input
value={corrente.luogoNascita ?? ""}
maxLength={80}
onChange={(e) => aggiorna({ luogoNascita: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<div className="grid grid-cols-2 gap-3">
<Campo label="Telefono">
<input
type="tel"
value={corrente.telefono ?? ""}
maxLength={20}
onChange={(e) => aggiorna({ telefono: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Email">
<input
type="email"
value={corrente.email ?? ""}
maxLength={120}
onChange={(e) => aggiorna({ email: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
</>
);
}
/**
* Tab Docs dell'allenatore: nome e cognome (sul proprio slot, M21) più i dati personali
* ridotti. Un solo pulsante salva entrambi.
*/
export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
const { profili } = useProfili();
const salvaProfilo = useSalvaProfilo();
const salvaNome = useSalvaNomeAllenatore();
const [bozza, setBozza] = useState<Profilo | null>(null);
const [nome, setNome] = useState<{ nome: string; cognome: string } | null>(null);
const corrente = bozza ?? profili[g.id] ?? profiloVuoto(g.id);
const nomeCorrente = nome ?? { nome: g.nome, cognome: g.cognome };
const sporco = bozza !== null || nome !== null;
const inCorso = salvaProfilo.isPending || salvaNome.isPending;
const perc = completamentoAllenatore(corrente);
async function salva() {
try {
if (nome) await salvaNome.mutateAsync({ giocatoreId: g.id, ...nome });
if (bozza) await salvaProfilo.mutateAsync(bozza);
setNome(null);
setBozza(null);
toast.success("Profilo aggiornato");
} catch (errore) {
toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito");
}
}
return (
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
<div className="flex items-center justify-between gap-2">
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-secondary">
<div
className="h-full rounded-full bg-accent-grad transition-all"
style={{ width: `${perc}%` }}
/>
</div>
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
{perc}%
</span>
</div>
<Intestazione titolo="Dati personali" completa={perc === 100} />
<div className="grid grid-cols-2 gap-3">
<Campo label="Nome">
<input
value={nomeCorrente.nome}
maxLength={40}
onChange={(e) => setNome({ ...nomeCorrente, nome: e.target.value })}
className={classiInput}
/>
</Campo>
<Campo label="Cognome">
<input
value={nomeCorrente.cognome}
maxLength={40}
onChange={(e) => setNome({ ...nomeCorrente, cognome: e.target.value })}
className={classiInput}
/>
</Campo>
</div>
<CampiAllenatore corrente={corrente} aggiorna={(p) => setBozza({ ...corrente, ...p })} />
<button
type="button"
onClick={() => void salva()}
disabled={!sporco || inCorso}
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
>
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{sporco ? "Salva" : "Salvato"}
</button>
</div>
);
}
/**
* Dati amministrativi del giocatore: quello che la dashboard amministratore poi legge.
* Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente.
@@ -380,13 +514,18 @@ export function ProfiloAmministrativo({
*/
export function CompletaProfilo({
giocatoreId,
allenatore = false,
indice = 0,
}: {
giocatoreId: string;
/** L'allenatore ha solo i dati personali ridotti (DD-034). */
allenatore?: boolean;
indice?: number;
}) {
const { profili, isPending } = useProfili();
const perc = completamento(profili[giocatoreId]);
const perc = allenatore
? completamentoAllenatore(profili[giocatoreId])
: completamento(profili[giocatoreId]);
if (isPending || perc === 100) return null;
return (
@@ -409,7 +548,9 @@ export function CompletaProfilo({
/>
</div>
<p className="mt-2 text-[13px] text-muted-foreground">
Documento, certificato medico e foto tessera servono per il tesseramento CSI.
{allenatore
? "Data e luogo di nascita, telefono ed email."
: "Documento, certificato medico e foto tessera servono per il tesseramento CSI."}
</p>
</Link>
</Reveal>
+3 -3
View File
@@ -3,12 +3,12 @@ import { formatData } from "@/lib/crapp-data";
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
import { useTurniPalloni } from "@/lib/palloni";
import { useEventi } from "@/lib/eventi";
import { useGiocatoreBase } from "@/lib/user-store";
import { useGiocatoreInCampo } from "@/lib/user-store";
/** Avvisi per chi è di turno: prendere i palloni oggi, o riportarli oggi. */
export function PromemoriaPalloni() {
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. Montato in Home.
const io = useGiocatoreBase();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). Montato in Home.
const io = useGiocatoreInCampo();
const { turni } = useTurniPalloni();
const { eventi } = useEventi();
if (!io) return null;
+8 -6
View File
@@ -8,9 +8,9 @@ import { Barra } from "@/components/motion/Barra";
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase } from "@/lib/user-store";
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import { usePuoGestireEventi } from "@/lib/ruoli";
import { dataOggi } from "@/lib/scout-live";
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
@@ -20,8 +20,10 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
const salva = useSalvaPresenza();
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
// niente statistiche di squadra.
const io = useGiocatoreBase();
const admin = useIsAdmin();
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 rosa = useAnagraficaRosa();
const [sollecito, setSollecito] = useState(false);
const passato = data < dataOggi();
@@ -37,7 +39,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
const res = await fetch("/api/public/sollecita-presenze", {
method: "POST",
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
body: JSON.stringify({ eventoId, da: io?.nome }),
body: JSON.stringify({ eventoId, da: mittente?.nome }),
});
if (!res.ok) throw new Error();
const dati = (await res.json()) as { inviate: number; destinatari: number };
@@ -115,7 +117,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
</div>
) : null}
{admin ? (
{puoSollecitare ? (
<button
type="button"
onClick={sollecita}
+3 -3
View File
@@ -4,7 +4,7 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli";
import {
@@ -27,8 +27,8 @@ export function SondaggioCacche({
dataEvento: string;
oraEvento: string;
}) {
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo();
const { righe } = useCacche();
const salva = useSalvaCacche();
const { righe: squadra } = useGiocatoriSquadra();
+2 -2
View File
@@ -4,7 +4,7 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/crapp/Avatar";
import { intestazioniAutenticate } from "@/lib/auth";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
import { useIsAdmin } from "@/lib/ruoli";
import { useGiocatoreBase } from "@/lib/user-store";
@@ -18,7 +18,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
const io = useGiocatoreBase();
const admin = useIsAdmin();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const rosa = squadra.filter(inRosa);
const id = turni[eventoId];
const proposto = !salvati[eventoId];
+5 -5
View File
@@ -2,8 +2,8 @@ import { useState } from "react";
import { Crown, Vote } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreInCampo } from "@/lib/user-store";
import { usePresenzeEvento } from "@/lib/presenze";
import type { Evento } from "@/lib/eventi";
import {
@@ -25,14 +25,14 @@ import {
*/
export function VotazioneMvp({ evento }: { evento: Evento }) {
const matchId = evento.id;
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo();
const voti = useVotiMvp();
const vota = useVotaMvp();
const { righe: squadra } = useGiocatoriSquadra();
const { risposte } = usePresenzeEvento(evento.id);
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
const rosa = squadra.filter((g) => g.attivo && presente(g.id));
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
const [aperto, setAperto] = useState(false);
const tutti: VotoMvp[] = voti.data ?? [];
+5 -5
View File
@@ -2,8 +2,8 @@ import { useState } from "react";
import { Check, Crown, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreInCampo } from "@/lib/user-store";
import {
categorieSocial,
conteggioCategoria,
@@ -15,12 +15,12 @@ import {
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */
export function VotoSocial({ matchId }: { matchId: string }) {
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
const io = useGiocatoreBase();
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreInCampo();
const voti = useVotiSocial();
const vota = useVotaSocial();
const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo);
const rosa = squadra.filter(inRosa);
const [aperta, setAperta] = useState<string | null>(null);
const tutti = voti.data ?? [];
+23 -10
View File
@@ -7,9 +7,9 @@
* è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non
* può fare da credenziale.
*
* Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli
* amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della
* sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
* Le route che mandano notifiche partono da un pulsante riservato agli amministratori, e il
* sollecito presenze anche agli allenatori (DD-034): `richiediAdmin` e `richiediGestoreEventi`
* verificano il token della sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
* proseguire, altrimenti la `Response` di rifiuto già pronta.
*/
@@ -24,10 +24,14 @@ function tokenDaRichiesta(request: Request): string | null {
}
/**
* Lascia passare solo un amministratore autenticato. `401` se manca o non vale il token,
* `403` se il token è buono ma l'utente non è admin.
* Lascia passare solo un utente autenticato con almeno uno dei `ruoli`. `401` se manca o non
* vale il token, `403` se il token è buono ma il ruolo no.
*/
export async function richiediAdmin(request: Request): Promise<Response | null> {
async function richiediRuolo(
request: Request,
ruoli: Array<"admin" | "allenatore">,
rifiuto: string,
): Promise<Response | null> {
const token = tokenDaRichiesta(request);
if (!token) return new Response("Autenticazione richiesta", { status: 401 });
@@ -37,13 +41,22 @@ export async function richiediAdmin(request: Request): Promise<Response | null>
if (error || !utente?.user) return new Response("Sessione non valida", { status: 401 });
// Stessa fonte di `src/lib/ruoli.ts`: i permessi stanno solo in `user_roles` (DD-011).
const { data: ruolo } = await supabaseAdmin
const { data: righe } = await supabaseAdmin
.from("user_roles")
.select("role")
.eq("user_id", utente.user.id)
.eq("role", "admin")
.maybeSingle();
.in("role", ruoli);
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
if (!righe?.length) return new Response(rifiuto, { status: 403 });
return null;
}
/** Solo un amministratore: promemoria palloni, messaggi, sondaggio, notifiche attive. */
export function richiediAdmin(request: Request): Promise<Response | null> {
return richiediRuolo(request, ["admin"], "Riservato agli amministratori");
}
/** Admin o allenatore: chi gestisce gli eventi può anche sollecitare le presenze (DD-034). */
export function richiediGestoreEventi(request: Request): Promise<Response | null> {
return richiediRuolo(request, ["admin", "allenatore"], "Riservato a admin e allenatori");
}
+82 -22
View File
@@ -7,10 +7,14 @@ import { dividiNome, giocatori } from "./crapp-data";
* truth per il collegamento account ↔ giocatore; `crapp-data.ts` resta il fallback finché
* la migrazione non è completa (DD-016 regola 1).
*/
/** Chi gioca e chi allena (DD-034): l'allenatore ha uno slot ma non è nella rosa di gioco. */
export type TipoMembro = "giocatore" | "allenatore";
export type GiocatoreSquadra = {
id: string;
nome: string;
cognome: string;
/** 0 per l'allenatore, che non ha numero di maglia (a database è NULL). */
numero: number;
ruolo: string;
authUserId: string | null;
@@ -20,13 +24,14 @@ export type GiocatoreSquadra = {
dataTessera: string | null;
/** Pubblica a tutta la rosa (M18): sincronizzata da `profili_giocatore.data_nascita`. */
nascita: string | null;
tipo: TipoMembro;
};
export type RigaGiocatoreSquadra = {
id: string;
nome: string;
cognome: string;
numero: number;
numero: number | null;
ruolo: string;
auth_user_id: string | null;
attivo: boolean;
@@ -34,6 +39,7 @@ export type RigaGiocatoreSquadra = {
numero_tessera: string | null;
data_tessera: string | null;
nascita: string | null;
tipo: TipoMembro;
};
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
@@ -54,6 +60,7 @@ export function rosaFallback(): GiocatoreSquadra[] {
numeroTessera: null,
dataTessera: null,
nascita: g.nascita,
tipo: "giocatore" as const,
}));
}
@@ -61,6 +68,24 @@ export function nomeCompleto(g: GiocatoreSquadra): string {
return `${g.nome} ${g.cognome}`.trim();
}
export function isAllenatore(g: Pick<GiocatoreSquadra, "tipo"> | null | undefined): boolean {
return g?.tipo === "allenatore";
}
/**
* Rosa di gioco: giocatori attivi, senza allenatori (DD-034). È il filtro di presenze,
* convocati, voti, palloni, classifiche e badge; gli allenatori compaiono solo dove la
* specifica lo dice (Rosa di Squadra, compleanni, promemoria evento).
*/
export function inRosa(g: Pick<GiocatoreSquadra, "attivo" | "tipo">): boolean {
return g.attivo && g.tipo === "giocatore";
}
/** Dove un giocatore ha il ruolo in campo, l'allenatore ha scritto «Allenatore». */
export function ruoloVisibile(g: Pick<GiocatoreSquadra, "ruolo" | "tipo">): string {
return g.tipo === "allenatore" ? "Allenatore" : g.ruolo;
}
/** Lo slot già collegato a questo account, se esiste. */
export function slotDi(
righe: GiocatoreSquadra[],
@@ -81,7 +106,7 @@ export function slotPerEmail(
}
export const COLONNE_SQUADRA =
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera, nascita";
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera, nascita, tipo";
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
@@ -89,7 +114,7 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
id: r.id,
nome: r.nome,
cognome: r.cognome,
numero: r.numero,
numero: r.numero ?? 0,
ruolo: r.ruolo,
authUserId: r.auth_user_id,
attivo: r.attivo,
@@ -97,6 +122,7 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
numeroTessera: r.numero_tessera,
dataTessera: r.data_tessera,
nascita: r.nascita,
tipo: r.tipo === "allenatore" ? "allenatore" : "giocatore",
};
}
@@ -120,7 +146,10 @@ export function useGiocatoriSquadra() {
/** Dati squadra: li gestisce solo un amministratore (DD-017). L'email è quella usata per
* il collegamento automatico al primo accesso (DD-018), non il dato personale del profilo. */
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo" | "email">;
export type DatiSquadra = Pick<
GiocatoreSquadra,
"nome" | "cognome" | "numero" | "ruolo" | "email" | "tipo"
>;
/**
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
@@ -130,6 +159,11 @@ export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" |
export function validaDatiSquadra(dati: DatiSquadra): string | null {
if (!dati.nome.trim()) return "Il nome non può essere vuoto.";
if (!dati.cognome.trim()) return "Il cognome non può essere vuoto.";
// L'allenatore non ha numero di maglia né ruolo in campo (DD-034).
if (dati.tipo === "allenatore") {
if (dati.email?.trim() && !dati.email.includes("@")) return "L'email non è valida.";
return null;
}
if (!Number.isInteger(dati.numero) || dati.numero <= 0)
return "Il numero di maglia deve essere maggiore di zero.";
if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto.";
@@ -152,7 +186,19 @@ export function numeroGiaUsato(
giocatoreId: string,
numero: number,
): boolean {
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
return righe.some((g) => g.id !== giocatoreId && inRosa(g) && g.numero === numero);
}
/** Riga da scrivere: all'allenatore numero NULL e ruolo vuoto, il tipo non cambia qui. */
function rigaDatiSquadra(dati: DatiSquadra) {
const allenatore = dati.tipo === "allenatore";
return {
nome: dati.nome.trim(),
cognome: dati.cognome.trim(),
numero: allenatore ? null : dati.numero,
ruolo: allenatore ? "" : dati.ruolo.trim(),
email: dati.email?.trim() || null,
};
}
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
@@ -160,19 +206,13 @@ export function useSalvaDatiSquadra() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => {
const dati = {
nome: input.dati.nome.trim(),
cognome: input.dati.cognome.trim(),
numero: input.dati.numero,
ruolo: input.dati.ruolo.trim(),
email: input.dati.email?.trim() || null,
};
const riga = rigaDatiSquadra(input.dati);
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update(dati)
.update(riga)
.eq("id", input.giocatoreId);
if (error) throw error;
return { giocatoreId: input.giocatoreId, dati };
return { giocatoreId: input.giocatoreId, dati: { ...riga, numero: riga.numero ?? 0 } };
},
onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
@@ -225,19 +265,13 @@ export function useAggiungiGiocatore() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { id: string; dati: DatiSquadra }): Promise<GiocatoreSquadra> => {
const riga = {
id: input.id,
nome: input.dati.nome.trim(),
cognome: input.dati.cognome.trim(),
numero: input.dati.numero,
ruolo: input.dati.ruolo.trim(),
email: input.dati.email?.trim() || null,
};
const riga = { id: input.id, tipo: input.dati.tipo, ...rigaDatiSquadra(input.dati) };
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga);
if (error) throw error;
// Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL.
return {
...riga,
numero: riga.numero ?? 0,
authUserId: null,
attivo: true,
numeroTessera: null,
@@ -257,6 +291,32 @@ export function useAggiungiGiocatore() {
});
}
/**
* Nome e cognome scritti dall'allenatore sul proprio slot (DD-034): per un giocatore sono
* dati squadra dell'admin, e il trigger di M21 rifiuta la stessa scrittura.
*/
export function useSalvaNomeAllenatore() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: { giocatoreId: string; nome: string; cognome: string }) => {
const dati = { nome: input.nome.trim(), cognome: input.cognome.trim() };
if (!dati.nome) throw new Error("Il nome non può essere vuoto.");
if (!dati.cognome) throw new Error("Il cognome non può essere vuoto.");
const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra")
.update(dati)
.eq("id", input.giocatoreId);
if (error) throw error;
return { giocatoreId: input.giocatoreId, dati };
},
onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
);
},
});
}
/**
* Attiva o disattiva un giocatore (es. ha lasciato la squadra): non elimina la riga, così
* presenze, voti, pagelle e badge della stagione restano agganciati al suo id.
+2 -2
View File
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { completaTurni } from "./palloni-core";
import { useEventi } from "./eventi";
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
export const TURNI_KEY = ["turni-palloni"] as const;
@@ -29,7 +29,7 @@ export function useTurniPalloni() {
const { eventi } = useEventi();
const { righe: squadra } = useGiocatoriSquadra();
const salvati = query.data ?? {};
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) }));
return { ...query, salvati, turni: completaTurni(salvati, eventi, rosa) };
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { useEventi } from "./eventi";
import { useRispostePresenze } from "./presenze";
import { useGiocatoriSquadra } from "./giocatori-squadra";
import { inRosa, useGiocatoriSquadra } from "./giocatori-squadra";
import {
presenzeUltimoMese,
presenzeUltimoMeseTutti,
@@ -30,7 +30,7 @@ export function usePresenzeUltimoMeseTutti(): Record<string, StatistichePresenza
const { righe: squadra } = useGiocatoriSquadra();
return useMemo(() => {
const idRosa = squadra.filter((g) => g.attivo).map((g) => g.id);
const idRosa = squadra.filter(inRosa).map((g) => g.id);
return presenzeUltimoMeseTutti(eventi, presenze, idRosa);
}, [eventi, presenze, squadra]);
}
+5 -3
View File
@@ -2,6 +2,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 { inRosa, type TipoMembro } from "./giocatori-squadra";
import { aggiornaSerie } from "./serie";
import { dataOggi } from "./scout-live";
@@ -71,17 +72,18 @@ export function totaliEventiGiocatore(
}
/**
* Chi va sollecitato per un evento: i giocatori attivi che non hanno ancora risposto, più
* 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
* palloni: la route `/api/public/sollecita-presenze` la chiama con i dati che ha già letto.
*/
export function destinatariSollecito(
squadra: Array<{ id: string; attivo: boolean }>,
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
risposte: Array<{ giocatore_id: string; stato: string }>,
): string[] {
const stati = new Map(risposte.map((r) => [r.giocatore_id, r.stato]));
// L'allenatore non risponde alle presenze, quindi non va sollecitato (DD-034).
return squadra
.filter((g) => g.attivo)
.filter(inRosa)
.filter((g) => {
const stato = stati.get(g.id);
return stato === undefined || stato === "forse";
+12
View File
@@ -144,6 +144,18 @@ export function completamento(p: Profilo | null | undefined): number {
);
}
/**
* L'allenatore compila solo i dati personali ridotti (DD-034): niente indirizzo, documento,
* certificato né foto tessera, che servono al tesseramento dei giocatori.
*/
export const CAMPI_ALLENATORE = ["dataNascita", "luogoNascita", "telefono", "email"] as const;
/** Percentuale dei campi dell'allenatore compilati: 100 quando ci sono tutti. */
export function completamentoAllenatore(p: Profilo | null | undefined): number {
const pieni = CAMPI_ALLENATORE.filter((c) => !!p?.[c]?.trim()).length;
return Math.round((pieni / CAMPI_ALLENATORE.length) * 100);
}
export type StatoScadenza = "mancante" | "scaduto" | "valido";
/** Un certificato scaduto blocca il tesseramento: per l'admin non vale come presente. */
+35 -34
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import type { Giocatore } from "./crapp-data";
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti";
import { mediePagelle, usePagelle } from "./pagelle";
import { statisticheCacche, useCacche } from "./cacche";
@@ -27,18 +27,21 @@ function iniziali(nome: string, cognome: string): string {
/**
* Solo anagrafica (id, nome, ruolo, numero, data di nascita) dei giocatori attivi — es.
* per i compleanni nel Calendario o le liste presenze. A differenza di `useRosa` non
* legge MVP, pagelle, cacche, palloni né infortuni: evita di montare quei cinque hook e
* il relativo `useMemo` solo per l'anagrafica.
* per le liste presenze o lo scout. A differenza di `useRosa` non legge MVP, pagelle,
* cacche, palloni né infortuni: evita di montare quei cinque hook e il relativo `useMemo`
* solo per l'anagrafica. Con `conAllenatori` include anche gli allenatori attivi, per chi
* elenca i membri della squadra e non solo chi gioca (compleanni in Calendario, DD-034).
*/
export function useAnagraficaRosa(): Array<
export function useAnagraficaRosa({
conAllenatori = false,
}: { conAllenatori?: boolean } = {}): Array<
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita">
> {
const { righe: squadra } = useGiocatoriSquadra();
return useMemo(
() =>
squadra
.filter((g) => g.attivo)
.filter((g) => (conAllenatori ? g.attivo : inRosa(g)))
.map((g) => ({
id: g.id,
nome: nomeCompleto(g),
@@ -46,7 +49,7 @@ export function useAnagraficaRosa(): Array<
numero: g.numero,
nascita: g.nascita ?? "",
})),
[squadra],
[squadra, conAllenatori],
);
}
@@ -54,7 +57,7 @@ export function useAnagraficaRosa(): Array<
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
* palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra`
* (DD-015): solo i giocatori attivi, gli altri restano nel database ma spariscono
* dagli elenchi correnti. Usa solo cache già in memoria: nessuna query aggiuntiva
* dagli elenchi correnti. Gli allenatori non ci sono (DD-034): non hanno statistiche. Usa solo cache già in memoria: nessuna query aggiuntiva
* rispetto a quelle che l'app fa comunque.
*/
export function useRosa(): Giocatore[] {
@@ -78,32 +81,30 @@ export function useRosa(): Giocatore[] {
const palloni = conteggioTurni(turniSalvati, eventi);
const mvpVinti = mvpVintiPerGiocatore(votiMvp ?? []);
return squadra
.filter((g) => g.attivo)
.map((g) => ({
id: g.id,
nome: nomeCompleto(g),
numero: g.numero,
ruolo: g.ruolo,
nascita: g.nascita ?? "",
iniziali: iniziali(g.nome, g.cognome),
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
totaliEventi: totaliEventiGiocatore(g.id, eventi),
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
serieConferme: serieConferme(g.id, eventi, tempi),
mvp: mvpVinti[g.id] ?? 0,
mediaVoto: medie[g.id]?.media ?? 0,
votiPagella: medie[g.id]?.voti ?? 0,
palloni: palloni[g.id] ?? 0,
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
cacche: statCacche[g.id]?.giornateTop ?? 0,
cacchePartita: statCacche[g.id]?.media ?? 0,
infortuni: infortuni[g.id] ?? 0,
ritardi: ritardi[g.id] ?? 0,
}));
return squadra.filter(inRosa).map((g) => ({
id: g.id,
nome: nomeCompleto(g),
numero: g.numero,
ruolo: g.ruolo,
nascita: g.nascita ?? "",
iniziali: iniziali(g.nome, g.cognome),
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
totaliEventi: totaliEventiGiocatore(g.id, eventi),
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
serieConferme: serieConferme(g.id, eventi, tempi),
mvp: mvpVinti[g.id] ?? 0,
mediaVoto: medie[g.id]?.media ?? 0,
votiPagella: medie[g.id]?.voti ?? 0,
palloni: palloni[g.id] ?? 0,
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
cacche: statCacche[g.id]?.giornateTop ?? 0,
cacchePartita: statCacche[g.id]?.media ?? 0,
infortuni: infortuni[g.id] ?? 0,
ritardi: ritardi[g.id] ?? 0,
}));
}, [
squadra,
votiMvp,
+26 -10
View File
@@ -2,33 +2,49 @@ import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { useSessione } from "./auth";
export const RUOLI_KEY = ["ruolo-admin"] as const;
export const RUOLI_KEY = ["ruoli-utente"] as const;
export type Ruolo = "admin" | "allenatore";
/**
* Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011).
* Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare.
* Permessi: unica fonte è `user_roles` nel database (DD-011). Nessuna lista di nomi,
* altrimenti basterebbe scegliere il nome giusto per amministrare. Il ruolo `allenatore`
* lo scrive un trigger quando si collega uno slot di tipo allenatore (M21, DD-034).
*/
/** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */
async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> {
async function fetchRuoli(utenteId: string | null): Promise<Ruolo[] | null> {
if (!utenteId) return null;
const { data, error } = await supabase
.from("user_roles")
.select("role")
.eq("user_id", utenteId)
.eq("role", "admin")
.maybeSingle();
.in("role", ["admin", "allenatore"]);
if (error) throw error;
return !!data;
return (data ?? []).map((r) => r.role as Ruolo);
}
export function useIsAdmin(): boolean {
function useRuoli(): Ruolo[] {
const { utenteId } = useSessione();
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
const query = useQuery({
queryKey: [...RUOLI_KEY, utenteId],
queryFn: () => fetchRuoloAdmin(utenteId),
queryFn: () => fetchRuoli(utenteId),
staleTime: 30 * 60_000,
});
return query.data === true;
return query.data ?? [];
}
export function useIsAdmin(): boolean {
return useRuoli().includes("admin");
}
export function useIsAllenatore(): boolean {
return useRuoli().includes("allenatore");
}
/** Chi crea e modifica gli eventi e sollecita le presenze: admin o allenatore (DD-034). */
export function usePuoGestireEventi(): boolean {
const ruoli = useRuoli();
return ruoli.includes("admin") || ruoli.includes("allenatore");
}
+18 -1
View File
@@ -1,6 +1,6 @@
import { useSyncExternalStore } from "react";
import type { Giocatore } from "./crapp-data";
import { useGiocatoriSquadra, type GiocatoreSquadra } from "./giocatori-squadra";
import { isAllenatore, useGiocatoriSquadra, type GiocatoreSquadra } from "./giocatori-squadra";
import { useIo } from "./rosa";
const KEY = "crapp-user-v1";
@@ -56,6 +56,23 @@ export function useGiocatoreBase(): GiocatoreSquadra | null {
return id ? (righe.find((x) => x.id === id) ?? null) : null;
}
/**
* Chi usa l'app è un allenatore (DD-034): decide cosa si vede (niente badge, cacche,
* stagione, obiettivi). I permessi veri stanno in `user_roles` (`usePuoGestireEventi`).
*/
export function useSonoAllenatore(): boolean {
return isAllenatore(useGiocatoreBase());
}
/**
* Il proprio slot solo se è di un giocatore: per rispondere alle presenze, votare,
* dichiarare le cacche o portare i palloni. Per l'allenatore è `null`.
*/
export function useGiocatoreInCampo(): GiocatoreSquadra | null {
const g = useGiocatoreBase();
return g && !isAllenatore(g) ? g : null;
}
/** Giocatore corrente con statistiche calcolate da dati reali (presenze, MVP, badge, …). */
export function useGiocatoreCorrente(): Giocatore | null {
return useIo();
+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 },
]),
]}
/>
</>
+30
View File
@@ -66,8 +66,19 @@ if (!locale) {
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
});
// Il ruolo allenatore lo scrive il trigger di M21 al collegamento dello slot; qui basta
// la riga in `user_roles`, che è ciò che la route legge (DD-034).
const emailAllenatore = `test-route-allenatore-${Date.now()}@example.test`;
const idAllenatore = await creaUtente(emailAllenatore);
await fetch(`${SUPABASE}/rest/v1/user_roles`, {
method: "POST",
headers: { ...authAdmin, "content-type": "application/json" },
body: JSON.stringify({ user_id: idAllenatore, role: "allenatore" }),
});
const tokenGiocatore = await accedi(emailGiocatore);
const tokenAdmin = await accedi(emailAdmin);
const tokenAllenatore = await accedi(emailAllenatore);
const server = await avviaServer();
console.log(`permessi route su ${server.baseUrl} (database ${SUPABASE})`);
@@ -108,6 +119,20 @@ if (!locale) {
}
});
await prova(
"l'allenatore sollecita le presenze ma non manda palloni né sondaggio",
async () => {
const sollecito = await chiama("/api/public/sollecita-presenze", {
authorization: `Bearer ${tokenAllenatore}`,
});
assert.equal(sollecito.status, 404, "sollecito: superato l'accesso, evento inesistente");
for (const percorso of ["/api/public/apri-sondaggio", "/api/public/promemoria-palloni"]) {
const res = await chiama(percorso, { authorization: `Bearer ${tokenAllenatore}` });
assert.equal(res.status, 403, `${percorso}: restano all'admin`);
}
},
);
/** Messaggio libero: stesso controllo d'accesso, corpo diverso. */
const chiamaMessaggio = (intestazioni: Record<string, string> = {}) =>
fetch(`${server.baseUrl}/api/public/notifica-personalizzata`, {
@@ -125,6 +150,11 @@ if (!locale) {
assert.equal(res.status, 403);
});
await prova("l'allenatore non manda messaggi personalizzati", async () => {
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAllenatore}` });
assert.equal(res.status, 403);
});
await prova("un amministratore manda il messaggio (nessun dispositivo iscritto)", async () => {
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAdmin}` });
assert.equal(res.status, 200);
+64 -1
View File
@@ -1,10 +1,14 @@
/** Check dell'anagrafica squadra: `bun test/unit/giocatori-squadra.test.ts`. */
import assert from "node:assert/strict";
import {
daRigaSquadra,
inRosa,
isAllenatore,
nomeCompleto,
numeroGiaUsato,
prossimoIdGiocatore,
rosaFallback,
ruoloVisibile,
slotDi,
slotPerEmail,
validaDatiSquadra,
@@ -24,6 +28,7 @@ const riga = (parziale: Partial<GiocatoreSquadra> = {}): GiocatoreSquadra => ({
numeroTessera: null,
dataTessera: null,
nascita: null,
tipo: "giocatore",
...parziale,
});
@@ -92,7 +97,14 @@ assert.equal(
assert.equal(slotPerEmail(rosaEmail, ""), null, "stringa vuota trattata come nessuna email");
// --- validaDatiSquadra ----------------------------------------------------------------
const datiOk = { nome: "Mario", cognome: "Rossi", numero: 7, ruolo: "schiacciatore", email: null };
const datiOk = {
nome: "Mario",
cognome: "Rossi",
numero: 7,
ruolo: "schiacciatore",
email: null,
tipo: "giocatore" as const,
};
assert.equal(validaDatiSquadra(datiOk), null, "dati validi: nessun errore");
assert.match(validaDatiSquadra({ ...datiOk, nome: "" })!, /nome/i);
assert.match(
@@ -155,5 +167,56 @@ assert.equal(
"conta solo il 7 attivo di g1, non serve escludere g3",
);
assert.equal(numeroGiaUsato(rosaNumeri, "g4", 11), false, "un numero libero non risulta usato");
assert.equal(
numeroGiaUsato([riga({ id: "g5", numero: 0, tipo: "allenatore" })], "g4", 0),
false,
"l'allenatore non occupa numeri di maglia",
);
// --- allenatore (DD-034) ---------------------------------------------------------
const allenatore = riga({ id: "g20", numero: 0, ruolo: "", tipo: "allenatore" });
assert.equal(isAllenatore(allenatore), true);
assert.equal(isAllenatore(riga()), false);
assert.equal(isAllenatore(null), false, "senza slot non si è allenatori");
assert.equal(inRosa(riga()), true, "giocatore attivo: in rosa");
assert.equal(inRosa(riga({ attivo: false })), false, "giocatore disattivato: fuori");
assert.equal(inRosa(allenatore), false, "l'allenatore non è nella rosa di gioco");
assert.equal(ruoloVisibile(allenatore), "Allenatore", "dove c'è il ruolo in campo");
assert.equal(ruoloVisibile(riga({ ruolo: "Libero" })), "Libero");
assert.equal(
validaDatiSquadra({ ...datiOk, tipo: "allenatore", numero: 0, ruolo: "" }),
null,
"l'allenatore non ha numero né ruolo",
);
assert.match(
validaDatiSquadra({ ...datiOk, tipo: "allenatore", nome: " " })!,
/nome/i,
"il nome resta obbligatorio anche per l'allenatore",
);
assert.match(
validaDatiSquadra({ ...datiOk, tipo: "allenatore", email: "senza-chiocciola" })!,
/email/i,
);
const rigaDb = {
id: "g20",
nome: "Carla",
cognome: "Neri",
numero: null,
ruolo: "",
auth_user_id: null,
attivo: true,
email: "carla@gmail.com",
numero_tessera: null,
data_tessera: null,
nascita: null,
tipo: "allenatore" as const,
};
assert.equal(daRigaSquadra(rigaDb).numero, 0, "numero NULL a database diventa 0");
assert.equal(daRigaSquadra(rigaDb).tipo, "allenatore");
assert.equal(daRigaSquadra({ ...rigaDb, numero: 7, tipo: "giocatore" }).numero, 7);
assert.ok(
rosaFallback().every((g) => g.tipo === "giocatore"),
"il fallback contiene solo giocatori",
);
console.log("giocatori-squadra: ok");
+6 -5
View File
@@ -120,11 +120,12 @@ assert.equal(totaliEventiGiocatore("g1", conRistretti, OGGI), 5, "chi non è con
// --- destinatari del sollecito: chi non ha risposto, più i "forse" ------------
const squadra = [
{ id: "g1", attivo: true },
{ id: "g2", attivo: true },
{ id: "g3", attivo: true },
{ id: "g4", attivo: true },
{ id: "g5", attivo: false }, // uscito dalla squadra: non lo si disturba più
{ id: "g1", attivo: true, tipo: "giocatore" as const },
{ id: "g2", attivo: true, tipo: "giocatore" as const },
{ id: "g3", attivo: true, tipo: "giocatore" as const },
{ id: "g4", attivo: true, tipo: "giocatore" as const },
{ id: "g5", attivo: false, tipo: "giocatore" as const }, // uscito: non lo si disturba più
{ id: "g6", attivo: true, tipo: "allenatore" as const }, // non risponde: mai sollecitato (DD-034)
];
const risposte = [
+31 -1
View File
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
aRigaProfilo,
completamento,
completamentoAllenatore,
csvTesseramento,
daRigaProfilo,
sezioniComplete,
@@ -115,6 +116,7 @@ const squadra: GiocatoreSquadra[] = [
numeroTessera: null,
dataTessera: null,
nascita: null,
tipo: "giocatore",
},
{
id: "g2",
@@ -128,6 +130,7 @@ const squadra: GiocatoreSquadra[] = [
numeroTessera: null,
dataTessera: null,
nascita: null,
tipo: "giocatore",
},
];
const csv = csvTesseramento(squadra, { g1: completo });
@@ -155,7 +158,14 @@ assert.equal(slotDi(squadra, "u2")?.id, "g2");
assert.equal(slotDi(squadra, "sconosciuto"), null);
// --- dati squadra modificabili dall'admin (DD-017) ---------------------------
const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda", email: null };
const datiOk = {
nome: "Ivan",
cognome: "Cacciari",
numero: 23,
ruolo: "Banda",
email: null,
tipo: "giocatore" as const,
};
assert.equal(validaDatiSquadra(datiOk), null);
assert.match(validaDatiSquadra({ ...datiOk, nome: " " }) ?? "", /nome/i);
assert.match(validaDatiSquadra({ ...datiOk, cognome: "" }) ?? "", /cognome/i);
@@ -184,4 +194,24 @@ assert.ok(
"il fallback non può collegare account",
);
// --- completamento dell'allenatore (DD-034) --------------------------------------
assert.equal(completamentoAllenatore(null), 0, "senza profilo: 0%");
assert.equal(
completamentoAllenatore({ ...vuoto, dataNascita: "1980-01-01", telefono: "333" }),
50,
"due campi su quattro",
);
assert.equal(
completamentoAllenatore({
...vuoto,
dataNascita: "1980-01-01",
luogoNascita: "Bologna",
telefono: "333",
email: "a@b.it",
}),
100,
"bastano i quattro dati personali: niente indirizzo, documento, certificato, foto",
);
assert.equal(completamentoAllenatore({ ...vuoto, telefono: " " }), 0, "spazi soli non contano");
console.log("profili-core: ok");