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 **Stato:** implementato
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts` **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), (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) (card condivisa)
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts` **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, - **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()` 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 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. di evento, i giorni con più eventi si dividono lo spazio.
- **`/eventi`** — "Gestione eventi", riservata agli amministratori (`useIsAdmin()`): crea, - **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la (`usePuoGestireEventi()`, [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)):
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato` 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 (`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). La pagina mostra 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 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 ## 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 ### Giocatore
Può: Può:
@@ -35,7 +38,8 @@ Può:
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email) - 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) - 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 - 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 - 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 riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
agganciati al suo id 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 giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
di altri. 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. Per ogni giocatore, nella tab Profili, vengono mostrati.
- Stato del profilo - 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 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 (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) ## 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)): Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)):
| Azione | Hook | Effetto | | Azione | Hook | Effetto |
| ---------------------- | ------------------------ | ------------------------------------------------------------- | | ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) | | 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 | | 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 | | 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 | | 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`) | | 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 Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
accesso con Google, per corrispondenza email 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 { Card } from "@/components/crapp/ui-bits";
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data"; import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
import type { Evento } from "@/lib/eventi"; 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 { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useGiocatoreBase } from "@/lib/user-store"; import { useGiocatoreInCampo } from "@/lib/user-store";
import { dataOggi } from "@/lib/scout-live"; import { dataOggi } from "@/lib/scout-live";
const tipoMeta = { const tipoMeta = {
@@ -69,12 +69,12 @@ export function EventoCard({
}) { }) {
const { risposte } = usePresenzeEvento(evento.id); const { risposte } = usePresenzeEvento(evento.id);
const salva = useSalvaPresenza(); const salva = useSalvaPresenza();
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta): `useGiocatoreBase` // Solo `io.id` serve qui (per leggere/scrivere la propria risposta, `null` per l'allenatore
// legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`. // 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. // Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
const io = useGiocatoreBase(); const io = useGiocatoreInCampo();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo); const rosa = squadra.filter(inRosa);
const stato = io ? risposte[io.id] : undefined; const stato = io ? risposte[io.id] : undefined;
const presentiVeri = rosa.filter( const presentiVeri = rosa.filter(
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo", (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 { Card } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar"; import { Avatar } from "@/components/crapp/Avatar";
import type { Giocatore } from "@/lib/crapp-data"; 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"; import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
@@ -20,8 +20,8 @@ export function Pagelle({
convocati: Giocatore[]; convocati: Giocatore[];
chiuse?: boolean; chiuse?: boolean;
}) { }) {
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. // Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreBase(); const io = useGiocatoreInCampo();
const { voti: tutti, isPending } = usePagelle(); const { voti: tutti, isPending } = usePagelle();
const vota = useVotaPagella(); const vota = useVotaPagella();
const [apertoPer, setApertoPer] = useState<string | null>(null); const [apertoPer, setApertoPer] = useState<string | null>(null);
+143 -2
View File
@@ -12,8 +12,10 @@ import {
useSalvaProfilo, useSalvaProfilo,
type SezioneFile, type SezioneFile,
} from "@/lib/profili"; } from "@/lib/profili";
import { useSalvaNomeAllenatore, type GiocatoreSquadra } from "@/lib/giocatori-squadra";
import { import {
completamento, completamento,
completamentoAllenatore,
profiloVuoto, profiloVuoto,
sezioniComplete, sezioniComplete,
type Profilo, 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. * 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. * Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente.
@@ -380,13 +514,18 @@ export function ProfiloAmministrativo({
*/ */
export function CompletaProfilo({ export function CompletaProfilo({
giocatoreId, giocatoreId,
allenatore = false,
indice = 0, indice = 0,
}: { }: {
giocatoreId: string; giocatoreId: string;
/** L'allenatore ha solo i dati personali ridotti (DD-034). */
allenatore?: boolean;
indice?: number; indice?: number;
}) { }) {
const { profili, isPending } = useProfili(); const { profili, isPending } = useProfili();
const perc = completamento(profili[giocatoreId]); const perc = allenatore
? completamentoAllenatore(profili[giocatoreId])
: completamento(profili[giocatoreId]);
if (isPending || perc === 100) return null; if (isPending || perc === 100) return null;
return ( return (
@@ -409,7 +548,9 @@ export function CompletaProfilo({
/> />
</div> </div>
<p className="mt-2 text-[13px] text-muted-foreground"> <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> </p>
</Link> </Link>
</Reveal> </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 { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
import { useTurniPalloni } from "@/lib/palloni"; import { useTurniPalloni } from "@/lib/palloni";
import { useEventi } from "@/lib/eventi"; 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. */ /** Avvisi per chi è di turno: prendere i palloni oggi, o riportarli oggi. */
export function PromemoriaPalloni() { export function PromemoriaPalloni() {
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. Montato in Home. // Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). Montato in Home.
const io = useGiocatoreBase(); const io = useGiocatoreInCampo();
const { turni } = useTurniPalloni(); const { turni } = useTurniPalloni();
const { eventi } = useEventi(); const { eventi } = useEventi();
if (!io) return null; 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 { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze"; import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
import { useAnagraficaRosa } from "@/lib/rosa"; import { useAnagraficaRosa } from "@/lib/rosa";
import { useGiocatoreBase } from "@/lib/user-store"; import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
import { intestazioniAutenticate } from "@/lib/auth"; import { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli"; import { usePuoGestireEventi } from "@/lib/ruoli";
import { dataOggi } from "@/lib/scout-live"; import { dataOggi } from "@/lib/scout-live";
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"]; const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
@@ -20,8 +20,10 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
const salva = useSalvaPresenza(); const salva = useSalvaPresenza();
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano, // Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
// niente statistiche di squadra. // niente statistiche di squadra.
const io = useGiocatoreBase(); const io = useGiocatoreInCampo();
const admin = useIsAdmin(); // 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 rosa = useAnagraficaRosa();
const [sollecito, setSollecito] = useState(false); const [sollecito, setSollecito] = useState(false);
const passato = data < dataOggi(); 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", { const res = await fetch("/api/public/sollecita-presenze", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) }, 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(); if (!res.ok) throw new Error();
const dati = (await res.json()) as { inviate: number; destinatari: number }; const dati = (await res.json()) as { inviate: number; destinatari: number };
@@ -115,7 +117,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
</div> </div>
) : null} ) : null}
{admin ? ( {puoSollecitare ? (
<button <button
type="button" type="button"
onClick={sollecita} onClick={sollecita}
+3 -3
View File
@@ -4,7 +4,7 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card } from "@/components/crapp/ui-bits"; import { Card } from "@/components/crapp/ui-bits";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; 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 { intestazioniAutenticate } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { import {
@@ -27,8 +27,8 @@ export function SondaggioCacche({
dataEvento: string; dataEvento: string;
oraEvento: string; oraEvento: string;
}) { }) {
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. // Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreBase(); const io = useGiocatoreInCampo();
const { righe } = useCacche(); const { righe } = useCacche();
const salva = useSalvaCacche(); const salva = useSalvaCacche();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
+2 -2
View File
@@ -4,7 +4,7 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Avatar } from "@/components/crapp/Avatar"; import { Avatar } from "@/components/crapp/Avatar";
import { intestazioniAutenticate } from "@/lib/auth"; 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 { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { useGiocatoreBase } from "@/lib/user-store"; import { useGiocatoreBase } from "@/lib/user-store";
@@ -18,7 +18,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
const io = useGiocatoreBase(); const io = useGiocatoreBase();
const admin = useIsAdmin(); const admin = useIsAdmin();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo); const rosa = squadra.filter(inRosa);
const id = turni[eventoId]; const id = turni[eventoId];
const proposto = !salvati[eventoId]; const proposto = !salvati[eventoId];
+5 -5
View File
@@ -2,8 +2,8 @@ import { useState } from "react";
import { Crown, Vote } from "lucide-react"; import { Crown, Vote } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store"; import { useGiocatoreInCampo } from "@/lib/user-store";
import { usePresenzeEvento } from "@/lib/presenze"; import { usePresenzeEvento } from "@/lib/presenze";
import type { Evento } from "@/lib/eventi"; import type { Evento } from "@/lib/eventi";
import { import {
@@ -25,14 +25,14 @@ import {
*/ */
export function VotazioneMvp({ evento }: { evento: Evento }) { export function VotazioneMvp({ evento }: { evento: Evento }) {
const matchId = evento.id; const matchId = evento.id;
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. // Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreBase(); const io = useGiocatoreInCampo();
const voti = useVotiMvp(); const voti = useVotiMvp();
const vota = useVotaMvp(); const vota = useVotaMvp();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const { risposte } = usePresenzeEvento(evento.id); const { risposte } = usePresenzeEvento(evento.id);
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo"; 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 [aperto, setAperto] = useState(false);
const tutti: VotoMvp[] = voti.data ?? []; 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 { Check, Crown, Sparkles } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store"; import { useGiocatoreInCampo } from "@/lib/user-store";
import { import {
categorieSocial, categorieSocial,
conteggioCategoria, conteggioCategoria,
@@ -15,12 +15,12 @@ import {
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */ /** Voto social post-partita: un compagno per categoria, veloce da mobile. */
export function VotoSocial({ matchId }: { matchId: string }) { export function VotoSocial({ matchId }: { matchId: string }) {
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. // Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
const io = useGiocatoreBase(); const io = useGiocatoreInCampo();
const voti = useVotiSocial(); const voti = useVotiSocial();
const vota = useVotaSocial(); const vota = useVotaSocial();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const rosa = squadra.filter((g) => g.attivo); const rosa = squadra.filter(inRosa);
const [aperta, setAperta] = useState<string | null>(null); const [aperta, setAperta] = useState<string | null>(null);
const tutti = voti.data ?? []; 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 * è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non
* può fare da credenziale. * può fare da credenziale.
* *
* Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli * Le route che mandano notifiche partono da un pulsante riservato agli amministratori, e il
* amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della * sollecito presenze anche agli allenatori (DD-034): `richiediAdmin` e `richiediGestoreEventi`
* sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può * 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. * 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, * Lascia passare solo un utente autenticato con almeno uno dei `ruoli`. `401` se manca o non
* `403` se il token è buono ma l'utente non è admin. * 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); const token = tokenDaRichiesta(request);
if (!token) return new Response("Autenticazione richiesta", { status: 401 }); 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 }); 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). // 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") .from("user_roles")
.select("role") .select("role")
.eq("user_id", utente.user.id) .eq("user_id", utente.user.id)
.eq("role", "admin") .in("role", ruoli);
.maybeSingle();
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 }); if (!righe?.length) return new Response(rifiuto, { status: 403 });
return null; 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é * truth per il collegamento account ↔ giocatore; `crapp-data.ts` resta il fallback finché
* la migrazione non è completa (DD-016 regola 1). * 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 = { export type GiocatoreSquadra = {
id: string; id: string;
nome: string; nome: string;
cognome: string; cognome: string;
/** 0 per l'allenatore, che non ha numero di maglia (a database è NULL). */
numero: number; numero: number;
ruolo: string; ruolo: string;
authUserId: string | null; authUserId: string | null;
@@ -20,13 +24,14 @@ export type GiocatoreSquadra = {
dataTessera: string | null; dataTessera: string | null;
/** Pubblica a tutta la rosa (M18): sincronizzata da `profili_giocatore.data_nascita`. */ /** Pubblica a tutta la rosa (M18): sincronizzata da `profili_giocatore.data_nascita`. */
nascita: string | null; nascita: string | null;
tipo: TipoMembro;
}; };
export type RigaGiocatoreSquadra = { export type RigaGiocatoreSquadra = {
id: string; id: string;
nome: string; nome: string;
cognome: string; cognome: string;
numero: number; numero: number | null;
ruolo: string; ruolo: string;
auth_user_id: string | null; auth_user_id: string | null;
attivo: boolean; attivo: boolean;
@@ -34,6 +39,7 @@ export type RigaGiocatoreSquadra = {
numero_tessera: string | null; numero_tessera: string | null;
data_tessera: string | null; data_tessera: string | null;
nascita: string | null; nascita: string | null;
tipo: TipoMembro;
}; };
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */ /** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
@@ -54,6 +60,7 @@ export function rosaFallback(): GiocatoreSquadra[] {
numeroTessera: null, numeroTessera: null,
dataTessera: null, dataTessera: null,
nascita: g.nascita, nascita: g.nascita,
tipo: "giocatore" as const,
})); }));
} }
@@ -61,6 +68,24 @@ export function nomeCompleto(g: GiocatoreSquadra): string {
return `${g.nome} ${g.cognome}`.trim(); 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. */ /** Lo slot già collegato a questo account, se esiste. */
export function slotDi( export function slotDi(
righe: GiocatoreSquadra[], righe: GiocatoreSquadra[],
@@ -81,7 +106,7 @@ export function slotPerEmail(
} }
export const COLONNE_SQUADRA = 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). */ /** Conversione riga database -> modello applicativo (riusabile anche lato server). */
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra { export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
@@ -89,7 +114,7 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
id: r.id, id: r.id,
nome: r.nome, nome: r.nome,
cognome: r.cognome, cognome: r.cognome,
numero: r.numero, numero: r.numero ?? 0,
ruolo: r.ruolo, ruolo: r.ruolo,
authUserId: r.auth_user_id, authUserId: r.auth_user_id,
attivo: r.attivo, attivo: r.attivo,
@@ -97,6 +122,7 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
numeroTessera: r.numero_tessera, numeroTessera: r.numero_tessera,
dataTessera: r.data_tessera, dataTessera: r.data_tessera,
nascita: r.nascita, 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 /** 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. */ * 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): * 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 { export function validaDatiSquadra(dati: DatiSquadra): string | null {
if (!dati.nome.trim()) return "Il nome non può essere vuoto."; if (!dati.nome.trim()) return "Il nome non può essere vuoto.";
if (!dati.cognome.trim()) return "Il cognome 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) if (!Number.isInteger(dati.numero) || dati.numero <= 0)
return "Il numero di maglia deve essere maggiore di zero."; return "Il numero di maglia deve essere maggiore di zero.";
if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto."; if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto.";
@@ -152,7 +186,19 @@ export function numeroGiaUsato(
giocatoreId: string, giocatoreId: string,
numero: number, numero: number,
): boolean { ): 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. */ /** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
@@ -160,19 +206,13 @@ export function useSalvaDatiSquadra() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => { mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => {
const dati = { const riga = rigaDatiSquadra(input.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 { error } = await supabaseNuoveTabelle const { error } = await supabaseNuoveTabelle
.from("giocatori_squadra") .from("giocatori_squadra")
.update(dati) .update(riga)
.eq("id", input.giocatoreId); .eq("id", input.giocatoreId);
if (error) throw error; if (error) throw error;
return { giocatoreId: input.giocatoreId, dati }; return { giocatoreId: input.giocatoreId, dati: { ...riga, numero: riga.numero ?? 0 } };
}, },
onSuccess: (input) => { onSuccess: (input) => {
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) => queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
@@ -225,19 +265,13 @@ export function useAggiungiGiocatore() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (input: { id: string; dati: DatiSquadra }): Promise<GiocatoreSquadra> => { mutationFn: async (input: { id: string; dati: DatiSquadra }): Promise<GiocatoreSquadra> => {
const riga = { const riga = { id: input.id, tipo: input.dati.tipo, ...rigaDatiSquadra(input.dati) };
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 { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga); const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga);
if (error) throw error; if (error) throw error;
// Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL. // Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL.
return { return {
...riga, ...riga,
numero: riga.numero ?? 0,
authUserId: null, authUserId: null,
attivo: true, attivo: true,
numeroTessera: null, 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ì * 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. * 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 { supabase } from "@/integrations/supabase/client";
import { completaTurni } from "./palloni-core"; import { completaTurni } from "./palloni-core";
import { useEventi } from "./eventi"; 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; export const TURNI_KEY = ["turni-palloni"] as const;
@@ -29,7 +29,7 @@ export function useTurniPalloni() {
const { eventi } = useEventi(); const { eventi } = useEventi();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const salvati = query.data ?? {}; 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) }; return { ...query, salvati, turni: completaTurni(salvati, eventi, rosa) };
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useEventi } from "./eventi"; import { useEventi } from "./eventi";
import { useRispostePresenze } from "./presenze"; import { useRispostePresenze } from "./presenze";
import { useGiocatoriSquadra } from "./giocatori-squadra"; import { inRosa, useGiocatoriSquadra } from "./giocatori-squadra";
import { import {
presenzeUltimoMese, presenzeUltimoMese,
presenzeUltimoMeseTutti, presenzeUltimoMeseTutti,
@@ -30,7 +30,7 @@ export function usePresenzeUltimoMeseTutti(): Record<string, StatistichePresenza
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
return useMemo(() => { 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); return presenzeUltimoMeseTutti(eventi, presenze, idRosa);
}, [eventi, presenze, squadra]); }, [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 { supabase } from "@/integrations/supabase/client";
import type { Stato } from "./crapp-data"; import type { Stato } from "./crapp-data";
import type { Evento } from "./eventi"; import type { Evento } from "./eventi";
import { inRosa, type TipoMembro } from "./giocatori-squadra";
import { aggiornaSerie } from "./serie"; import { aggiornaSerie } from "./serie";
import { dataOggi } from "./scout-live"; 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 * 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. * palloni: la route `/api/public/sollecita-presenze` la chiama con i dati che ha già letto.
*/ */
export function destinatariSollecito( export function destinatariSollecito(
squadra: Array<{ id: string; attivo: boolean }>, squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
risposte: Array<{ giocatore_id: string; stato: string }>, risposte: Array<{ giocatore_id: string; stato: string }>,
): string[] { ): string[] {
const stati = new Map(risposte.map((r) => [r.giocatore_id, r.stato])); 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 return squadra
.filter((g) => g.attivo) .filter(inRosa)
.filter((g) => { .filter((g) => {
const stato = stati.get(g.id); const stato = stati.get(g.id);
return stato === undefined || stato === "forse"; 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"; export type StatoScadenza = "mancante" | "scaduto" | "valido";
/** Un certificato scaduto blocca il tesseramento: per l'admin non vale come presente. */ /** 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 { useMemo } from "react";
import type { Giocatore } from "./crapp-data"; 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 { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti";
import { mediePagelle, usePagelle } from "./pagelle"; import { mediePagelle, usePagelle } from "./pagelle";
import { statisticheCacche, useCacche } from "./cacche"; 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. * 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 * per le liste presenze o lo scout. A differenza di `useRosa` non legge MVP, pagelle,
* legge MVP, pagelle, cacche, palloni né infortuni: evita di montare quei cinque hook e * cacche, palloni né infortuni: evita di montare quei cinque hook e il relativo `useMemo`
* il relativo `useMemo` solo per l'anagrafica. * 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"> Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita">
> { > {
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
return useMemo( return useMemo(
() => () =>
squadra squadra
.filter((g) => g.attivo) .filter((g) => (conAllenatori ? g.attivo : inRosa(g)))
.map((g) => ({ .map((g) => ({
id: g.id, id: g.id,
nome: nomeCompleto(g), nome: nomeCompleto(g),
@@ -46,7 +49,7 @@ export function useAnagraficaRosa(): Array<
numero: g.numero, numero: g.numero,
nascita: g.nascita ?? "", 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, * Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
* palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra` * palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra`
* (DD-015): solo i giocatori attivi, gli altri restano nel database ma spariscono * (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. * rispetto a quelle che l'app fa comunque.
*/ */
export function useRosa(): Giocatore[] { export function useRosa(): Giocatore[] {
@@ -78,32 +81,30 @@ export function useRosa(): Giocatore[] {
const palloni = conteggioTurni(turniSalvati, eventi); const palloni = conteggioTurni(turniSalvati, eventi);
const mvpVinti = mvpVintiPerGiocatore(votiMvp ?? []); const mvpVinti = mvpVintiPerGiocatore(votiMvp ?? []);
return squadra return squadra.filter(inRosa).map((g) => ({
.filter((g) => g.attivo) id: g.id,
.map((g) => ({ nome: nomeCompleto(g),
id: g.id, numero: g.numero,
nome: nomeCompleto(g), ruolo: g.ruolo,
numero: g.numero, nascita: g.nascita ?? "",
ruolo: g.ruolo, iniziali: iniziali(g.nome, g.cognome),
nascita: g.nascita ?? "", presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
iniziali: iniziali(g.nome, g.cognome), totaliEventi: totaliEventiGiocatore(g.id, eventi),
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze), partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
totaliEventi: totaliEventiGiocatore(g.id, eventi), streak: serieConsecutiva(g.id, eventi, mappaPresenze),
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze), serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
streak: serieConsecutiva(g.id, eventi, mappaPresenze), seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"), serieConferme: serieConferme(g.id, eventi, tempi),
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"), mvp: mvpVinti[g.id] ?? 0,
serieConferme: serieConferme(g.id, eventi, tempi), mediaVoto: medie[g.id]?.media ?? 0,
mvp: mvpVinti[g.id] ?? 0, votiPagella: medie[g.id]?.voti ?? 0,
mediaVoto: medie[g.id]?.media ?? 0, palloni: palloni[g.id] ?? 0,
votiPagella: medie[g.id]?.voti ?? 0, seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
palloni: palloni[g.id] ?? 0, cacche: statCacche[g.id]?.giornateTop ?? 0,
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi), cacchePartita: statCacche[g.id]?.media ?? 0,
cacche: statCacche[g.id]?.giornateTop ?? 0, infortuni: infortuni[g.id] ?? 0,
cacchePartita: statCacche[g.id]?.media ?? 0, ritardi: ritardi[g.id] ?? 0,
infortuni: infortuni[g.id] ?? 0, }));
ritardi: ritardi[g.id] ?? 0,
}));
}, [ }, [
squadra, squadra,
votiMvp, votiMvp,
+26 -10
View File
@@ -2,33 +2,49 @@ import { useQuery } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
import { useSessione } from "./auth"; 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). * Permessi: unica fonte è `user_roles` nel database (DD-011). Nessuna lista di nomi,
* Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare. * 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. */ /** `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; if (!utenteId) return null;
const { data, error } = await supabase const { data, error } = await supabase
.from("user_roles") .from("user_roles")
.select("role") .select("role")
.eq("user_id", utenteId) .eq("user_id", utenteId)
.eq("role", "admin") .in("role", ["admin", "allenatore"]);
.maybeSingle();
if (error) throw error; if (error) throw error;
return !!data; return (data ?? []).map((r) => r.role as Ruolo);
} }
export function useIsAdmin(): boolean { function useRuoli(): Ruolo[] {
const { utenteId } = useSessione(); const { utenteId } = useSessione();
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta. // Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
const query = useQuery({ const query = useQuery({
queryKey: [...RUOLI_KEY, utenteId], queryKey: [...RUOLI_KEY, utenteId],
queryFn: () => fetchRuoloAdmin(utenteId), queryFn: () => fetchRuoli(utenteId),
staleTime: 30 * 60_000, 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 { useSyncExternalStore } from "react";
import type { Giocatore } from "./crapp-data"; 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"; import { useIo } from "./rosa";
const KEY = "crapp-user-v1"; const KEY = "crapp-user-v1";
@@ -56,6 +56,23 @@ export function useGiocatoreBase(): GiocatoreSquadra | null {
return id ? (righe.find((x) => x.id === id) ?? null) : 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, …). */ /** Giocatore corrente con statistiche calcolate da dati reali (presenze, MVP, badge, …). */
export function useGiocatoreCorrente(): Giocatore | null { export function useGiocatoreCorrente(): Giocatore | null {
return useIo(); return useIo();
+213 -130
View File
@@ -21,12 +21,15 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Campo, classiInput, PageHeader, Select, StatTile } from "@/components/crapp/ui-bits"; import { Campo, classiInput, PageHeader, Select, StatTile } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo"; import { CampiAllenatore, CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
import { import {
inRosa,
isAllenatore,
nomeCompleto, nomeCompleto,
numeroGiaUsato, numeroGiaUsato,
prossimoIdGiocatore, prossimoIdGiocatore,
RUOLI, RUOLI,
ruoloVisibile,
useAggiungiGiocatore, useAggiungiGiocatore,
useGiocatoriSquadra, useGiocatoriSquadra,
useImpostaAttivo, useImpostaAttivo,
@@ -41,6 +44,7 @@ import {
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili"; import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
import { import {
completamento, completamento,
completamentoAllenatore,
csvTesseramento, csvTesseramento,
profiloVuoto, profiloVuoto,
sezioniComplete, sezioniComplete,
@@ -126,7 +130,10 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
numero: g.numero, numero: g.numero,
ruolo: g.ruolo, ruolo: g.ruolo,
email: g.email, 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); const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
async function confermaSquadra() { async function confermaSquadra() {
@@ -135,7 +142,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
toast.error(errore); toast.error(errore);
return; 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.`); toast.error(`Il numero ${squadraCorrente.numero} è già assegnato a un altro giocatore.`);
return; return;
} }
@@ -216,28 +223,34 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
className={classiInput} className={classiInput}
/> />
</Campo> </Campo>
<Campo label="Numero"> {allenatore ? null : (
<input <>
type="number" <Campo label="Numero">
min={1} <input
value={squadraCorrente.numero} type="number"
onChange={(e) => setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) })} min={1}
className={classiInput} value={squadraCorrente.numero}
/> onChange={(e) =>
</Campo> setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) })
<Campo label="Ruolo"> }
<Select className={classiInput}
value={squadraCorrente.ruolo} />
onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })} </Campo>
> <Campo label="Ruolo">
<option value="">—</option> <Select
{RUOLI.map((r) => ( value={squadraCorrente.ruolo}
<option key={r} value={r}> onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })}
{r} >
</option> <option value="">—</option>
))} {RUOLI.map((r) => (
</Select> <option key={r} value={r}>
</Campo> {r}
</option>
))}
</Select>
</Campo>
</>
)}
</div> </div>
<Campo label="Email"> <Campo label="Email">
<input <input
@@ -257,44 +270,61 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
Salva dati squadra Salva dati squadra
</button> </button>
<h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3> {allenatore ? null : (
<div className="grid grid-cols-2 gap-3"> <>
<Campo label="Numero tessera"> <h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3>
<input <div className="grid grid-cols-2 gap-3">
value={tesseramentoCorrente.numeroTessera ?? ""} <Campo label="Numero tessera">
maxLength={40} <input
placeholder="Non ancora tesserato" value={tesseramentoCorrente.numeroTessera ?? ""}
onChange={(e) => maxLength={40}
setTesseramento({ ...tesseramentoCorrente, numeroTessera: e.target.value || null }) placeholder="Non ancora tesserato"
} onChange={(e) =>
className={classiInput} setTesseramento({
/> ...tesseramentoCorrente,
</Campo> numeroTessera: e.target.value || null,
<Campo label="Data tessera"> })
<input }
type="date" className={classiInput}
value={tesseramentoCorrente.dataTessera ?? ""} />
onChange={(e) => </Campo>
setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null }) <Campo label="Data tessera">
} <input
className={classiInput} type="date"
/> value={tesseramentoCorrente.dataTessera ?? ""}
</Campo> onChange={(e) =>
</div> setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null })
<button }
type="button" className={classiInput}
onClick={confermaTesseramento} />
disabled={!tesseramento || salvaTesseramento.isPending} </Campo>
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50" </div>
> <button
Salva tesseramento type="button"
</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 {allenatore ? (
corrente={profiloCorrente} <>
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })} <h3 className="font-display text-sm uppercase tracking-wide">Dati personali</h3>
sezioni={sezioniComplete(profiloCorrente)} <CampiAllenatore
/> corrente={profiloCorrente}
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
/>
</>
) : (
<CampiProfilo
corrente={profiloCorrente}
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
sezioni={sezioniComplete(profiloCorrente)}
/>
)}
<button <button
type="button" type="button"
onClick={confermaProfilo} onClick={confermaProfilo}
@@ -402,8 +432,9 @@ function SchedaGiocatore({
onToggle: () => void; onToggle: () => void;
}) { }) {
const nome = nomeCompleto(g); const nome = nomeCompleto(g);
const { ruolo, numero } = g; const { numero } = g;
const perc = completamento(profilo); const allenatore = isAllenatore(g);
const perc = allenatore ? completamentoAllenatore(profilo) : completamento(profilo);
const sezioni = sezioniComplete(profilo); const sezioni = sezioniComplete(profilo);
const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi); const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi);
const fronte = statoScadenza(profilo?.documentoScadenza, profilo?.documentoFrontePath, 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"> <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"> <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"> <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>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-semibold leading-tight">{nome}</p> <p className="truncate font-semibold leading-tight">{nome}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{ruolo} · profilo {perc}% {ruoloVisibile(g)} · profilo {perc}%
</p> </p>
</div> </div>
<ChevronDown <ChevronDown
@@ -436,55 +467,65 @@ function SchedaGiocatore({
/> />
</div> </div>
<div className="mt-3 flex flex-wrap gap-2"> {allenatore ? null : (
<Documento <div className="mt-3 flex flex-wrap gap-2">
icona={<IdCard className="h-3.5 w-3.5" />} <Documento
label="Doc fronte" icona={<IdCard className="h-3.5 w-3.5" />}
stato={fronte} label="Doc fronte"
path={profilo?.documentoFrontePath ?? null} stato={fronte}
/> path={profilo?.documentoFrontePath ?? null}
<Documento />
icona={<IdCard className="h-3.5 w-3.5" />} <Documento
label="Doc retro" icona={<IdCard className="h-3.5 w-3.5" />}
stato={retro} label="Doc retro"
path={profilo?.documentoRetroPath ?? null} stato={retro}
/> path={profilo?.documentoRetroPath ?? null}
<Documento />
icona={<FileText className="h-3.5 w-3.5" />} <Documento
label="Certificato" icona={<FileText className="h-3.5 w-3.5" />}
stato={certificato} label="Certificato"
path={profilo?.certificatoPath ?? null} stato={certificato}
/> path={profilo?.certificatoPath ?? null}
<Documento />
icona={<Image className="h-3.5 w-3.5" />} <Documento
label="Foto" icona={<Image className="h-3.5 w-3.5" />}
stato={sezioni.foto ? "presente" : "assente"} label="Foto"
path={profilo?.fotoPath ?? null} stato={sezioni.foto ? "presente" : "assente"}
/> path={profilo?.fotoPath ?? null}
<span />
className={cn( <span
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase", className={cn(
statoClasse[g.numeroTessera ? "presente" : "assente"], "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"} <BadgeCheck className="h-3.5 w-3.5" />
</span> {g.numeroTessera ? "Tesserato" : "Da tesserare"}
</div> </span>
</div>
)}
{aperta ? <ModificaGiocatore g={g} profilo={profilo} /> : null} {aperta ? <ModificaGiocatore g={g} profilo={profilo} /> : null}
</Reveal> </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, /** Form per aggiungere un giocatore alla rosa (DD-017), o un allenatore (DD-034): l'id
* il database non lo genera da solo. */ * `g<N>` è calcolato in app, il database non lo genera da solo. */
function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) { function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
const [aperto, setAperto] = useState(false); const [aperto, setAperto] = useState(false);
const [dati, setDati] = useState<DatiSquadra>(datiVuoti); const [dati, setDati] = useState<DatiSquadra>(datiVuoti);
const aggiungi = useAggiungiGiocatore(); const aggiungi = useAggiungiGiocatore();
const allenatore = dati.tipo === "allenatore";
async function conferma() { async function conferma() {
const errore = validaDatiSquadra(dati); const errore = validaDatiSquadra(dati);
@@ -492,13 +533,17 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
toast.error(errore); toast.error(errore);
return; return;
} }
if (numeroGiaUsato(righe, "", dati.numero)) { if (!allenatore && numeroGiaUsato(righe, "", dati.numero)) {
toast.error(`Il numero ${dati.numero} è già assegnato a un altro giocatore.`); toast.error(`Il numero ${dati.numero} è già assegnato a un altro giocatore.`);
return; return;
} }
try { try {
await aggiungi.mutateAsync({ id: prossimoIdGiocatore(righe), dati }); 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); setDati(datiVuoti);
setAperto(false); setAperto(false);
} catch (e) { } catch (e) {
@@ -513,14 +558,27 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
onClick={() => setAperto(true)} 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" 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> </button>
); );
} }
return ( return (
<div className="mt-3 space-y-3 rounded-2xl bg-card p-4 shadow-card"> <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"> <div className="grid grid-cols-2 gap-3">
<Campo label="Nome"> <Campo label="Nome">
<input <input
@@ -538,25 +596,32 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
className={classiInput} className={classiInput}
/> />
</Campo> </Campo>
<Campo label="Numero"> {allenatore ? null : (
<input <>
type="number" <Campo label="Numero">
min={1} <input
value={dati.numero} type="number"
onChange={(e) => setDati({ ...dati, numero: Number(e.target.value) })} min={1}
className={classiInput} value={dati.numero}
/> onChange={(e) => setDati({ ...dati, numero: Number(e.target.value) })}
</Campo> className={classiInput}
<Campo label="Ruolo"> />
<Select value={dati.ruolo} onChange={(e) => setDati({ ...dati, ruolo: e.target.value })}> </Campo>
<option value="">—</option> <Campo label="Ruolo">
{RUOLI.map((r) => ( <Select
<option key={r} value={r}> value={dati.ruolo}
{r} onChange={(e) => setDati({ ...dati, ruolo: e.target.value })}
</option> >
))} <option value="">—</option>
</Select> {RUOLI.map((r) => (
</Campo> <option key={r} value={r}>
{r}
</option>
))}
</Select>
</Campo>
</>
)}
</div> </div>
<Campo label="Email"> <Campo label="Email">
<input <input
@@ -610,7 +675,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate font-semibold leading-tight">{nomeCompleto(g)}</p> <p className="truncate font-semibold leading-tight">{nomeCompleto(g)}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
#{g.numero} · {g.ruolo} {isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}
</p> </p>
</div> </div>
<button <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 disattivi = squadra.filter((g) => !g.attivo);
const completi = attivi.filter((g) => completamento(profili[g.id]) === 100).length; const completi = attivi.filter((g) => completamento(profili[g.id]) === 100).length;
const certificatiOk = attivi.filter( const certificatiOk = attivi.filter(
@@ -723,6 +792,20 @@ function Dashboard() {
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))} 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> </div>
); );
@@ -737,7 +820,7 @@ function Dashboard() {
const contenutoNotifiche = notificheAttive ? ( const contenutoNotifiche = notificheAttive ? (
<> <>
<StatTile <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" label="Notifiche attive"
/> />
<button <button
@@ -748,7 +831,7 @@ function Dashboard() {
<Send className="h-4 w-4" /> Invia messaggio a tutti <Send className="h-4 w-4" /> Invia messaggio a tutti
</button> </button>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{attivi.map((g) => { {membri.map((g) => {
const attiva = notificheAttive.has(g.id); const attiva = notificheAttive.has(g.id);
return ( return (
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card"> <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 { richiediAdmin } from "@/lib/auth-route.server";
import { leggiEventi } from "@/lib/eventi.server"; import { leggiEventi } from "@/lib/eventi.server";
import { inviaPush } from "@/lib/webpush.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) }); 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 }); if (!partita) return new Response("Evento non trovato", { status: 404 });
const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
const { data: iscrizioni } = await supabaseAdmin const { data: tutte } = await supabaseAdmin
.from("push_subscriptions") .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 titolo = "💩 Sondaggio pre-partita aperto";
const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`; const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`;
let inviate = 0; let inviate = 0;
for (const iscrizione of iscrizioni ?? []) { for (const iscrizione of iscrizioni) {
try { try {
const { stato } = await inviaPush(iscrizione, titolo, testo); const { stato } = await inviaPush(iscrizione, titolo, testo);
if (stato === 404 || stato === 410) { 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 type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod"; import { z } from "zod";
import { richiediAdmin } from "@/lib/auth-route.server"; 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 { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
import { avvisiPalloniEvento, completaTurni } from "@/lib/palloni-core"; import { avvisiPalloniEvento, completaTurni } from "@/lib/palloni-core";
import { inviaPush } from "@/lib/webpush.server"; 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; for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
const squadra = await leggiGiocatoriSquadra(); const squadra = await leggiGiocatoriSquadra();
const rosa = squadra const rosa = squadra.filter(inRosa).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
.filter((g) => g.attivo)
.map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
const turni = completaTurni(salvati, eventi, rosa); const turni = completaTurni(salvati, eventi, rosa);
const avvisi = avvisiPalloniEvento(turni, eventi, evento.id); const avvisi = avvisiPalloniEvento(turni, eventi, evento.id);
+2 -2
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod"; 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 { formatData } from "@/lib/crapp-data";
import { leggiEventi } from "@/lib/eventi.server"; import { leggiEventi } from "@/lib/eventi.server";
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server"; import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
@@ -17,7 +17,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
server: { server: {
handlers: { handlers: {
POST: async ({ request }) => { POST: async ({ request }) => {
const negato = await richiediAdmin(request); const negato = await richiediGestoreEventi(request);
if (negato) return negato; if (negato) return negato;
const parsed = schema.safeParse(await request.json()); 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 { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi"; import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
import { useAnagraficaRosa } from "@/lib/rosa"; 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 { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
import { import {
Drawer, Drawer,
@@ -55,9 +55,9 @@ function Calendario() {
}, []); }, []);
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null); const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
const [drawerAperto, setDrawerAperto] = useState(false); const [drawerAperto, setDrawerAperto] = useState(false);
const admin = useIsAdmin(); const puoGestire = usePuoGestireEventi();
const { eventi } = useEventi(); const { eventi } = useEventi();
const rosa = useAnagraficaRosa(); const rosa = useAnagraficaRosa({ conAllenatori: true });
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo // `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi. // `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
const ridotto = useMotoRidotto(); const ridotto = useMotoRidotto();
@@ -244,7 +244,7 @@ function Calendario() {
</Card> </Card>
</Section> </Section>
{admin ? ( {puoGestire ? (
<div className="px-5 pt-4"> <div className="px-5 pt-4">
<Link <Link
to="/eventi" to="/eventi"
+7 -6
View File
@@ -16,7 +16,7 @@ import {
} from "@/components/ui/drawer"; } from "@/components/ui/drawer";
import { formatData } from "@/lib/crapp-data"; import { formatData } from "@/lib/crapp-data";
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario"; 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 { import {
categoriaEvento, categoriaEvento,
daCategoria, daCategoria,
@@ -28,7 +28,7 @@ import {
type Evento, type Evento,
} from "@/lib/eventi"; } from "@/lib/eventi";
import { useGiocatoreBase } from "@/lib/user-store"; import { useGiocatoreBase } from "@/lib/user-store";
import { useIsAdmin } from "@/lib/ruoli"; import { usePuoGestireEventi } from "@/lib/ruoli";
import { molla, proietta } from "@/lib/molla"; import { molla, proietta } from "@/lib/molla";
import { useMotoRidotto } from "@/lib/motion"; import { useMotoRidotto } from "@/lib/motion";
@@ -61,9 +61,10 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [
]; ];
function GestioneEventi() { 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 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 { eventi, isPending, isError, error, refetch } = useEventi();
const { righe: squadra } = useGiocatoriSquadra(); const { righe: squadra } = useGiocatoriSquadra();
const salva = useSalvaEvento(); const salva = useSalvaEvento();
@@ -72,7 +73,7 @@ function GestioneEventi() {
const [daEliminare, setDaEliminare] = useState<Evento | null>(null); const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
const [confermaModifica, setConfermaModifica] = useState(false); const [confermaModifica, setConfermaModifica] = useState(false);
const [cambiaGiorno, setCambiaGiorno] = 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. // SSR-safe: la data di oggi arriva solo dopo il mount.
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null); 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 { anno, mese, direzione, precedente, successivo } = useMeseNav();
const { giorni, offsetLunedi } = giorniDelMese(anno, mese); const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
if (!io || !admin) { if (!io || !puoGestire) {
return ( return (
<> <>
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" /> <PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
+78 -61
View File
@@ -1,4 +1,5 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { Flame, ChevronRight } from "lucide-react"; import { Flame, ChevronRight } from "lucide-react";
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard"; import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni"; import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
@@ -11,6 +12,8 @@ import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { useEventi, type Evento } from "@/lib/eventi"; import { useEventi, type Evento } from "@/lib/eventi";
import { useRispostePresenze } from "@/lib/presenze"; import { useRispostePresenze } from "@/lib/presenze";
import { useIo, useObiettivi } from "@/lib/rosa"; 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 { useCsi } from "@/lib/csi";
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core"; import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
import { useScoutMatches } from "@/lib/scout-store"; import { useScoutMatches } from "@/lib/scout-store";
@@ -38,7 +41,10 @@ export const Route = createFileRoute("/")({
}); });
function Index() { 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 giocatore = useIo();
const allenatore = isAllenatore(base);
const { eventi } = useEventi(); const { eventi } = useEventi();
const { presenze } = useRispostePresenze(); const { presenze } = useRispostePresenze();
const oggi = new Date().toISOString().slice(0, 10); const oggi = new Date().toISOString().slice(0, 10);
@@ -65,7 +71,8 @@ function Index() {
? { ...ultimaBase, mvp: (eventoUltima && mvpPerMatch[eventoUltima.id]) ?? "" } ? { ...ultimaBase, mvp: (eventoUltima && mvpPerMatch[eventoUltima.id]) ?? "" }
: null; : null;
if (!giocatore) return null; if (!base || (!giocatore && !allenatore)) return null;
const nome = giocatore?.nome ?? nomeCompleto(base);
return ( return (
<> <>
@@ -78,7 +85,7 @@ function Index() {
/> />
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80"> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
Ciao {giocatore.nome.split(" ")[0]} Ciao {nome.split(" ")[0]}
</p> </p>
<h1 className="font-display-lg text-4xl uppercase leading-none">CRAP Volley</h1> <h1 className="font-display-lg text-4xl uppercase leading-none">CRAP Volley</h1>
</div> </div>
@@ -86,7 +93,9 @@ function Index() {
<LinkProfilo /> <LinkProfilo />
</div> </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"> <div className="rounded-2xl bg-primary-foreground/10 p-3">
<p className="font-display text-2xl leading-none">{noi ? `${noi.pos}º` : "—"}</p> <p className="font-display text-2xl leading-none">{noi ? `${noi.pos}º` : "—"}</p>
<p className="text-xs uppercase text-primary-foreground/80">In classifica</p> <p className="text-xs uppercase text-primary-foreground/80">In classifica</p>
@@ -97,19 +106,21 @@ function Index() {
</p> </p>
<p className="text-xs uppercase text-primary-foreground/80">Bilancio W-L</p> <p className="text-xs uppercase text-primary-foreground/80">Bilancio W-L</p>
</div> </div>
<div className="rounded-2xl bg-primary-foreground/10 p-3"> {giocatore ? (
<p className="inline-flex items-center gap-1 font-display text-2xl leading-none"> <div className="rounded-2xl bg-primary-foreground/10 p-3">
<Flame className="h-4 w-4 text-accent" /> <p className="inline-flex items-center gap-1 font-display text-2xl leading-none">
{giocatore.streak} <Flame className="h-4 w-4 text-accent" />
</p> {giocatore.streak}
<p className="text-xs uppercase text-primary-foreground/80">Streak</p> </p>
</div> <p className="text-xs uppercase text-primary-foreground/80">Streak</p>
</div>
) : null}
</div> </div>
</Reveal> </Reveal>
<PromemoriaPalloni /> <PromemoriaPalloni />
<CompletaProfilo giocatoreId={giocatore.id} indice={1} /> <CompletaProfilo giocatoreId={base.id} allenatore={allenatore} indice={1} />
<Section <Section
titolo="Prossimo impegno" titolo="Prossimo impegno"
@@ -132,20 +143,22 @@ function Index() {
)} )}
</Section> </Section>
<Section titolo="Da confermare" indice={3}> {giocatore ? (
<div className="space-y-3"> <Section titolo="Da confermare" indice={3}>
{daConfermare.length > 0 ? ( <div className="space-y-3">
daConfermare.map((e) => { {daConfermare.length > 0 ? (
const link = linkPerEvento(e); daConfermare.map((e) => {
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />; 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 className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
</p> Nient'altro da confermare: sei in pari.
)} </p>
</div> )}
</Section> </div>
</Section>
) : null}
<Section <Section
titolo="Ultima partita" titolo="Ultima partita"
@@ -207,43 +220,47 @@ function Index() {
)} )}
</Section> </Section>
<Section {allenatore ? null : (
titolo="Obiettivo di squadra" <Section
indice={5} titolo="Obiettivo di squadra"
azione={ indice={5}
<Link azione={
to="/squadra" <Link
className="inline-flex items-center text-[14px] font-semibold text-accent" to="/squadra"
> className="inline-flex items-center text-[14px] font-semibold text-accent"
Tutti <ChevronRight className="h-4 w-4" /> >
</Link> Tutti <ChevronRight className="h-4 w-4" />
} </Link>
> }
{obiettivo ? ( >
<Card> {obiettivo ? (
<div className="flex items-center gap-2 text-sm font-bold"> <Card>
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo} <div className="flex items-center gap-2 text-sm font-bold">
</div> <span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" /> </div>
<p className="mt-2 text-xs text-muted-foreground"> <Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "} <p className="mt-2 text-xs text-muted-foreground">
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}. Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
</p> {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
<p className="mt-1 text-xs font-semibold text-accent"> </p>
{microcopyObiettivo(obiettivo)} <p className="mt-1 text-xs font-semibold text-accent">
</p> {microcopyObiettivo(obiettivo)}
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p> </p>
</Card> <p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
) : null} </Card>
</Section> ) : null}
</Section>
)}
<Section titolo="Colpo d'occhio" indice={6}> {giocatore ? (
<div className="grid grid-cols-3 gap-2"> <Section titolo="Colpo d'occhio" indice={6}>
<StatTile valore={giocatore.presenze} label="Presenze" /> <div className="grid grid-cols-3 gap-2">
<StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" /> <StatTile valore={giocatore.presenze} label="Presenze" />
<StatTile valore={giocatore.mvp} label="MVP" /> <StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" />
</div> <StatTile valore={giocatore.mvp} label="MVP" />
</Section> </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 { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export"; import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { useSonoAllenatore } from "@/lib/user-store";
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp"; import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
import { VotoSocial } from "@/components/crapp/VotoSocial"; import { VotoSocial } from "@/components/crapp/VotoSocial";
import { TurnoPalloni } from "@/components/crapp/TurnoPalloni"; import { TurnoPalloni } from "@/components/crapp/TurnoPalloni";
@@ -47,6 +48,7 @@ function PartitaDetail() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { evento } = useEvento(id); const { evento } = useEvento(id);
const admin = useIsAdmin(); const admin = useIsAdmin();
const sonoAllenatore = useSonoAllenatore();
const scoutMatches = useScoutMatches(); const scoutMatches = useScoutMatches();
const { data: csi } = useCsi(); const { data: csi } = useCsi();
const { risposte } = usePresenzeEvento(id); const { risposte } = usePresenzeEvento(id);
@@ -203,9 +205,12 @@ function PartitaDetail() {
</Section> </Section>
{match ? ( {match ? (
<Section titolo="Badge votati dai compagni"> // Badge e cacche non si mostrano all'allenatore (DD-034).
<VotoSocial matchId={match.id} /> sonoAllenatore ? null : (
</Section> <Section titolo="Badge votati dai compagni">
<VotoSocial matchId={match.id} />
</Section>
)
) : ( ) : (
<Section titolo="In programma"> <Section titolo="In programma">
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card"> <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} /> <ScoutEntry eventoId={evento.id} />
</Section> </Section>
<Section titolo="Sondaggio pre-partita"> {sonoAllenatore ? null : (
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} /> <Section titolo="Sondaggio pre-partita">
</Section> <SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
</Section>
)}
{match ? ( {match ? (
<Section titolo="Pagelle di fine partita"> <Section titolo="Pagelle di fine partita">
+232 -181
View File
@@ -14,8 +14,9 @@ import {
} from "@/lib/avatar-store"; } from "@/lib/avatar-store";
import { SerieGriglia } from "@/components/crapp/SerieCard"; import { SerieGriglia } from "@/components/crapp/SerieCard";
import { CollezioneBadge } from "@/components/crapp/CollezioneBadge"; 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 { useVotiSocial } from "@/lib/badge-social";
import { inizialiDa } from "@/lib/crapp-data";
import { useIo } from "@/lib/rosa"; import { useIo } from "@/lib/rosa";
import { usePresenzeUltimoMese } from "@/lib/presenze-mese"; import { usePresenzeUltimoMese } from "@/lib/presenze-mese";
import { import {
@@ -24,7 +25,13 @@ import {
pushSupportato, pushSupportato,
statoNotifiche, statoNotifiche,
} from "@/lib/push-client"; } 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 { esci } from "@/lib/auth";
import { useIsAdmin } from "@/lib/ruoli"; import { useIsAdmin } from "@/lib/ruoli";
import { Reveal } from "@/components/motion/Reveal"; import { Reveal } from "@/components/motion/Reveal";
@@ -60,130 +67,29 @@ export const Route = createFileRoute("/profilo")({
}); });
function 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 { tab } = Route.useSearch();
const tabIniziale = tab ?? "stagione"; const tabIniziale = tab ?? "stagione";
const votiSocial = useVotiSocial(); const votiSocial = useVotiSocial();
const g = useIo(); const g = useIo();
const admin = useIsAdmin();
const ultimoMese = usePresenzeUltimoMese(g?.id); 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; 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; 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 ( return (
<> <>
{/* Sul profilo il logo torna in home al posto del link al profilo. */} {/* Sul profilo il logo torna in home al posto del link al profilo. */}
<PageHeader <PageHeader titolo={g.nome} sottotitolo={`#${g.numero} · ${g.ruolo}`} azione={<LinkHome />} />
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>
}
/>
<Reveal className="-mt-6 px-5"> <CardAvatar id={g.id} fallback={g.iniziali} />
<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>
<BarraSottosezioni <BarraSottosezioni
key={tabIniziale} key={tabIniziale}
@@ -236,78 +142,223 @@ function Profilo() {
{ {
id: "impostazioni", id: "impostazioni",
label: "Opzioni", label: "Opzioni",
contenuto: ( contenuto: <TabOpzioni giocatoreId={g.id} />,
<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>
),
}, },
]} ]}
/> />
</> </>
); );
} }
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 { PageHeader, StatTile } from "@/components/crapp/ui-bits";
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
import { Avatar } from "@/components/crapp/Avatar"; 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 { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
import { import {
useRosa, useRosa,
@@ -15,6 +15,13 @@ import {
type CriterioClassifica, type CriterioClassifica,
} from "@/lib/rosa"; } from "@/lib/rosa";
import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese"; 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 { totaliSquadra, useScoutMatches } from "@/lib/scout-store";
import { useCsi } from "@/lib/csi"; import { useCsi } from "@/lib/csi";
import { partiteGiocate } from "@/lib/csi-core"; import { partiteGiocate } from "@/lib/csi-core";
@@ -87,6 +94,14 @@ function valore(
function Squadra() { function Squadra() {
const rosa = useRosa(); 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 mese = usePresenzeUltimoMeseTutti();
const scoutMatches = useScoutMatches(); const scoutMatches = useScoutMatches();
const { voti: pagelle } = usePagelle(); const { voti: pagelle } = usePagelle();
@@ -115,9 +130,36 @@ function Squadra() {
const contenutoRosa = useMemo( const contenutoRosa = useMemo(
() => ( () => (
<div className="space-y-2"> <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) => { {rosa.map((g) => {
const stati = badgeGiocatore(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; const isOpen = aperto === g.id;
return ( return (
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card"> <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: "Presenze di fila", v: g.streak },
{ l: "Media voto", v: g.mediaVoto || "—" }, { l: "Media voto", v: g.mediaVoto || "—" },
{ l: "MVP", v: g.mvp }, { l: "MVP", v: g.mvp },
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" }, ...(sonoAllenatore
? []
: [{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" }]),
].map((s) => ( ].map((s) => (
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center"> <div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
<p className="font-display text-xl leading-none">{s.v}</p> <p className="font-display text-xl leading-none">{s.v}</p>
@@ -196,37 +240,45 @@ function Squadra() {
))} ))}
</div> </div>
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground"> {sonoAllenatore ? null : (
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali} <>
</p> <p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
{sbloccati.length === 0 ? ( Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground"> </p>
Nessun badge sbloccato per ora. {sbloccati.length === 0 ? (
</p> <p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
) : ( Nessun badge sbloccato per ora.
<div className="mt-2 grid grid-cols-2 gap-2"> </p>
{sbloccati.map((b) => { ) : (
const Icon = b.def.icon; <div className="mt-2 grid grid-cols-2 gap-2">
const meta = gradoMeta[b.grado!]; {sbloccati.map((b) => {
return ( const Icon = b.def.icon;
<BadgeDrawer key={b.def.id} def={b.def} stato={b}> const meta = gradoMeta[b.grado!];
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}> return (
<Icon className={cn("h-4 w-4", meta.text)} /> <BadgeDrawer key={b.def.id} def={b.def} stato={b}>
<p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p> <div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
<p className="text-xs text-muted-foreground"> <Icon className={cn("h-4 w-4", meta.text)} />
{b.valore} {b.def.unita} <p className="mt-1 text-xs font-bold leading-tight">
{b.prossimaSoglia {b.def.nome}
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}` </p>
: ""} <p className="text-xs text-muted-foreground">
</p> {b.valore} {b.def.unita}
<p className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}> {b.prossimaSoglia
{meta.label} ? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
</p> : ""}
</div> </p>
</BadgeDrawer> <p
); className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}
})} >
</div> {meta.label}
</p>
</div>
</BadgeDrawer>
);
})}
</div>
)}
</>
)} )}
</div> </div>
) : null} ) : null}
@@ -235,7 +287,7 @@ function Squadra() {
})} })}
</div> </div>
), ),
[rosa, aperto, mese], [rosa, aperto, mese, allenatori, sonoAllenatore],
); );
const contenutoStats = useMemo( const contenutoStats = useMemo(
@@ -271,7 +323,7 @@ function Squadra() {
<DrawerTitle>Classifica per</DrawerTitle> <DrawerTitle>Classifica per</DrawerTitle>
</DrawerHeader> </DrawerHeader>
<div className="flex flex-col gap-1 px-4 pb-6"> <div className="flex flex-col gap-1 px-4 pb-6">
{criteri.map((c) => ( {criteriVisibili.map((c) => (
<DrawerClose key={c.id} asChild> <DrawerClose key={c.id} asChild>
<button <button
type="button" type="button"
@@ -326,7 +378,18 @@ function Squadra() {
</div> </div>
</div> </div>
), ),
[mediaPresenze, matchGiocati, pagelle, team, filtroAperto, criterio, ordinati, rank, max], [
mediaPresenze,
matchGiocati,
pagelle,
team,
filtroAperto,
criterio,
ordinati,
rank,
max,
criteriVisibili,
],
); );
const contenutoObiettivi = useMemo( const contenutoObiettivi = useMemo(
@@ -450,8 +513,12 @@ function Squadra() {
voci={[ voci={[
{ id: "rosa", label: "Rosa", contenuto: contenutoRosa }, { id: "rosa", label: "Rosa", contenuto: contenutoRosa },
{ id: "stats", label: "Stats", contenuto: contenutoStats }, { id: "stats", label: "Stats", contenuto: contenutoStats },
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi }, ...(sonoAllenatore
{ id: "badge", label: "Badge", contenuto: contenutoBadge }, ? []
: [
{ 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" }), 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 tokenGiocatore = await accedi(emailGiocatore);
const tokenAdmin = await accedi(emailAdmin); const tokenAdmin = await accedi(emailAdmin);
const tokenAllenatore = await accedi(emailAllenatore);
const server = await avviaServer(); const server = await avviaServer();
console.log(`permessi route su ${server.baseUrl} (database ${SUPABASE})`); 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. */ /** Messaggio libero: stesso controllo d'accesso, corpo diverso. */
const chiamaMessaggio = (intestazioni: Record<string, string> = {}) => const chiamaMessaggio = (intestazioni: Record<string, string> = {}) =>
fetch(`${server.baseUrl}/api/public/notifica-personalizzata`, { fetch(`${server.baseUrl}/api/public/notifica-personalizzata`, {
@@ -125,6 +150,11 @@ if (!locale) {
assert.equal(res.status, 403); 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 () => { await prova("un amministratore manda il messaggio (nessun dispositivo iscritto)", async () => {
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAdmin}` }); const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAdmin}` });
assert.equal(res.status, 200); assert.equal(res.status, 200);
+64 -1
View File
@@ -1,10 +1,14 @@
/** Check dell'anagrafica squadra: `bun test/unit/giocatori-squadra.test.ts`. */ /** Check dell'anagrafica squadra: `bun test/unit/giocatori-squadra.test.ts`. */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import {
daRigaSquadra,
inRosa,
isAllenatore,
nomeCompleto, nomeCompleto,
numeroGiaUsato, numeroGiaUsato,
prossimoIdGiocatore, prossimoIdGiocatore,
rosaFallback, rosaFallback,
ruoloVisibile,
slotDi, slotDi,
slotPerEmail, slotPerEmail,
validaDatiSquadra, validaDatiSquadra,
@@ -24,6 +28,7 @@ const riga = (parziale: Partial<GiocatoreSquadra> = {}): GiocatoreSquadra => ({
numeroTessera: null, numeroTessera: null,
dataTessera: null, dataTessera: null,
nascita: null, nascita: null,
tipo: "giocatore",
...parziale, ...parziale,
}); });
@@ -92,7 +97,14 @@ assert.equal(
assert.equal(slotPerEmail(rosaEmail, ""), null, "stringa vuota trattata come nessuna email"); assert.equal(slotPerEmail(rosaEmail, ""), null, "stringa vuota trattata come nessuna email");
// --- validaDatiSquadra ---------------------------------------------------------------- // --- 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.equal(validaDatiSquadra(datiOk), null, "dati validi: nessun errore");
assert.match(validaDatiSquadra({ ...datiOk, nome: "" })!, /nome/i); assert.match(validaDatiSquadra({ ...datiOk, nome: "" })!, /nome/i);
assert.match( assert.match(
@@ -155,5 +167,56 @@ assert.equal(
"conta solo il 7 attivo di g1, non serve escludere g3", "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(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"); 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" ------------ // --- destinatari del sollecito: chi non ha risposto, più i "forse" ------------
const squadra = [ const squadra = [
{ id: "g1", attivo: true }, { id: "g1", attivo: true, tipo: "giocatore" as const },
{ id: "g2", attivo: true }, { id: "g2", attivo: true, tipo: "giocatore" as const },
{ id: "g3", attivo: true }, { id: "g3", attivo: true, tipo: "giocatore" as const },
{ id: "g4", attivo: true }, { id: "g4", attivo: true, tipo: "giocatore" as const },
{ id: "g5", attivo: false }, // uscito dalla squadra: non lo si disturba più { 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 = [ const risposte = [
+31 -1
View File
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { import {
aRigaProfilo, aRigaProfilo,
completamento, completamento,
completamentoAllenatore,
csvTesseramento, csvTesseramento,
daRigaProfilo, daRigaProfilo,
sezioniComplete, sezioniComplete,
@@ -115,6 +116,7 @@ const squadra: GiocatoreSquadra[] = [
numeroTessera: null, numeroTessera: null,
dataTessera: null, dataTessera: null,
nascita: null, nascita: null,
tipo: "giocatore",
}, },
{ {
id: "g2", id: "g2",
@@ -128,6 +130,7 @@ const squadra: GiocatoreSquadra[] = [
numeroTessera: null, numeroTessera: null,
dataTessera: null, dataTessera: null,
nascita: null, nascita: null,
tipo: "giocatore",
}, },
]; ];
const csv = csvTesseramento(squadra, { g1: completo }); const csv = csvTesseramento(squadra, { g1: completo });
@@ -155,7 +158,14 @@ assert.equal(slotDi(squadra, "u2")?.id, "g2");
assert.equal(slotDi(squadra, "sconosciuto"), null); assert.equal(slotDi(squadra, "sconosciuto"), null);
// --- dati squadra modificabili dall'admin (DD-017) --------------------------- // --- 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.equal(validaDatiSquadra(datiOk), null);
assert.match(validaDatiSquadra({ ...datiOk, nome: " " }) ?? "", /nome/i); assert.match(validaDatiSquadra({ ...datiOk, nome: " " }) ?? "", /nome/i);
assert.match(validaDatiSquadra({ ...datiOk, cognome: "" }) ?? "", /cognome/i); assert.match(validaDatiSquadra({ ...datiOk, cognome: "" }) ?? "", /cognome/i);
@@ -184,4 +194,24 @@ assert.ok(
"il fallback non può collegare account", "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"); console.log("profili-core: ok");