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:
1 parent
15a010855e
commit
2ec72fd08f
35 files changed
+1215
-577
No files matched your search
@@ -3,7 +3,7 @@
|
||||
**Stato:** implementato
|
||||
**File principali:** `src/lib/eventi.ts`, `src/lib/eventi.server.ts`, `src/lib/calendario.ts`
|
||||
(griglia mensile condivisa), `src/routes/calendario.tsx` (vista mensile, tutti),
|
||||
`src/routes/eventi.tsx` (creazione/modifica, solo admin), `src/components/crapp/EventoCard.tsx`
|
||||
`src/routes/eventi.tsx` (creazione/modifica, admin e allenatori), `src/components/crapp/EventoCard.tsx`
|
||||
(card condivisa)
|
||||
**Test:** `test/unit/eventi.test.ts`, `test/unit/calendario.test.ts`
|
||||
|
||||
@@ -20,12 +20,14 @@ scout e turno palloni — la maggior parte degli altri moduli dipende da un `eve
|
||||
|
||||
- **`/calendario`** — vista mensile per tutta la squadra, sola lettura. Mostra allenamenti,
|
||||
partite, eventi ed **eventi virtuali** per i compleanni della rosa (`compleanniEventi()`
|
||||
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa()`, non righe
|
||||
in `eventi.ts`, generati a runtime dall'anagrafica di `useAnagraficaRosa({ conAllenatori:
|
||||
true })` — ci sono anche gli allenatori, DD-034 — non righe
|
||||
vere di `eventi_app`): la spunta della vista `giorniIT`/`mesiIT` colora la cella per tipo
|
||||
di evento, i giorni con più eventi si dividono lo spazio.
|
||||
- **`/eventi`** — "Gestione eventi", riservata agli amministratori (`useIsAdmin()`): crea,
|
||||
modifica ed elimina un evento, sceglie i convocati (`convocatiEvento()`, vuoto = tutta la
|
||||
rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
||||
- **`/eventi`** — "Gestione eventi", riservata ad amministratori e allenatori
|
||||
(`usePuoGestireEventi()`, [DD-034](../DESIGN_DECISIONS.md#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore)):
|
||||
crea, modifica ed elimina un evento, sceglie i convocati tra i soli giocatori
|
||||
(`convocatiEvento()`, vuoto = tutta la rosa). Da qui si distingue "partita" da "amichevole" tramite il flag `campionato`
|
||||
(`categoriaEvento()`/`daCategoria()` in `eventi.ts` convertono tra la categoria mostrata
|
||||
in interfaccia e la coppia `{ tipo, campionato }` salvata nel database). La pagina mostra
|
||||
solo una griglia mensile (stessa logica di `/calendario`, tramite le funzioni condivise di
|
||||
|
||||
@@ -14,6 +14,9 @@ L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al
|
||||
|
||||
## Utenti
|
||||
|
||||
L'allenatore ha un profilo ridotto (solo Docs con i dati personali essenziali e Opzioni):
|
||||
la specifica sta in [allenatore.md](allenatore.md).
|
||||
|
||||
### Giocatore
|
||||
|
||||
Può:
|
||||
@@ -35,7 +38,8 @@ Può:
|
||||
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo, email)
|
||||
- compilare e correggere i dati personali e del documento al posto di un giocatore (DD-017)
|
||||
- scollegare un account da un profilo, liberando lo slot
|
||||
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale)
|
||||
- aggiungere un nuovo giocatore alla rosa (id, nome, cognome, numero, ruolo, email opzionale),
|
||||
oppure un allenatore (tipo «Allenatore»: niente numero né ruolo, DD-034)
|
||||
- disattivare un giocatore che ha lasciato la squadra, e riattivarlo in caso di errore: la
|
||||
riga non viene eliminata, così presenze, voti, pagelle e badge della stagione restano
|
||||
agganciati al suo id
|
||||
@@ -179,6 +183,9 @@ alle notifiche push e i loro nomi, leggendo `GET /api/public/notifiche-attive` (
|
||||
giocatore deve fare dal proprio dispositivo (Profilo), l'admin non può attivarla per conto
|
||||
di altri.
|
||||
|
||||
Gli allenatori stanno nella stessa tab in un gruppo «Allenatori» a parte, senza documenti né
|
||||
tesseramento, e non entrano nei conteggi della tab Squadra né nell'export CSI (DD-034).
|
||||
|
||||
Per ogni giocatore, nella tab Profili, vengono mostrati.
|
||||
|
||||
- Stato del profilo
|
||||
|
||||
+18
-8
@@ -39,19 +39,29 @@ giocatore dalla dashboard admin non aveva alcun effetto sul resto dell'app.
|
||||
|
||||
Entrambe filtrano solo i giocatori `attivo`: chi ha lasciato la squadra resta nel database
|
||||
(presenze, voti, pagelle e badge della stagione restano agganciati al suo id) ma sparisce
|
||||
dagli elenchi correnti.
|
||||
dagli elenchi correnti. Il filtro è `inRosa()` (`giocatori-squadra.ts`), che esclude anche
|
||||
gli allenatori (`tipo = 'allenatore'`, DD-034): hanno uno slot ma non giocano.
|
||||
`useAnagraficaRosa({ conAllenatori: true })` li include, per i compleanni.
|
||||
|
||||
## Allenatori in Squadra
|
||||
|
||||
Nella tab Rosa gli allenatori attivi compaiono in cima, con foto, nome e la dicitura
|
||||
«Allenatore» dove i giocatori hanno il ruolo in campo (`ruoloVisibile()`); niente numero,
|
||||
statistiche né badge, e niente posto nella tab Stats. Chi usa l'app da allenatore vede solo
|
||||
Rosa e Stats, senza badge e senza cacche (criterio «Cacche» e riga «Cacche/partita»). La
|
||||
specifica completa sta in [allenatore.md](allenatore.md).
|
||||
|
||||
## Gestione dati squadra (solo amministratore)
|
||||
|
||||
Da `/admin` un amministratore può ([DD-017](../DESIGN_DECISIONS.md#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore)):
|
||||
|
||||
| Azione | Hook | Effetto |
|
||||
| ---------------------- | ------------------------ | ------------------------------------------------------------- |
|
||||
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
|
||||
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database |
|
||||
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
|
||||
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
|
||||
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
|
||||
| Azione | Hook | Effetto |
|
||||
| ------------------------------ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Modificare dati squadra | `useSalvaDatiSquadra()` | Nome, cognome, numero, ruolo, email (usata per il collegamento automatico, non il dato personale del profilo) |
|
||||
| Aggiungere un giocatore | `useAggiungiGiocatore()` | Nuova riga con id progressivo `g<N>` (`prossimoIdGiocatore()`), non generato dal database; con tipo «Allenatore» senza numero né ruolo (DD-034) |
|
||||
| Attivare/disattivare | `useImpostaAttivo()` | Non elimina la riga: la storia della stagione resta intatta |
|
||||
| Scollegare un account | `useScollegaAccount()` | Libera uno slot collegato per errore ([DD-016](../DESIGN_DECISIONS.md#dd-016--schema-dati-profilo-giocatore-f0) regola 2); il giocatore si ricollega al primo accesso successivo |
|
||||
| Registrare il tesseramento CSI | `useSalvaTesseramento()` | Numero e data tessera, note solo dopo il tesseramento effettivo (vedi `profilo-giocatore.md`) |
|
||||
|
||||
Il collegamento giocatore↔account, invece, non è manuale: avviene in automatico al primo
|
||||
accesso con Google, per corrispondenza email
|
||||
|
||||
@@ -14,9 +14,9 @@ import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import { formatData, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { inRosa, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { dataOggi } from "@/lib/scout-live";
|
||||
|
||||
const tipoMeta = {
|
||||
@@ -69,12 +69,12 @@ export function EventoCard({
|
||||
}) {
|
||||
const { risposte } = usePresenzeEvento(evento.id);
|
||||
const salva = useSalvaPresenza();
|
||||
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta): `useGiocatoreBase`
|
||||
// legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
|
||||
// Solo `io.id` serve qui (per leggere/scrivere la propria risposta, `null` per l'allenatore
|
||||
// che non risponde, DD-034): `useGiocatoreInCampo` legge la sola anagrafica, non le statistiche di tutta la rosa di `useGiocatoreCorrente`.
|
||||
// Rilevante perché ogni card monta questo hook: il Calendario ne rende diverse insieme.
|
||||
const io = useGiocatoreBase();
|
||||
const io = useGiocatoreInCampo();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const rosa = squadra.filter(inRosa);
|
||||
const stato = io ? risposte[io.id] : undefined;
|
||||
const presentiVeri = rosa.filter(
|
||||
(g) => risposte[g.id] === "presente" || risposte[g.id] === "ritardo",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import type { Giocatore } from "@/lib/crapp-data";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { mieiVoti, pagellePartita, usePagelle, useVotaPagella } from "@/lib/pagelle";
|
||||
|
||||
const voti = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
@@ -20,8 +20,8 @@ export function Pagelle({
|
||||
convocati: Giocatore[];
|
||||
chiuse?: boolean;
|
||||
}) {
|
||||
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||
const io = useGiocatoreBase();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const { voti: tutti, isPending } = usePagelle();
|
||||
const vota = useVotaPagella();
|
||||
const [apertoPer, setApertoPer] = useState<string | null>(null);
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
useSalvaProfilo,
|
||||
type SezioneFile,
|
||||
} from "@/lib/profili";
|
||||
import { useSalvaNomeAllenatore, type GiocatoreSquadra } from "@/lib/giocatori-squadra";
|
||||
import {
|
||||
completamento,
|
||||
completamentoAllenatore,
|
||||
profiloVuoto,
|
||||
sezioniComplete,
|
||||
type Profilo,
|
||||
@@ -244,6 +246,138 @@ export function CampiProfilo({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* I dati personali dell'allenatore (DD-034): gli stessi campi di `profili_giocatore`, meno
|
||||
* indirizzo, documento, certificato e foto tessera. Condivisi tra Profilo e dashboard admin.
|
||||
*/
|
||||
export function CampiAllenatore({
|
||||
corrente,
|
||||
aggiorna,
|
||||
}: {
|
||||
corrente: Profilo;
|
||||
aggiorna: (patch: Partial<Profilo>) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Data di nascita">
|
||||
<input
|
||||
type="date"
|
||||
value={corrente.dataNascita ?? ""}
|
||||
onChange={(e) => aggiorna({ dataNascita: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Luogo di nascita">
|
||||
<input
|
||||
value={corrente.luogoNascita ?? ""}
|
||||
maxLength={80}
|
||||
onChange={(e) => aggiorna({ luogoNascita: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Telefono">
|
||||
<input
|
||||
type="tel"
|
||||
value={corrente.telefono ?? ""}
|
||||
maxLength={20}
|
||||
onChange={(e) => aggiorna({ telefono: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Email">
|
||||
<input
|
||||
type="email"
|
||||
value={corrente.email ?? ""}
|
||||
maxLength={120}
|
||||
onChange={(e) => aggiorna({ email: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab Docs dell'allenatore: nome e cognome (sul proprio slot, M21) più i dati personali
|
||||
* ridotti. Un solo pulsante salva entrambi.
|
||||
*/
|
||||
export function ProfiloAllenatore({ g }: { g: GiocatoreSquadra }) {
|
||||
const { profili } = useProfili();
|
||||
const salvaProfilo = useSalvaProfilo();
|
||||
const salvaNome = useSalvaNomeAllenatore();
|
||||
const [bozza, setBozza] = useState<Profilo | null>(null);
|
||||
const [nome, setNome] = useState<{ nome: string; cognome: string } | null>(null);
|
||||
|
||||
const corrente = bozza ?? profili[g.id] ?? profiloVuoto(g.id);
|
||||
const nomeCorrente = nome ?? { nome: g.nome, cognome: g.cognome };
|
||||
const sporco = bozza !== null || nome !== null;
|
||||
const inCorso = salvaProfilo.isPending || salvaNome.isPending;
|
||||
const perc = completamentoAllenatore(corrente);
|
||||
|
||||
async function salva() {
|
||||
try {
|
||||
if (nome) await salvaNome.mutateAsync({ giocatoreId: g.id, ...nome });
|
||||
if (bozza) await salvaProfilo.mutateAsync(bozza);
|
||||
setNome(null);
|
||||
setBozza(null);
|
||||
toast.success("Profilo aggiornato");
|
||||
} catch (errore) {
|
||||
toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-grad transition-all"
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-bold tabular-nums text-muted-foreground">
|
||||
{perc}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Intestazione titolo="Dati personali" completa={perc === 100} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Nome">
|
||||
<input
|
||||
value={nomeCorrente.nome}
|
||||
maxLength={40}
|
||||
onChange={(e) => setNome({ ...nomeCorrente, nome: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Cognome">
|
||||
<input
|
||||
value={nomeCorrente.cognome}
|
||||
maxLength={40}
|
||||
onChange={(e) => setNome({ ...nomeCorrente, cognome: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<CampiAllenatore corrente={corrente} aggiorna={(p) => setBozza({ ...corrente, ...p })} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void salva()}
|
||||
disabled={!sporco || inCorso}
|
||||
className="premi flex w-full items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-50"
|
||||
>
|
||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{sporco ? "Salva" : "Salvato"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dati amministrativi del giocatore: quello che la dashboard amministratore poi legge.
|
||||
* Ogni giocatore scrive solo la propria riga — è la RLS a garantirlo, non questo componente.
|
||||
@@ -380,13 +514,18 @@ export function ProfiloAmministrativo({
|
||||
*/
|
||||
export function CompletaProfilo({
|
||||
giocatoreId,
|
||||
allenatore = false,
|
||||
indice = 0,
|
||||
}: {
|
||||
giocatoreId: string;
|
||||
/** L'allenatore ha solo i dati personali ridotti (DD-034). */
|
||||
allenatore?: boolean;
|
||||
indice?: number;
|
||||
}) {
|
||||
const { profili, isPending } = useProfili();
|
||||
const perc = completamento(profili[giocatoreId]);
|
||||
const perc = allenatore
|
||||
? completamentoAllenatore(profili[giocatoreId])
|
||||
: completamento(profili[giocatoreId]);
|
||||
if (isPending || perc === 100) return null;
|
||||
|
||||
return (
|
||||
@@ -409,7 +548,9 @@ export function CompletaProfilo({
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">
|
||||
Documento, certificato medico e foto tessera servono per il tesseramento CSI.
|
||||
{allenatore
|
||||
? "Data e luogo di nascita, telefono ed email."
|
||||
: "Documento, certificato medico e foto tessera servono per il tesseramento CSI."}
|
||||
</p>
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { formatData } from "@/lib/crapp-data";
|
||||
import { eventiPalloni, eventoPrecedente, eventoSuccessivo, oggiISO } from "@/lib/palloni-core";
|
||||
import { useTurniPalloni } from "@/lib/palloni";
|
||||
import { useEventi } from "@/lib/eventi";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
|
||||
/** Avvisi per chi è di turno: prendere i palloni oggi, o riportarli oggi. */
|
||||
export function PromemoriaPalloni() {
|
||||
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche. Montato in Home.
|
||||
const io = useGiocatoreBase();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034). Montato in Home.
|
||||
const io = useGiocatoreInCampo();
|
||||
const { turni } = useTurniPalloni();
|
||||
const { eventi } = useEventi();
|
||||
if (!io) return null;
|
||||
|
||||
@@ -8,9 +8,9 @@ import { Barra } from "@/components/motion/Barra";
|
||||
import { statoMeta, type Giocatore, type Stato } from "@/lib/crapp-data";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useGiocatoreBase, useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { intestazioniAutenticate } from "@/lib/auth";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { usePuoGestireEventi } from "@/lib/ruoli";
|
||||
import { dataOggi } from "@/lib/scout-live";
|
||||
|
||||
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
|
||||
@@ -20,8 +20,10 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
const salva = useSalvaPresenza();
|
||||
// Solo `.id`/`.nome` servono qui: `useGiocatoreBase`/`useAnagraficaRosa` bastano,
|
||||
// niente statistiche di squadra.
|
||||
const io = useGiocatoreBase();
|
||||
const admin = useIsAdmin();
|
||||
const io = useGiocatoreInCampo();
|
||||
// Il nome di chi sollecita: admin o allenatore, che non ha una risposta propria (DD-034).
|
||||
const mittente = useGiocatoreBase();
|
||||
const puoSollecitare = usePuoGestireEventi();
|
||||
const rosa = useAnagraficaRosa();
|
||||
const [sollecito, setSollecito] = useState(false);
|
||||
const passato = data < dataOggi();
|
||||
@@ -37,7 +39,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
const res = await fetch("/api/public/sollecita-presenze", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) },
|
||||
body: JSON.stringify({ eventoId, da: io?.nome }),
|
||||
body: JSON.stringify({ eventoId, da: mittente?.nome }),
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
const dati = (await res.json()) as { inviate: number; destinatari: number };
|
||||
@@ -115,7 +117,7 @@ export function RosaPresenze({ eventoId, data }: { eventoId: string; data: strin
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{admin ? (
|
||||
{puoSollecitare ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sollecita}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/crapp/ui-bits";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { intestazioniAutenticate } from "@/lib/auth";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import {
|
||||
@@ -27,8 +27,8 @@ export function SondaggioCacche({
|
||||
dataEvento: string;
|
||||
oraEvento: string;
|
||||
}) {
|
||||
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||
const io = useGiocatoreBase();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const { righe } = useCacche();
|
||||
const salva = useSalvaCacche();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { intestazioniAutenticate } from "@/lib/auth";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
@@ -18,7 +18,7 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) {
|
||||
const io = useGiocatoreBase();
|
||||
const admin = useIsAdmin();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const rosa = squadra.filter(inRosa);
|
||||
|
||||
const id = turni[eventoId];
|
||||
const proposto = !salvati[eventoId];
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import { Crown, Vote } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import { usePresenzeEvento } from "@/lib/presenze";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import {
|
||||
@@ -25,14 +25,14 @@ import {
|
||||
*/
|
||||
export function VotazioneMvp({ evento }: { evento: Evento }) {
|
||||
const matchId = evento.id;
|
||||
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||
const io = useGiocatoreBase();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const voti = useVotiMvp();
|
||||
const vota = useVotaMvp();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const { risposte } = usePresenzeEvento(evento.id);
|
||||
const presente = (id: string) => risposte[id] === "presente" || risposte[id] === "ritardo";
|
||||
const rosa = squadra.filter((g) => g.attivo && presente(g.id));
|
||||
const rosa = squadra.filter((g) => inRosa(g) && presente(g.id));
|
||||
const [aperto, setAperto] = useState(false);
|
||||
|
||||
const tutti: VotoMvp[] = voti.data ?? [];
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import { Check, Crown, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreInCampo } from "@/lib/user-store";
|
||||
import {
|
||||
categorieSocial,
|
||||
conteggioCategoria,
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
|
||||
/** Voto social post-partita: un compagno per categoria, veloce da mobile. */
|
||||
export function VotoSocial({ matchId }: { matchId: string }) {
|
||||
// Solo `.id` serve qui: `useGiocatoreBase` basta, niente statistiche.
|
||||
const io = useGiocatoreBase();
|
||||
// Solo `.id` serve qui, niente statistiche; `null` per l'allenatore, che non gioca (DD-034).
|
||||
const io = useGiocatoreInCampo();
|
||||
const voti = useVotiSocial();
|
||||
const vota = useVotaSocial();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const rosa = squadra.filter(inRosa);
|
||||
const [aperta, setAperta] = useState<string | null>(null);
|
||||
|
||||
const tutti = voti.data ?? [];
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
* è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non
|
||||
* può fare da credenziale.
|
||||
*
|
||||
* Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli
|
||||
* amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della
|
||||
* sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
|
||||
* Le route che mandano notifiche partono da un pulsante riservato agli amministratori, e il
|
||||
* sollecito presenze anche agli allenatori (DD-034): `richiediAdmin` e `richiediGestoreEventi`
|
||||
* verificano il token della sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può
|
||||
* proseguire, altrimenti la `Response` di rifiuto già pronta.
|
||||
*/
|
||||
|
||||
@@ -24,10 +24,14 @@ function tokenDaRichiesta(request: Request): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lascia passare solo un amministratore autenticato. `401` se manca o non vale il token,
|
||||
* `403` se il token è buono ma l'utente non è admin.
|
||||
* Lascia passare solo un utente autenticato con almeno uno dei `ruoli`. `401` se manca o non
|
||||
* vale il token, `403` se il token è buono ma il ruolo no.
|
||||
*/
|
||||
export async function richiediAdmin(request: Request): Promise<Response | null> {
|
||||
async function richiediRuolo(
|
||||
request: Request,
|
||||
ruoli: Array<"admin" | "allenatore">,
|
||||
rifiuto: string,
|
||||
): Promise<Response | null> {
|
||||
const token = tokenDaRichiesta(request);
|
||||
if (!token) return new Response("Autenticazione richiesta", { status: 401 });
|
||||
|
||||
@@ -37,13 +41,22 @@ export async function richiediAdmin(request: Request): Promise<Response | null>
|
||||
if (error || !utente?.user) return new Response("Sessione non valida", { status: 401 });
|
||||
|
||||
// Stessa fonte di `src/lib/ruoli.ts`: i permessi stanno solo in `user_roles` (DD-011).
|
||||
const { data: ruolo } = await supabaseAdmin
|
||||
const { data: righe } = await supabaseAdmin
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", utente.user.id)
|
||||
.eq("role", "admin")
|
||||
.maybeSingle();
|
||||
.in("role", ruoli);
|
||||
|
||||
if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 });
|
||||
if (!righe?.length) return new Response(rifiuto, { status: 403 });
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Solo un amministratore: promemoria palloni, messaggi, sondaggio, notifiche attive. */
|
||||
export function richiediAdmin(request: Request): Promise<Response | null> {
|
||||
return richiediRuolo(request, ["admin"], "Riservato agli amministratori");
|
||||
}
|
||||
|
||||
/** Admin o allenatore: chi gestisce gli eventi può anche sollecitare le presenze (DD-034). */
|
||||
export function richiediGestoreEventi(request: Request): Promise<Response | null> {
|
||||
return richiediRuolo(request, ["admin", "allenatore"], "Riservato a admin e allenatori");
|
||||
}
|
||||
@@ -7,10 +7,14 @@ import { dividiNome, giocatori } from "./crapp-data";
|
||||
* truth per il collegamento account ↔ giocatore; `crapp-data.ts` resta il fallback finché
|
||||
* la migrazione non è completa (DD-016 regola 1).
|
||||
*/
|
||||
/** Chi gioca e chi allena (DD-034): l'allenatore ha uno slot ma non è nella rosa di gioco. */
|
||||
export type TipoMembro = "giocatore" | "allenatore";
|
||||
|
||||
export type GiocatoreSquadra = {
|
||||
id: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
/** 0 per l'allenatore, che non ha numero di maglia (a database è NULL). */
|
||||
numero: number;
|
||||
ruolo: string;
|
||||
authUserId: string | null;
|
||||
@@ -20,13 +24,14 @@ export type GiocatoreSquadra = {
|
||||
dataTessera: string | null;
|
||||
/** Pubblica a tutta la rosa (M18): sincronizzata da `profili_giocatore.data_nascita`. */
|
||||
nascita: string | null;
|
||||
tipo: TipoMembro;
|
||||
};
|
||||
|
||||
export type RigaGiocatoreSquadra = {
|
||||
id: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
numero: number;
|
||||
numero: number | null;
|
||||
ruolo: string;
|
||||
auth_user_id: string | null;
|
||||
attivo: boolean;
|
||||
@@ -34,6 +39,7 @@ export type RigaGiocatoreSquadra = {
|
||||
numero_tessera: string | null;
|
||||
data_tessera: string | null;
|
||||
nascita: string | null;
|
||||
tipo: TipoMembro;
|
||||
};
|
||||
|
||||
/** Ruoli ammessi in campo (pallavolo): usati per il menu a tendina del profilo squadra. */
|
||||
@@ -54,6 +60,7 @@ export function rosaFallback(): GiocatoreSquadra[] {
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
nascita: g.nascita,
|
||||
tipo: "giocatore" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -61,6 +68,24 @@ export function nomeCompleto(g: GiocatoreSquadra): string {
|
||||
return `${g.nome} ${g.cognome}`.trim();
|
||||
}
|
||||
|
||||
export function isAllenatore(g: Pick<GiocatoreSquadra, "tipo"> | null | undefined): boolean {
|
||||
return g?.tipo === "allenatore";
|
||||
}
|
||||
|
||||
/**
|
||||
* Rosa di gioco: giocatori attivi, senza allenatori (DD-034). È il filtro di presenze,
|
||||
* convocati, voti, palloni, classifiche e badge; gli allenatori compaiono solo dove la
|
||||
* specifica lo dice (Rosa di Squadra, compleanni, promemoria evento).
|
||||
*/
|
||||
export function inRosa(g: Pick<GiocatoreSquadra, "attivo" | "tipo">): boolean {
|
||||
return g.attivo && g.tipo === "giocatore";
|
||||
}
|
||||
|
||||
/** Dove un giocatore ha il ruolo in campo, l'allenatore ha scritto «Allenatore». */
|
||||
export function ruoloVisibile(g: Pick<GiocatoreSquadra, "ruolo" | "tipo">): string {
|
||||
return g.tipo === "allenatore" ? "Allenatore" : g.ruolo;
|
||||
}
|
||||
|
||||
/** Lo slot già collegato a questo account, se esiste. */
|
||||
export function slotDi(
|
||||
righe: GiocatoreSquadra[],
|
||||
@@ -81,7 +106,7 @@ export function slotPerEmail(
|
||||
}
|
||||
|
||||
export const COLONNE_SQUADRA =
|
||||
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera, nascita";
|
||||
"id, nome, cognome, numero, ruolo, auth_user_id, attivo, email, numero_tessera, data_tessera, nascita, tipo";
|
||||
|
||||
/** Conversione riga database -> modello applicativo (riusabile anche lato server). */
|
||||
export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
||||
@@ -89,7 +114,7 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
||||
id: r.id,
|
||||
nome: r.nome,
|
||||
cognome: r.cognome,
|
||||
numero: r.numero,
|
||||
numero: r.numero ?? 0,
|
||||
ruolo: r.ruolo,
|
||||
authUserId: r.auth_user_id,
|
||||
attivo: r.attivo,
|
||||
@@ -97,6 +122,7 @@ export function daRigaSquadra(r: RigaGiocatoreSquadra): GiocatoreSquadra {
|
||||
numeroTessera: r.numero_tessera,
|
||||
dataTessera: r.data_tessera,
|
||||
nascita: r.nascita,
|
||||
tipo: r.tipo === "allenatore" ? "allenatore" : "giocatore",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,7 +146,10 @@ export function useGiocatoriSquadra() {
|
||||
|
||||
/** Dati squadra: li gestisce solo un amministratore (DD-017). L'email è quella usata per
|
||||
* il collegamento automatico al primo accesso (DD-018), non il dato personale del profilo. */
|
||||
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo" | "email">;
|
||||
export type DatiSquadra = Pick<
|
||||
GiocatoreSquadra,
|
||||
"nome" | "cognome" | "numero" | "ruolo" | "email" | "tipo"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
|
||||
@@ -130,6 +159,11 @@ export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" |
|
||||
export function validaDatiSquadra(dati: DatiSquadra): string | null {
|
||||
if (!dati.nome.trim()) return "Il nome non può essere vuoto.";
|
||||
if (!dati.cognome.trim()) return "Il cognome non può essere vuoto.";
|
||||
// L'allenatore non ha numero di maglia né ruolo in campo (DD-034).
|
||||
if (dati.tipo === "allenatore") {
|
||||
if (dati.email?.trim() && !dati.email.includes("@")) return "L'email non è valida.";
|
||||
return null;
|
||||
}
|
||||
if (!Number.isInteger(dati.numero) || dati.numero <= 0)
|
||||
return "Il numero di maglia deve essere maggiore di zero.";
|
||||
if (!dati.ruolo.trim()) return "Il ruolo non può essere vuoto.";
|
||||
@@ -152,7 +186,19 @@ export function numeroGiaUsato(
|
||||
giocatoreId: string,
|
||||
numero: number,
|
||||
): boolean {
|
||||
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
|
||||
return righe.some((g) => g.id !== giocatoreId && inRosa(g) && g.numero === numero);
|
||||
}
|
||||
|
||||
/** Riga da scrivere: all'allenatore numero NULL e ruolo vuoto, il tipo non cambia qui. */
|
||||
function rigaDatiSquadra(dati: DatiSquadra) {
|
||||
const allenatore = dati.tipo === "allenatore";
|
||||
return {
|
||||
nome: dati.nome.trim(),
|
||||
cognome: dati.cognome.trim(),
|
||||
numero: allenatore ? null : dati.numero,
|
||||
ruolo: allenatore ? "" : dati.ruolo.trim(),
|
||||
email: dati.email?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
|
||||
@@ -160,19 +206,13 @@ export function useSalvaDatiSquadra() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => {
|
||||
const dati = {
|
||||
nome: input.dati.nome.trim(),
|
||||
cognome: input.dati.cognome.trim(),
|
||||
numero: input.dati.numero,
|
||||
ruolo: input.dati.ruolo.trim(),
|
||||
email: input.dati.email?.trim() || null,
|
||||
};
|
||||
const riga = rigaDatiSquadra(input.dati);
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update(dati)
|
||||
.update(riga)
|
||||
.eq("id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
return { giocatoreId: input.giocatoreId, dati };
|
||||
return { giocatoreId: input.giocatoreId, dati: { ...riga, numero: riga.numero ?? 0 } };
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
@@ -225,19 +265,13 @@ export function useAggiungiGiocatore() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { id: string; dati: DatiSquadra }): Promise<GiocatoreSquadra> => {
|
||||
const riga = {
|
||||
id: input.id,
|
||||
nome: input.dati.nome.trim(),
|
||||
cognome: input.dati.cognome.trim(),
|
||||
numero: input.dati.numero,
|
||||
ruolo: input.dati.ruolo.trim(),
|
||||
email: input.dati.email?.trim() || null,
|
||||
};
|
||||
const riga = { id: input.id, tipo: input.dati.tipo, ...rigaDatiSquadra(input.dati) };
|
||||
const { error } = await supabaseNuoveTabelle.from("giocatori_squadra").insert(riga);
|
||||
if (error) throw error;
|
||||
// Le colonne non inviate hanno i default della tabella (M1): `attivo` true, il resto NULL.
|
||||
return {
|
||||
...riga,
|
||||
numero: riga.numero ?? 0,
|
||||
authUserId: null,
|
||||
attivo: true,
|
||||
numeroTessera: null,
|
||||
@@ -257,6 +291,32 @@ export function useAggiungiGiocatore() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nome e cognome scritti dall'allenatore sul proprio slot (DD-034): per un giocatore sono
|
||||
* dati squadra dell'admin, e il trigger di M21 rifiuta la stessa scrittura.
|
||||
*/
|
||||
export function useSalvaNomeAllenatore() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; nome: string; cognome: string }) => {
|
||||
const dati = { nome: input.nome.trim(), cognome: input.cognome.trim() };
|
||||
if (!dati.nome) throw new Error("Il nome non può essere vuoto.");
|
||||
if (!dati.cognome) throw new Error("Il cognome non può essere vuoto.");
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update(dati)
|
||||
.eq("id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
return { giocatoreId: input.giocatoreId, dati };
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Attiva o disattiva un giocatore (es. ha lasciato la squadra): non elimina la riga, così
|
||||
* presenze, voti, pagelle e badge della stagione restano agganciati al suo id.
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { completaTurni } from "./palloni-core";
|
||||
import { useEventi } from "./eventi";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
|
||||
export const TURNI_KEY = ["turni-palloni"] as const;
|
||||
|
||||
@@ -29,7 +29,7 @@ export function useTurniPalloni() {
|
||||
const { eventi } = useEventi();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const salvati = query.data ?? {};
|
||||
const rosa = squadra.filter((g) => g.attivo).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
const rosa = squadra.filter(inRosa).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
return { ...query, salvati, turni: completaTurni(salvati, eventi, rosa) };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEventi } from "./eventi";
|
||||
import { useRispostePresenze } from "./presenze";
|
||||
import { useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import { inRosa, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import {
|
||||
presenzeUltimoMese,
|
||||
presenzeUltimoMeseTutti,
|
||||
@@ -30,7 +30,7 @@ export function usePresenzeUltimoMeseTutti(): Record<string, StatistichePresenza
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
|
||||
return useMemo(() => {
|
||||
const idRosa = squadra.filter((g) => g.attivo).map((g) => g.id);
|
||||
const idRosa = squadra.filter(inRosa).map((g) => g.id);
|
||||
return presenzeUltimoMeseTutti(eventi, presenze, idRosa);
|
||||
}, [eventi, presenze, squadra]);
|
||||
}
|
||||
+5
-3
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import type { Stato } from "./crapp-data";
|
||||
import type { Evento } from "./eventi";
|
||||
import { inRosa, type TipoMembro } from "./giocatori-squadra";
|
||||
import { aggiornaSerie } from "./serie";
|
||||
import { dataOggi } from "./scout-live";
|
||||
|
||||
@@ -71,17 +72,18 @@ export function totaliEventiGiocatore(
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi va sollecitato per un evento: i giocatori attivi che non hanno ancora risposto, più
|
||||
* Chi va sollecitato per un evento: i giocatori attivi (non gli allenatori) che non hanno ancora risposto, più
|
||||
* quelli che hanno risposto «forse». Funzione pura, come `avvisiPalloniEvento()` per i
|
||||
* palloni: la route `/api/public/sollecita-presenze` la chiama con i dati che ha già letto.
|
||||
*/
|
||||
export function destinatariSollecito(
|
||||
squadra: Array<{ id: string; attivo: boolean }>,
|
||||
squadra: Array<{ id: string; attivo: boolean; tipo: TipoMembro }>,
|
||||
risposte: Array<{ giocatore_id: string; stato: string }>,
|
||||
): string[] {
|
||||
const stati = new Map(risposte.map((r) => [r.giocatore_id, r.stato]));
|
||||
// L'allenatore non risponde alle presenze, quindi non va sollecitato (DD-034).
|
||||
return squadra
|
||||
.filter((g) => g.attivo)
|
||||
.filter(inRosa)
|
||||
.filter((g) => {
|
||||
const stato = stati.get(g.id);
|
||||
return stato === undefined || stato === "forse";
|
||||
|
||||
@@ -144,6 +144,18 @@ export function completamento(p: Profilo | null | undefined): number {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'allenatore compila solo i dati personali ridotti (DD-034): niente indirizzo, documento,
|
||||
* certificato né foto tessera, che servono al tesseramento dei giocatori.
|
||||
*/
|
||||
export const CAMPI_ALLENATORE = ["dataNascita", "luogoNascita", "telefono", "email"] as const;
|
||||
|
||||
/** Percentuale dei campi dell'allenatore compilati: 100 quando ci sono tutti. */
|
||||
export function completamentoAllenatore(p: Profilo | null | undefined): number {
|
||||
const pieni = CAMPI_ALLENATORE.filter((c) => !!p?.[c]?.trim()).length;
|
||||
return Math.round((pieni / CAMPI_ALLENATORE.length) * 100);
|
||||
}
|
||||
|
||||
export type StatoScadenza = "mancante" | "scaduto" | "valido";
|
||||
|
||||
/** Un certificato scaduto blocca il tesseramento: per l'admin non vale come presente. */
|
||||
|
||||
+35
-34
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "./giocatori-squadra";
|
||||
import { mvpVintiPerGiocatore, useVotiMvp } from "./mvp-voti";
|
||||
import { mediePagelle, usePagelle } from "./pagelle";
|
||||
import { statisticheCacche, useCacche } from "./cacche";
|
||||
@@ -27,18 +27,21 @@ function iniziali(nome: string, cognome: string): string {
|
||||
|
||||
/**
|
||||
* Solo anagrafica (id, nome, ruolo, numero, data di nascita) dei giocatori attivi — es.
|
||||
* per i compleanni nel Calendario o le liste presenze. A differenza di `useRosa` non
|
||||
* legge MVP, pagelle, cacche, palloni né infortuni: evita di montare quei cinque hook e
|
||||
* il relativo `useMemo` solo per l'anagrafica.
|
||||
* per le liste presenze o lo scout. A differenza di `useRosa` non legge MVP, pagelle,
|
||||
* cacche, palloni né infortuni: evita di montare quei cinque hook e il relativo `useMemo`
|
||||
* solo per l'anagrafica. Con `conAllenatori` include anche gli allenatori attivi, per chi
|
||||
* elenca i membri della squadra e non solo chi gioca (compleanni in Calendario, DD-034).
|
||||
*/
|
||||
export function useAnagraficaRosa(): Array<
|
||||
export function useAnagraficaRosa({
|
||||
conAllenatori = false,
|
||||
}: { conAllenatori?: boolean } = {}): Array<
|
||||
Pick<Giocatore, "id" | "nome" | "ruolo" | "numero" | "nascita">
|
||||
> {
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
return useMemo(
|
||||
() =>
|
||||
squadra
|
||||
.filter((g) => g.attivo)
|
||||
.filter((g) => (conAllenatori ? g.attivo : inRosa(g)))
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
nome: nomeCompleto(g),
|
||||
@@ -46,7 +49,7 @@ export function useAnagraficaRosa(): Array<
|
||||
numero: g.numero,
|
||||
nascita: g.nascita ?? "",
|
||||
})),
|
||||
[squadra],
|
||||
[squadra, conAllenatori],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +57,7 @@ export function useAnagraficaRosa(): Array<
|
||||
* Rosa completa con tutte le statistiche personali (presenze, MVP, media voto,
|
||||
* palloni, infortuni, ritardi, cacche). Legge l'anagrafica da `giocatori_squadra`
|
||||
* (DD-015): solo i giocatori attivi, gli altri restano nel database ma spariscono
|
||||
* dagli elenchi correnti. Usa solo cache già in memoria: nessuna query aggiuntiva
|
||||
* dagli elenchi correnti. Gli allenatori non ci sono (DD-034): non hanno statistiche. Usa solo cache già in memoria: nessuna query aggiuntiva
|
||||
* rispetto a quelle che l'app fa comunque.
|
||||
*/
|
||||
export function useRosa(): Giocatore[] {
|
||||
@@ -78,32 +81,30 @@ export function useRosa(): Giocatore[] {
|
||||
const palloni = conteggioTurni(turniSalvati, eventi);
|
||||
const mvpVinti = mvpVintiPerGiocatore(votiMvp ?? []);
|
||||
|
||||
return squadra
|
||||
.filter((g) => g.attivo)
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
nome: nomeCompleto(g),
|
||||
numero: g.numero,
|
||||
ruolo: g.ruolo,
|
||||
nascita: g.nascita ?? "",
|
||||
iniziali: iniziali(g.nome, g.cognome),
|
||||
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
|
||||
totaliEventi: totaliEventiGiocatore(g.id, eventi),
|
||||
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
|
||||
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
|
||||
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
|
||||
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
|
||||
serieConferme: serieConferme(g.id, eventi, tempi),
|
||||
mvp: mvpVinti[g.id] ?? 0,
|
||||
mediaVoto: medie[g.id]?.media ?? 0,
|
||||
votiPagella: medie[g.id]?.voti ?? 0,
|
||||
palloni: palloni[g.id] ?? 0,
|
||||
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
|
||||
cacche: statCacche[g.id]?.giornateTop ?? 0,
|
||||
cacchePartita: statCacche[g.id]?.media ?? 0,
|
||||
infortuni: infortuni[g.id] ?? 0,
|
||||
ritardi: ritardi[g.id] ?? 0,
|
||||
}));
|
||||
return squadra.filter(inRosa).map((g) => ({
|
||||
id: g.id,
|
||||
nome: nomeCompleto(g),
|
||||
numero: g.numero,
|
||||
ruolo: g.ruolo,
|
||||
nascita: g.nascita ?? "",
|
||||
iniziali: iniziali(g.nome, g.cognome),
|
||||
presenze: contaPresenzeGiocatore(g.id, eventi, mappaPresenze),
|
||||
totaliEventi: totaliEventiGiocatore(g.id, eventi),
|
||||
partiteGiocate: contaPartiteGiocate(g.id, eventi, mappaPresenze),
|
||||
streak: serieConsecutiva(g.id, eventi, mappaPresenze),
|
||||
serieAllenamenti: serieConsecutiva(g.id, eventi, mappaPresenze, "allenamento"),
|
||||
seriePartite: serieConsecutiva(g.id, eventi, mappaPresenze, "partita"),
|
||||
serieConferme: serieConferme(g.id, eventi, tempi),
|
||||
mvp: mvpVinti[g.id] ?? 0,
|
||||
mediaVoto: medie[g.id]?.media ?? 0,
|
||||
votiPagella: medie[g.id]?.voti ?? 0,
|
||||
palloni: palloni[g.id] ?? 0,
|
||||
seriePalloni: serieConsecutivaPalloni(g.id, turniSalvati, eventi),
|
||||
cacche: statCacche[g.id]?.giornateTop ?? 0,
|
||||
cacchePartita: statCacche[g.id]?.media ?? 0,
|
||||
infortuni: infortuni[g.id] ?? 0,
|
||||
ritardi: ritardi[g.id] ?? 0,
|
||||
}));
|
||||
}, [
|
||||
squadra,
|
||||
votiMvp,
|
||||
|
||||
+26
-10
@@ -2,33 +2,49 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useSessione } from "./auth";
|
||||
|
||||
export const RUOLI_KEY = ["ruolo-admin"] as const;
|
||||
export const RUOLI_KEY = ["ruoli-utente"] as const;
|
||||
|
||||
export type Ruolo = "admin" | "allenatore";
|
||||
|
||||
/**
|
||||
* Permessi di amministrazione: unica fonte è `user_roles` nel database (DD-011).
|
||||
* Nessuna lista di nomi, altrimenti basterebbe scegliere il nome giusto per amministrare.
|
||||
* Permessi: unica fonte è `user_roles` nel database (DD-011). Nessuna lista di nomi,
|
||||
* altrimenti basterebbe scegliere il nome giusto per amministrare. Il ruolo `allenatore`
|
||||
* lo scrive un trigger quando si collega uno slot di tipo allenatore (M21, DD-034).
|
||||
*/
|
||||
|
||||
/** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */
|
||||
async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> {
|
||||
async function fetchRuoli(utenteId: string | null): Promise<Ruolo[] | null> {
|
||||
if (!utenteId) return null;
|
||||
const { data, error } = await supabase
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", utenteId)
|
||||
.eq("role", "admin")
|
||||
.maybeSingle();
|
||||
.in("role", ["admin", "allenatore"]);
|
||||
if (error) throw error;
|
||||
return !!data;
|
||||
return (data ?? []).map((r) => r.role as Ruolo);
|
||||
}
|
||||
|
||||
export function useIsAdmin(): boolean {
|
||||
function useRuoli(): Ruolo[] {
|
||||
const { utenteId } = useSessione();
|
||||
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
|
||||
const query = useQuery({
|
||||
queryKey: [...RUOLI_KEY, utenteId],
|
||||
queryFn: () => fetchRuoloAdmin(utenteId),
|
||||
queryFn: () => fetchRuoli(utenteId),
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
return query.data === true;
|
||||
return query.data ?? [];
|
||||
}
|
||||
|
||||
export function useIsAdmin(): boolean {
|
||||
return useRuoli().includes("admin");
|
||||
}
|
||||
|
||||
export function useIsAllenatore(): boolean {
|
||||
return useRuoli().includes("allenatore");
|
||||
}
|
||||
|
||||
/** Chi crea e modifica gli eventi e sollecita le presenze: admin o allenatore (DD-034). */
|
||||
export function usePuoGestireEventi(): boolean {
|
||||
const ruoli = useRuoli();
|
||||
return ruoli.includes("admin") || ruoli.includes("allenatore");
|
||||
}
|
||||
+18
-1
@@ -1,6 +1,6 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { Giocatore } from "./crapp-data";
|
||||
import { useGiocatoriSquadra, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
import { isAllenatore, useGiocatoriSquadra, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
import { useIo } from "./rosa";
|
||||
|
||||
const KEY = "crapp-user-v1";
|
||||
@@ -56,6 +56,23 @@ export function useGiocatoreBase(): GiocatoreSquadra | null {
|
||||
return id ? (righe.find((x) => x.id === id) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chi usa l'app è un allenatore (DD-034): decide cosa si vede (niente badge, cacche,
|
||||
* stagione, obiettivi). I permessi veri stanno in `user_roles` (`usePuoGestireEventi`).
|
||||
*/
|
||||
export function useSonoAllenatore(): boolean {
|
||||
return isAllenatore(useGiocatoreBase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Il proprio slot solo se è di un giocatore: per rispondere alle presenze, votare,
|
||||
* dichiarare le cacche o portare i palloni. Per l'allenatore è `null`.
|
||||
*/
|
||||
export function useGiocatoreInCampo(): GiocatoreSquadra | null {
|
||||
const g = useGiocatoreBase();
|
||||
return g && !isAllenatore(g) ? g : null;
|
||||
}
|
||||
|
||||
/** Giocatore corrente con statistiche calcolate da dati reali (presenze, MVP, badge, …). */
|
||||
export function useGiocatoreCorrente(): Giocatore | null {
|
||||
return useIo();
|
||||
|
||||
+213
-130
@@ -21,12 +21,15 @@ import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Campo, classiInput, PageHeader, Select, StatTile } from "@/components/crapp/ui-bits";
|
||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { CampiAllenatore, CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import {
|
||||
inRosa,
|
||||
isAllenatore,
|
||||
nomeCompleto,
|
||||
numeroGiaUsato,
|
||||
prossimoIdGiocatore,
|
||||
RUOLI,
|
||||
ruoloVisibile,
|
||||
useAggiungiGiocatore,
|
||||
useGiocatoriSquadra,
|
||||
useImpostaAttivo,
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
|
||||
import {
|
||||
completamento,
|
||||
completamentoAllenatore,
|
||||
csvTesseramento,
|
||||
profiloVuoto,
|
||||
sezioniComplete,
|
||||
@@ -126,7 +130,10 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
numero: g.numero,
|
||||
ruolo: g.ruolo,
|
||||
email: g.email,
|
||||
tipo: g.tipo,
|
||||
};
|
||||
// L'allenatore non ha numero, ruolo in campo né tesseramento (DD-034).
|
||||
const allenatore = isAllenatore(g);
|
||||
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
|
||||
|
||||
async function confermaSquadra() {
|
||||
@@ -135,7 +142,7 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
toast.error(errore);
|
||||
return;
|
||||
}
|
||||
if (numeroGiaUsato(righe, g.id, squadraCorrente.numero)) {
|
||||
if (!allenatore && numeroGiaUsato(righe, g.id, squadraCorrente.numero)) {
|
||||
toast.error(`Il numero ${squadraCorrente.numero} è già assegnato a un altro giocatore.`);
|
||||
return;
|
||||
}
|
||||
@@ -216,28 +223,34 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Numero">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={squadraCorrente.numero}
|
||||
onChange={(e) => setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Ruolo">
|
||||
<Select
|
||||
value={squadraCorrente.ruolo}
|
||||
onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{RUOLI.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Campo>
|
||||
{allenatore ? null : (
|
||||
<>
|
||||
<Campo label="Numero">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={squadraCorrente.numero}
|
||||
onChange={(e) =>
|
||||
setDatiSquadra({ ...squadraCorrente, numero: Number(e.target.value) })
|
||||
}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Ruolo">
|
||||
<Select
|
||||
value={squadraCorrente.ruolo}
|
||||
onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{RUOLI.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Campo>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Campo label="Email">
|
||||
<input
|
||||
@@ -257,44 +270,61 @@ function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profi
|
||||
Salva dati squadra
|
||||
</button>
|
||||
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Numero tessera">
|
||||
<input
|
||||
value={tesseramentoCorrente.numeroTessera ?? ""}
|
||||
maxLength={40}
|
||||
placeholder="Non ancora tesserato"
|
||||
onChange={(e) =>
|
||||
setTesseramento({ ...tesseramentoCorrente, numeroTessera: e.target.value || null })
|
||||
}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Data tessera">
|
||||
<input
|
||||
type="date"
|
||||
value={tesseramentoCorrente.dataTessera ?? ""}
|
||||
onChange={(e) =>
|
||||
setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null })
|
||||
}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confermaTesseramento}
|
||||
disabled={!tesseramento || salvaTesseramento.isPending}
|
||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
Salva tesseramento
|
||||
</button>
|
||||
{allenatore ? null : (
|
||||
<>
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">Tesseramento CSI</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Numero tessera">
|
||||
<input
|
||||
value={tesseramentoCorrente.numeroTessera ?? ""}
|
||||
maxLength={40}
|
||||
placeholder="Non ancora tesserato"
|
||||
onChange={(e) =>
|
||||
setTesseramento({
|
||||
...tesseramentoCorrente,
|
||||
numeroTessera: e.target.value || null,
|
||||
})
|
||||
}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Data tessera">
|
||||
<input
|
||||
type="date"
|
||||
value={tesseramentoCorrente.dataTessera ?? ""}
|
||||
onChange={(e) =>
|
||||
setTesseramento({ ...tesseramentoCorrente, dataTessera: e.target.value || null })
|
||||
}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confermaTesseramento}
|
||||
disabled={!tesseramento || salvaTesseramento.isPending}
|
||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
Salva tesseramento
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<CampiProfilo
|
||||
corrente={profiloCorrente}
|
||||
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
|
||||
sezioni={sezioniComplete(profiloCorrente)}
|
||||
/>
|
||||
{allenatore ? (
|
||||
<>
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">Dati personali</h3>
|
||||
<CampiAllenatore
|
||||
corrente={profiloCorrente}
|
||||
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CampiProfilo
|
||||
corrente={profiloCorrente}
|
||||
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
|
||||
sezioni={sezioniComplete(profiloCorrente)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={confermaProfilo}
|
||||
@@ -402,8 +432,9 @@ function SchedaGiocatore({
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const nome = nomeCompleto(g);
|
||||
const { ruolo, numero } = g;
|
||||
const perc = completamento(profilo);
|
||||
const { numero } = g;
|
||||
const allenatore = isAllenatore(g);
|
||||
const perc = allenatore ? completamentoAllenatore(profilo) : completamento(profilo);
|
||||
const sezioni = sezioniComplete(profilo);
|
||||
const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi);
|
||||
const fronte = statoScadenza(profilo?.documentoScadenza, profilo?.documentoFrontePath, oggi);
|
||||
@@ -413,12 +444,12 @@ function SchedaGiocatore({
|
||||
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
|
||||
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 text-left">
|
||||
<div className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-secondary font-display text-sm tabular-nums">
|
||||
{numero}
|
||||
{allenatore ? "All" : numero}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold leading-tight">{nome}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ruolo} · profilo {perc}%
|
||||
{ruoloVisibile(g)} · profilo {perc}%
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
@@ -436,55 +467,65 @@ function SchedaGiocatore({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Documento
|
||||
icona={<IdCard className="h-3.5 w-3.5" />}
|
||||
label="Doc fronte"
|
||||
stato={fronte}
|
||||
path={profilo?.documentoFrontePath ?? null}
|
||||
/>
|
||||
<Documento
|
||||
icona={<IdCard className="h-3.5 w-3.5" />}
|
||||
label="Doc retro"
|
||||
stato={retro}
|
||||
path={profilo?.documentoRetroPath ?? null}
|
||||
/>
|
||||
<Documento
|
||||
icona={<FileText className="h-3.5 w-3.5" />}
|
||||
label="Certificato"
|
||||
stato={certificato}
|
||||
path={profilo?.certificatoPath ?? null}
|
||||
/>
|
||||
<Documento
|
||||
icona={<Image className="h-3.5 w-3.5" />}
|
||||
label="Foto"
|
||||
stato={sezioni.foto ? "presente" : "assente"}
|
||||
path={profilo?.fotoPath ?? null}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
|
||||
statoClasse[g.numeroTessera ? "presente" : "assente"],
|
||||
)}
|
||||
>
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
{g.numeroTessera ? "Tesserato" : "Da tesserare"}
|
||||
</span>
|
||||
</div>
|
||||
{allenatore ? null : (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Documento
|
||||
icona={<IdCard className="h-3.5 w-3.5" />}
|
||||
label="Doc fronte"
|
||||
stato={fronte}
|
||||
path={profilo?.documentoFrontePath ?? null}
|
||||
/>
|
||||
<Documento
|
||||
icona={<IdCard className="h-3.5 w-3.5" />}
|
||||
label="Doc retro"
|
||||
stato={retro}
|
||||
path={profilo?.documentoRetroPath ?? null}
|
||||
/>
|
||||
<Documento
|
||||
icona={<FileText className="h-3.5 w-3.5" />}
|
||||
label="Certificato"
|
||||
stato={certificato}
|
||||
path={profilo?.certificatoPath ?? null}
|
||||
/>
|
||||
<Documento
|
||||
icona={<Image className="h-3.5 w-3.5" />}
|
||||
label="Foto"
|
||||
stato={sezioni.foto ? "presente" : "assente"}
|
||||
path={profilo?.fotoPath ?? null}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-bold uppercase",
|
||||
statoClasse[g.numeroTessera ? "presente" : "assente"],
|
||||
)}
|
||||
>
|
||||
<BadgeCheck className="h-3.5 w-3.5" />
|
||||
{g.numeroTessera ? "Tesserato" : "Da tesserare"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{aperta ? <ModificaGiocatore g={g} profilo={profilo} /> : null}
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
const datiVuoti: DatiSquadra = { nome: "", cognome: "", numero: 1, ruolo: "", email: null };
|
||||
const datiVuoti: DatiSquadra = {
|
||||
nome: "",
|
||||
cognome: "",
|
||||
numero: 1,
|
||||
ruolo: "",
|
||||
email: null,
|
||||
tipo: "giocatore",
|
||||
};
|
||||
|
||||
/** Form per aggiungere un giocatore alla rosa (DD-017): l'id `g<N>` è calcolato in app,
|
||||
* il database non lo genera da solo. */
|
||||
/** Form per aggiungere un giocatore alla rosa (DD-017), o un allenatore (DD-034): l'id
|
||||
* `g<N>` è calcolato in app, il database non lo genera da solo. */
|
||||
function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||
const [aperto, setAperto] = useState(false);
|
||||
const [dati, setDati] = useState<DatiSquadra>(datiVuoti);
|
||||
const aggiungi = useAggiungiGiocatore();
|
||||
const allenatore = dati.tipo === "allenatore";
|
||||
|
||||
async function conferma() {
|
||||
const errore = validaDatiSquadra(dati);
|
||||
@@ -492,13 +533,17 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||
toast.error(errore);
|
||||
return;
|
||||
}
|
||||
if (numeroGiaUsato(righe, "", dati.numero)) {
|
||||
if (!allenatore && numeroGiaUsato(righe, "", dati.numero)) {
|
||||
toast.error(`Il numero ${dati.numero} è già assegnato a un altro giocatore.`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await aggiungi.mutateAsync({ id: prossimoIdGiocatore(righe), dati });
|
||||
toast.success(`${dati.nome} ${dati.cognome} aggiunto alla rosa`);
|
||||
toast.success(
|
||||
allenatore
|
||||
? `${dati.nome} ${dati.cognome} aggiunto come allenatore`
|
||||
: `${dati.nome} ${dati.cognome} aggiunto alla rosa`,
|
||||
);
|
||||
setDati(datiVuoti);
|
||||
setAperto(false);
|
||||
} catch (e) {
|
||||
@@ -513,14 +558,27 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||
onClick={() => setAperto(true)}
|
||||
className="premi mt-3 flex w-full items-center justify-center gap-2 rounded-2xl bg-secondary py-3 text-sm font-bold uppercase"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" /> Aggiungi giocatore
|
||||
<UserPlus className="h-4 w-4" /> Aggiungi giocatore o allenatore
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 rounded-2xl bg-card p-4 shadow-card">
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">Nuovo giocatore</h3>
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">
|
||||
{allenatore ? "Nuovo allenatore" : "Nuovo giocatore"}
|
||||
</h3>
|
||||
<Campo label="Tipo">
|
||||
<Select
|
||||
value={dati.tipo}
|
||||
onChange={(e) =>
|
||||
setDati({ ...dati, tipo: e.target.value === "allenatore" ? "allenatore" : "giocatore" })
|
||||
}
|
||||
>
|
||||
<option value="giocatore">Giocatore</option>
|
||||
<option value="allenatore">Allenatore</option>
|
||||
</Select>
|
||||
</Campo>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Nome">
|
||||
<input
|
||||
@@ -538,25 +596,32 @@ function AggiungiGiocatore({ righe }: { righe: GiocatoreSquadra[] }) {
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Numero">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={dati.numero}
|
||||
onChange={(e) => setDati({ ...dati, numero: Number(e.target.value) })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Ruolo">
|
||||
<Select value={dati.ruolo} onChange={(e) => setDati({ ...dati, ruolo: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{RUOLI.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Campo>
|
||||
{allenatore ? null : (
|
||||
<>
|
||||
<Campo label="Numero">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={dati.numero}
|
||||
onChange={(e) => setDati({ ...dati, numero: Number(e.target.value) })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Ruolo">
|
||||
<Select
|
||||
value={dati.ruolo}
|
||||
onChange={(e) => setDati({ ...dati, ruolo: e.target.value })}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{RUOLI.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Campo>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Campo label="Email">
|
||||
<input
|
||||
@@ -610,7 +675,7 @@ function GiocatoreDisattivato({ g }: { g: GiocatoreSquadra }) {
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold leading-tight">{nomeCompleto(g)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
#{g.numero} · {g.ruolo}
|
||||
{isAllenatore(g) ? ruoloVisibile(g) : `#${g.numero} · ${g.ruolo}`}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -673,7 +738,11 @@ function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
const attivi = squadra.filter((g) => g.attivo);
|
||||
// Conteggi, tesseramento ed export CSI riguardano solo chi gioca (DD-034).
|
||||
const attivi = squadra.filter(inRosa);
|
||||
const allenatori = squadra.filter((g) => g.attivo && isAllenatore(g));
|
||||
// Le notifiche arrivano anche agli allenatori: nella tab Notifiche ci sono tutti.
|
||||
const membri = [...attivi, ...allenatori];
|
||||
const disattivi = squadra.filter((g) => !g.attivo);
|
||||
const completi = attivi.filter((g) => completamento(profili[g.id]) === 100).length;
|
||||
const certificatiOk = attivi.filter(
|
||||
@@ -723,6 +792,20 @@ function Dashboard() {
|
||||
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
|
||||
/>
|
||||
))}
|
||||
{allenatori.length > 0 ? (
|
||||
<h3 className="pt-2 font-display text-sm uppercase tracking-wide">Allenatori</h3>
|
||||
) : null}
|
||||
{allenatori.map((g, i) => (
|
||||
<SchedaGiocatore
|
||||
key={g.id}
|
||||
g={g}
|
||||
profilo={profili[g.id]}
|
||||
oggi={oggi}
|
||||
indice={attivi.length + i}
|
||||
aperta={schedaAperta === g.id}
|
||||
onToggle={() => setSchedaAperta((v) => (v === g.id ? null : g.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -737,7 +820,7 @@ function Dashboard() {
|
||||
const contenutoNotifiche = notificheAttive ? (
|
||||
<>
|
||||
<StatTile
|
||||
valore={`${attivi.filter((g) => notificheAttive.has(g.id)).length}/${attivi.length}`}
|
||||
valore={`${membri.filter((g) => notificheAttive.has(g.id)).length}/${membri.length}`}
|
||||
label="Notifiche attive"
|
||||
/>
|
||||
<button
|
||||
@@ -748,7 +831,7 @@ function Dashboard() {
|
||||
<Send className="h-4 w-4" /> Invia messaggio a tutti
|
||||
</button>
|
||||
<div className="mt-3 space-y-2">
|
||||
{attivi.map((g) => {
|
||||
{membri.map((g) => {
|
||||
const attiva = notificheAttive.has(g.id);
|
||||
return (
|
||||
<div key={g.id} className="flex items-center gap-2 rounded-2xl bg-card p-3 shadow-card">
|
||||
|
||||
@@ -3,6 +3,8 @@ import { z } from "zod";
|
||||
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
import { isAllenatore } from "@/lib/giocatori-squadra";
|
||||
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
|
||||
|
||||
const schema = z.object({ eventoId: z.string().min(1).max(50) });
|
||||
|
||||
@@ -22,15 +24,20 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
|
||||
if (!partita) return new Response("Evento non trovato", { status: 404 });
|
||||
|
||||
const { supabaseAdmin } = await import("@/integrations/supabase/client.server");
|
||||
const { data: iscrizioni } = await supabaseAdmin
|
||||
const { data: tutte } = await supabaseAdmin
|
||||
.from("push_subscriptions")
|
||||
.select("endpoint, p256dh, auth");
|
||||
.select("endpoint, p256dh, auth, giocatore_id");
|
||||
// Il sondaggio cacche non riguarda l'allenatore, che non le vede (DD-034).
|
||||
const allenatori = new Set(
|
||||
(await leggiGiocatoriSquadra()).filter(isAllenatore).map((g) => g.id),
|
||||
);
|
||||
const iscrizioni = (tutte ?? []).filter((i) => !allenatori.has(i.giocatore_id));
|
||||
|
||||
const titolo = "💩 Sondaggio pre-partita aperto";
|
||||
const testo = `${partita.titolo} · ore ${partita.ora}. Quante cacche hai fatto? Rispondi prima del fischio d'inizio.`;
|
||||
|
||||
let inviate = 0;
|
||||
for (const iscrizione of iscrizioni ?? []) {
|
||||
for (const iscrizione of iscrizioni) {
|
||||
try {
|
||||
const { stato } = await inviaPush(iscrizione, titolo, testo);
|
||||
if (stato === 404 || stato === 410) {
|
||||
@@ -46,7 +53,7 @@ export const Route = createFileRoute("/api/public/apri-sondaggio")({
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ inviate, destinatari: (iscrizioni ?? []).length });
|
||||
return Response.json({ inviate, destinatari: iscrizioni.length });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
import { nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
import { inRosa, nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
|
||||
import { avvisiPalloniEvento, completaTurni } from "@/lib/palloni-core";
|
||||
import { inviaPush } from "@/lib/webpush.server";
|
||||
@@ -37,9 +37,7 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({
|
||||
for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id;
|
||||
|
||||
const squadra = await leggiGiocatoriSquadra();
|
||||
const rosa = squadra
|
||||
.filter((g) => g.attivo)
|
||||
.map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
const rosa = squadra.filter(inRosa).map((g) => ({ id: g.id, nome: nomeCompleto(g) }));
|
||||
const turni = completaTurni(salvati, eventi, rosa);
|
||||
|
||||
const avvisi = avvisiPalloniEvento(turni, eventi, evento.id);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { z } from "zod";
|
||||
import { richiediAdmin } from "@/lib/auth-route.server";
|
||||
import { richiediGestoreEventi } from "@/lib/auth-route.server";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { leggiEventi } from "@/lib/eventi.server";
|
||||
import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server";
|
||||
@@ -17,7 +17,7 @@ export const Route = createFileRoute("/api/public/sollecita-presenze")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const negato = await richiediAdmin(request);
|
||||
const negato = await richiediGestoreEventi(request);
|
||||
if (negato) return negato;
|
||||
|
||||
const parsed = schema.safeParse(await request.json());
|
||||
|
||||
@@ -10,7 +10,7 @@ import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
import { Card, PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
||||
import { useAnagraficaRosa } from "@/lib/rosa";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { usePuoGestireEventi } from "@/lib/ruoli";
|
||||
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||
import {
|
||||
Drawer,
|
||||
@@ -55,9 +55,9 @@ function Calendario() {
|
||||
}, []);
|
||||
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
|
||||
const [drawerAperto, setDrawerAperto] = useState(false);
|
||||
const admin = useIsAdmin();
|
||||
const puoGestire = usePuoGestireEventi();
|
||||
const { eventi } = useEventi();
|
||||
const rosa = useAnagraficaRosa();
|
||||
const rosa = useAnagraficaRosa({ conAllenatori: true });
|
||||
// `useMotoRidotto` copre anche i device deboli (RAM bassa), non solo
|
||||
// `prefers-reduced-motion`: disattiva anche lo swipe orizzontale tra mesi.
|
||||
const ridotto = useMotoRidotto();
|
||||
@@ -244,7 +244,7 @@ function Calendario() {
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
{admin ? (
|
||||
{puoGestire ? (
|
||||
<div className="px-5 pt-4">
|
||||
<Link
|
||||
to="/eventi"
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@/components/ui/drawer";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { giorniDelMese, giorniIT, mesiIT, pad2, useMeseNav } from "@/lib/calendario";
|
||||
import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { inRosa, nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import {
|
||||
categoriaEvento,
|
||||
daCategoria,
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
type Evento,
|
||||
} from "@/lib/eventi";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { usePuoGestireEventi } from "@/lib/ruoli";
|
||||
import { molla, proietta } from "@/lib/molla";
|
||||
import { useMotoRidotto } from "@/lib/motion";
|
||||
|
||||
@@ -61,9 +61,10 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [
|
||||
];
|
||||
|
||||
function GestioneEventi() {
|
||||
// Solo verità (`!io`, gate admin): `useGiocatoreBase` basta, niente statistiche.
|
||||
// Solo verità (`!io`, gate admin o allenatore): `useGiocatoreBase` basta, niente statistiche.
|
||||
const io = useGiocatoreBase();
|
||||
const admin = useIsAdmin();
|
||||
// Admin o allenatore (DD-034): la RLS di `eventi_app` (M21) dice lo stesso.
|
||||
const puoGestire = usePuoGestireEventi();
|
||||
const { eventi, isPending, isError, error, refetch } = useEventi();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const salva = useSalvaEvento();
|
||||
@@ -72,7 +73,7 @@ function GestioneEventi() {
|
||||
const [daEliminare, setDaEliminare] = useState<Evento | null>(null);
|
||||
const [confermaModifica, setConfermaModifica] = useState(false);
|
||||
const [cambiaGiorno, setCambiaGiorno] = useState(false);
|
||||
const rosa = squadra.filter((g) => g.attivo);
|
||||
const rosa = squadra.filter(inRosa);
|
||||
|
||||
// SSR-safe: la data di oggi arriva solo dopo il mount.
|
||||
const [oggi, setOggi] = useState<{ anno: number; mese: number; giorno: number } | null>(null);
|
||||
@@ -85,7 +86,7 @@ function GestioneEventi() {
|
||||
const { anno, mese, direzione, precedente, successivo } = useMeseNav();
|
||||
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
|
||||
|
||||
if (!io || !admin) {
|
||||
if (!io || !puoGestire) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
|
||||
|
||||
+78
-61
@@ -1,4 +1,5 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Flame, ChevronRight } from "lucide-react";
|
||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
|
||||
@@ -11,6 +12,8 @@ import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
||||
import { useEventi, type Evento } from "@/lib/eventi";
|
||||
import { useRispostePresenze } from "@/lib/presenze";
|
||||
import { useIo, useObiettivi } from "@/lib/rosa";
|
||||
import { isAllenatore, nomeCompleto } from "@/lib/giocatori-squadra";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
import { useCsi } from "@/lib/csi";
|
||||
import { isNostraSquadra, matchDaPartitaCsi, partiteGiocate } from "@/lib/csi-core";
|
||||
import { useScoutMatches } from "@/lib/scout-store";
|
||||
@@ -38,7 +41,10 @@ export const Route = createFileRoute("/")({
|
||||
});
|
||||
|
||||
function Index() {
|
||||
// `useIo` c'è solo per i giocatori; l'allenatore ha lo slot ma non statistiche (DD-034).
|
||||
const base = useGiocatoreBase();
|
||||
const giocatore = useIo();
|
||||
const allenatore = isAllenatore(base);
|
||||
const { eventi } = useEventi();
|
||||
const { presenze } = useRispostePresenze();
|
||||
const oggi = new Date().toISOString().slice(0, 10);
|
||||
@@ -65,7 +71,8 @@ function Index() {
|
||||
? { ...ultimaBase, mvp: (eventoUltima && mvpPerMatch[eventoUltima.id]) ?? "" }
|
||||
: null;
|
||||
|
||||
if (!giocatore) return null;
|
||||
if (!base || (!giocatore && !allenatore)) return null;
|
||||
const nome = giocatore?.nome ?? nomeCompleto(base);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -78,7 +85,7 @@ function Index() {
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80">
|
||||
Ciao {giocatore.nome.split(" ")[0]}
|
||||
Ciao {nome.split(" ")[0]}
|
||||
</p>
|
||||
<h1 className="font-display-lg text-4xl uppercase leading-none">CRAP Volley</h1>
|
||||
</div>
|
||||
@@ -86,7 +93,9 @@ function Index() {
|
||||
<LinkProfilo />
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-3 gap-2 text-center">
|
||||
<div
|
||||
className={cn("mt-6 grid gap-2 text-center", giocatore ? "grid-cols-3" : "grid-cols-2")}
|
||||
>
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="font-display text-2xl leading-none">{noi ? `${noi.pos}º` : "—"}</p>
|
||||
<p className="text-xs uppercase text-primary-foreground/80">In classifica</p>
|
||||
@@ -97,19 +106,21 @@ function Index() {
|
||||
</p>
|
||||
<p className="text-xs uppercase text-primary-foreground/80">Bilancio W-L</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="inline-flex items-center gap-1 font-display text-2xl leading-none">
|
||||
<Flame className="h-4 w-4 text-accent" />
|
||||
{giocatore.streak}
|
||||
</p>
|
||||
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
|
||||
</div>
|
||||
{giocatore ? (
|
||||
<div className="rounded-2xl bg-primary-foreground/10 p-3">
|
||||
<p className="inline-flex items-center gap-1 font-display text-2xl leading-none">
|
||||
<Flame className="h-4 w-4 text-accent" />
|
||||
{giocatore.streak}
|
||||
</p>
|
||||
<p className="text-xs uppercase text-primary-foreground/80">Streak</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<PromemoriaPalloni />
|
||||
|
||||
<CompletaProfilo giocatoreId={giocatore.id} indice={1} />
|
||||
<CompletaProfilo giocatoreId={base.id} allenatore={allenatore} indice={1} />
|
||||
|
||||
<Section
|
||||
titolo="Prossimo impegno"
|
||||
@@ -132,20 +143,22 @@ function Index() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section titolo="Da confermare" indice={3}>
|
||||
<div className="space-y-3">
|
||||
{daConfermare.length > 0 ? (
|
||||
daConfermare.map((e) => {
|
||||
const link = linkPerEvento(e);
|
||||
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
||||
})
|
||||
) : (
|
||||
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
||||
Nient'altro da confermare: sei in pari.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
{giocatore ? (
|
||||
<Section titolo="Da confermare" indice={3}>
|
||||
<div className="space-y-3">
|
||||
{daConfermare.length > 0 ? (
|
||||
daConfermare.map((e) => {
|
||||
const link = linkPerEvento(e);
|
||||
return <EventoCard key={e.id} evento={e} {...(link ? { linkTo: link } : {})} />;
|
||||
})
|
||||
) : (
|
||||
<p className="rounded-3xl bg-card p-4 text-xs text-muted-foreground shadow-card">
|
||||
Nient'altro da confermare: sei in pari.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section
|
||||
titolo="Ultima partita"
|
||||
@@ -207,43 +220,47 @@ function Index() {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
titolo="Obiettivo di squadra"
|
||||
indice={5}
|
||||
azione={
|
||||
<Link
|
||||
to="/squadra"
|
||||
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
||||
>
|
||||
Tutti <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{obiettivo ? (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 text-sm font-bold">
|
||||
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
|
||||
</div>
|
||||
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
|
||||
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-accent">
|
||||
{microcopyObiettivo(obiettivo)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
</Section>
|
||||
{allenatore ? null : (
|
||||
<Section
|
||||
titolo="Obiettivo di squadra"
|
||||
indice={5}
|
||||
azione={
|
||||
<Link
|
||||
to="/squadra"
|
||||
className="inline-flex items-center text-[14px] font-semibold text-accent"
|
||||
>
|
||||
Tutti <ChevronRight className="h-4 w-4" />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{obiettivo ? (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 text-sm font-bold">
|
||||
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
|
||||
</div>
|
||||
<Barra percentuale={progressoObiettivo(obiettivo)} trackClassName="mt-3" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Siamo al <Numero valore={progressoObiettivo(obiettivo)} suffisso="%" /> —{" "}
|
||||
{obiettivo.valore}/{obiettivo.target} {obiettivo.unita}.
|
||||
</p>
|
||||
<p className="mt-1 text-xs font-semibold text-accent">
|
||||
{microcopyObiettivo(obiettivo)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{obiettivo.impatto}</p>
|
||||
</Card>
|
||||
) : null}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section titolo="Colpo d'occhio" indice={6}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile valore={giocatore.presenze} label="Presenze" />
|
||||
<StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" />
|
||||
<StatTile valore={giocatore.mvp} label="MVP" />
|
||||
</div>
|
||||
</Section>
|
||||
{giocatore ? (
|
||||
<Section titolo="Colpo d'occhio" indice={6}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile valore={giocatore.presenze} label="Presenze" />
|
||||
<StatTile valore={mediaVotoColpoDOcchio(giocatore)} label="Media voto" />
|
||||
<StatTile valore={giocatore.mvp} label="MVP" />
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { ScoutEntry } from "@/components/crapp/ScoutEntry";
|
||||
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
|
||||
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { useSonoAllenatore } from "@/lib/user-store";
|
||||
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
|
||||
import { VotoSocial } from "@/components/crapp/VotoSocial";
|
||||
import { TurnoPalloni } from "@/components/crapp/TurnoPalloni";
|
||||
@@ -47,6 +48,7 @@ function PartitaDetail() {
|
||||
const { id } = Route.useParams();
|
||||
const { evento } = useEvento(id);
|
||||
const admin = useIsAdmin();
|
||||
const sonoAllenatore = useSonoAllenatore();
|
||||
const scoutMatches = useScoutMatches();
|
||||
const { data: csi } = useCsi();
|
||||
const { risposte } = usePresenzeEvento(id);
|
||||
@@ -203,9 +205,12 @@ function PartitaDetail() {
|
||||
</Section>
|
||||
|
||||
{match ? (
|
||||
<Section titolo="Badge votati dai compagni">
|
||||
<VotoSocial matchId={match.id} />
|
||||
</Section>
|
||||
// Badge e cacche non si mostrano all'allenatore (DD-034).
|
||||
sonoAllenatore ? null : (
|
||||
<Section titolo="Badge votati dai compagni">
|
||||
<VotoSocial matchId={match.id} />
|
||||
</Section>
|
||||
)
|
||||
) : (
|
||||
<Section titolo="In programma">
|
||||
<p className="rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
@@ -219,9 +224,11 @@ function PartitaDetail() {
|
||||
<ScoutEntry eventoId={evento.id} />
|
||||
</Section>
|
||||
|
||||
<Section titolo="Sondaggio pre-partita">
|
||||
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
|
||||
</Section>
|
||||
{sonoAllenatore ? null : (
|
||||
<Section titolo="Sondaggio pre-partita">
|
||||
<SondaggioCacche eventoId={evento.id} dataEvento={evento.data} oraEvento={evento.ora} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{match ? (
|
||||
<Section titolo="Pagelle di fine partita">
|
||||
|
||||
+232
-181
@@ -14,8 +14,9 @@ import {
|
||||
} from "@/lib/avatar-store";
|
||||
import { SerieGriglia } from "@/components/crapp/SerieCard";
|
||||
import { CollezioneBadge } from "@/components/crapp/CollezioneBadge";
|
||||
import { ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { ProfiloAllenatore, ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { useVotiSocial } from "@/lib/badge-social";
|
||||
import { inizialiDa } from "@/lib/crapp-data";
|
||||
import { useIo } from "@/lib/rosa";
|
||||
import { usePresenzeUltimoMese } from "@/lib/presenze-mese";
|
||||
import {
|
||||
@@ -24,7 +25,13 @@ import {
|
||||
pushSupportato,
|
||||
statoNotifiche,
|
||||
} from "@/lib/push-client";
|
||||
import { resetGiocatore } from "@/lib/user-store";
|
||||
import { resetGiocatore, useGiocatoreBase } from "@/lib/user-store";
|
||||
import {
|
||||
isAllenatore,
|
||||
nomeCompleto,
|
||||
ruoloVisibile,
|
||||
type GiocatoreSquadra,
|
||||
} from "@/lib/giocatori-squadra";
|
||||
import { esci } from "@/lib/auth";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
@@ -60,130 +67,29 @@ export const Route = createFileRoute("/profilo")({
|
||||
});
|
||||
|
||||
function Profilo() {
|
||||
// L'allenatore ha un profilo ridotto: solo Docs e Opzioni, niente stagione né badge (DD-034).
|
||||
const base = useGiocatoreBase();
|
||||
if (base && isAllenatore(base)) return <ProfiloAllenatorePagina g={base} />;
|
||||
return <ProfiloGiocatore />;
|
||||
}
|
||||
|
||||
function ProfiloGiocatore() {
|
||||
const { tab } = Route.useSearch();
|
||||
const tabIniziale = tab ?? "stagione";
|
||||
const votiSocial = useVotiSocial();
|
||||
const g = useIo();
|
||||
const admin = useIsAdmin();
|
||||
const ultimoMese = usePresenzeUltimoMese(g?.id);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fotoEsiste = useAvatarEsiste(g?.id);
|
||||
const impostaAvatarEsiste = useImpostaAvatarEsiste();
|
||||
const [bust, setBust] = useState(0);
|
||||
const [notifiche, setNotifiche] = useState(false);
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
const [supportate, setSupportate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setSupportate(pushSupportato());
|
||||
statoNotifiche()
|
||||
.then(setNotifiche)
|
||||
.catch(() => setNotifiche(false));
|
||||
}, []);
|
||||
|
||||
if (!g) return null;
|
||||
|
||||
async function cambiaNotifiche() {
|
||||
if (!g || inCorso) return;
|
||||
setInCorso(true);
|
||||
try {
|
||||
if (notifiche) {
|
||||
await disattivaNotifiche();
|
||||
setNotifiche(false);
|
||||
toast.success("Notifiche disattivate");
|
||||
} else {
|
||||
await attivaNotifiche(g.id);
|
||||
setNotifiche(true);
|
||||
toast.success("Notifiche attive");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Notifiche non disponibili");
|
||||
} finally {
|
||||
setInCorso(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await esci();
|
||||
resetGiocatore();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
|
||||
}
|
||||
}
|
||||
|
||||
const percPresenze = g.totaliEventi ? Math.round((g.presenze / g.totaliEventi) * 100) : 0;
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file || !g) return;
|
||||
try {
|
||||
await caricaAvatar(g.id, file);
|
||||
setBust(Date.now());
|
||||
impostaAvatarEsiste(g.id, true);
|
||||
toast.success("Immagine profilo aggiornata");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a caricare l'immagine");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sul profilo il logo torna in home al posto del link al profilo. */}
|
||||
<PageHeader
|
||||
titolo={g.nome}
|
||||
sottotitolo={`#${g.numero} · ${g.ruolo}`}
|
||||
azione={
|
||||
<Link to="/" aria-label="Vai alla home" className="premi shrink-0 rounded-2xl">
|
||||
<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<PageHeader titolo={g.nome} sottotitolo={`#${g.numero} · ${g.ruolo}`} azione={<LinkHome />} />
|
||||
|
||||
<Reveal className="-mt-6 px-5">
|
||||
<Card>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Avatar id={g.id} fallback={g.iniziali} className="h-20 w-20 text-2xl" bust={bust} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={onFile}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
|
||||
>
|
||||
Cambia immagine profilo
|
||||
</button>
|
||||
{fotoEsiste.data ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!confirm("Rimuovere l'immagine profilo?")) return;
|
||||
try {
|
||||
await rimuoviAvatar(g.id);
|
||||
setBust(Date.now());
|
||||
impostaAvatarEsiste(g.id, false);
|
||||
toast.success("Immagine rimossa");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a rimuovere l'immagine");
|
||||
}
|
||||
}}
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/15 text-destructive"
|
||||
aria-label="Rimuovi immagine"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
<CardAvatar id={g.id} fallback={g.iniziali} />
|
||||
|
||||
<BarraSottosezioni
|
||||
key={tabIniziale}
|
||||
@@ -236,78 +142,223 @@ function Profilo() {
|
||||
{
|
||||
id: "impostazioni",
|
||||
label: "Opzioni",
|
||||
contenuto: (
|
||||
<div className="space-y-3">
|
||||
<div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cambiaNotifiche}
|
||||
disabled={!supportate || inCorso}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">Notifiche</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{supportate
|
||||
? notifiche
|
||||
? "Attive su questo dispositivo"
|
||||
: "Palloni, solleciti presenze e avvisi in app"
|
||||
: "Non supportate su questo dispositivo"}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
|
||||
notifiche
|
||||
? "bg-accent-grad text-accent-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
{admin ? (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Dashboard amministratore</span>
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
) : null}
|
||||
<a
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Segnala un bug</span>
|
||||
<Bug className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
|
||||
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">CrAPP v{APP_VERSION}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Esci
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
contenuto: <TabOpzioni giocatoreId={g.id} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfiloAllenatorePagina({ g }: { g: GiocatoreSquadra }) {
|
||||
const { tab } = Route.useSearch();
|
||||
// Stagione e Badge non esistono per l'allenatore: un link diretto apre Docs.
|
||||
const tabIniziale = tab === "impostazioni" ? "impostazioni" : "documenti";
|
||||
const nome = nomeCompleto(g);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo={nome} sottotitolo={ruoloVisibile(g)} azione={<LinkHome />} />
|
||||
|
||||
<CardAvatar id={g.id} fallback={inizialiDa(nome)} />
|
||||
|
||||
<BarraSottosezioni
|
||||
key={tabIniziale}
|
||||
defaultId={tabIniziale}
|
||||
riempiLarghezza
|
||||
voci={[
|
||||
{ id: "documenti", label: "Docs", contenuto: <ProfiloAllenatore g={g} /> },
|
||||
{
|
||||
id: "impostazioni",
|
||||
label: "Opzioni",
|
||||
contenuto: <TabOpzioni giocatoreId={g.id} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkHome() {
|
||||
return (
|
||||
<Link to="/" aria-label="Vai alla home" className="premi shrink-0 rounded-2xl">
|
||||
<TeamLogo src="/logo-nerorosso.svg" className="h-14 w-14" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** Foto profilo: stessa per giocatore e allenatore (bucket `avatar-giocatori`). */
|
||||
function CardAvatar({ id, fallback }: { id: string; fallback: string }) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const fotoEsiste = useAvatarEsiste(id);
|
||||
const impostaAvatarEsiste = useImpostaAvatarEsiste();
|
||||
const [bust, setBust] = useState(0);
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
await caricaAvatar(id, file);
|
||||
setBust(Date.now());
|
||||
impostaAvatarEsiste(id, true);
|
||||
toast.success("Immagine profilo aggiornata");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a caricare l'immagine");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Reveal className="-mt-6 px-5">
|
||||
<Card>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Avatar id={id} fallback={fallback} className="h-20 w-20 text-2xl" bust={bust} />
|
||||
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={onFile} />
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="premi min-h-11 flex-1 rounded-xl bg-secondary px-3 text-xs font-bold uppercase tracking-wide"
|
||||
>
|
||||
Cambia immagine profilo
|
||||
</button>
|
||||
{fotoEsiste.data ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!confirm("Rimuovere l'immagine profilo?")) return;
|
||||
try {
|
||||
await rimuoviAvatar(id);
|
||||
setBust(Date.now());
|
||||
impostaAvatarEsiste(id, false);
|
||||
toast.success("Immagine rimossa");
|
||||
} catch {
|
||||
toast.error("Non sono riuscito a rimuovere l'immagine");
|
||||
}
|
||||
}}
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-destructive/15 text-destructive"
|
||||
aria-label="Rimuovi immagine"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tab Opzioni: notifiche, dashboard admin, segnalazioni, uscita. Uguale per tutti. */
|
||||
function TabOpzioni({ giocatoreId }: { giocatoreId: string }) {
|
||||
const admin = useIsAdmin();
|
||||
const [notifiche, setNotifiche] = useState(false);
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
const [supportate, setSupportate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setSupportate(pushSupportato());
|
||||
statoNotifiche()
|
||||
.then(setNotifiche)
|
||||
.catch(() => setNotifiche(false));
|
||||
}, []);
|
||||
|
||||
async function cambiaNotifiche() {
|
||||
if (inCorso) return;
|
||||
setInCorso(true);
|
||||
try {
|
||||
if (notifiche) {
|
||||
await disattivaNotifiche();
|
||||
setNotifiche(false);
|
||||
toast.success("Notifiche disattivate");
|
||||
} else {
|
||||
await attivaNotifiche(giocatoreId);
|
||||
setNotifiche(true);
|
||||
toast.success("Notifiche attive");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Notifiche non disponibili");
|
||||
} finally {
|
||||
setInCorso(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await esci();
|
||||
resetGiocatore();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cambiaNotifiche}
|
||||
disabled={!supportate || inCorso}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm disabled:opacity-60"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">Notifiche</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{supportate
|
||||
? notifiche
|
||||
? "Attive su questo dispositivo"
|
||||
: "Palloni, solleciti presenze e avvisi in app"
|
||||
: "Non supportate su questo dispositivo"}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-8 w-8 shrink-0 place-items-center rounded-xl",
|
||||
notifiche
|
||||
? "bg-accent-grad text-accent-foreground"
|
||||
: "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</span>
|
||||
</button>
|
||||
{admin ? (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Dashboard amministratore</span>
|
||||
<ShieldCheck className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
) : null}
|
||||
<a
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Segnala un bug</span>
|
||||
<Bug className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/ivancacciari1995-a11y/CRAPP/issues/new?template=feature_request.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex min-h-11 w-full items-center justify-between gap-3 px-4 py-3 text-sm transition-colors hover:bg-accent/5"
|
||||
>
|
||||
<span className="min-w-0 truncate">Suggerisci una nuova funzionalità</span>
|
||||
<Lightbulb className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">CrAPP v{APP_VERSION}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="premi flex min-h-11 w-full items-center justify-center gap-2 rounded-3xl bg-destructive/15 px-4 py-3 text-sm font-bold text-destructive shadow-card"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Esci
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+106
-39
@@ -5,7 +5,7 @@ import { cn } from "@/lib/utils";
|
||||
import { PageHeader, StatTile } from "@/components/crapp/ui-bits";
|
||||
import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { formatData } from "@/lib/crapp-data";
|
||||
import { formatData, inizialiDa } from "@/lib/crapp-data";
|
||||
import { microcopyObiettivo, progressoObiettivo } from "@/lib/obiettivi";
|
||||
import {
|
||||
useRosa,
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
type CriterioClassifica,
|
||||
} from "@/lib/rosa";
|
||||
import { usePresenzeUltimoMeseTutti } from "@/lib/presenze-mese";
|
||||
import {
|
||||
isAllenatore,
|
||||
nomeCompleto,
|
||||
ruoloVisibile,
|
||||
useGiocatoriSquadra,
|
||||
} from "@/lib/giocatori-squadra";
|
||||
import { useSonoAllenatore } from "@/lib/user-store";
|
||||
import { totaliSquadra, useScoutMatches } from "@/lib/scout-store";
|
||||
import { useCsi } from "@/lib/csi";
|
||||
import { partiteGiocate } from "@/lib/csi-core";
|
||||
@@ -87,6 +94,14 @@ function valore(
|
||||
|
||||
function Squadra() {
|
||||
const rosa = useRosa();
|
||||
// Gli allenatori stanno in Rosa con i giocatori, senza numero né statistiche (DD-034).
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const allenatori = useMemo(() => squadra.filter((g) => g.attivo && isAllenatore(g)), [squadra]);
|
||||
// Chi allena non vede badge, cacche né obiettivi (DD-034).
|
||||
const sonoAllenatore = useSonoAllenatore();
|
||||
const criteriVisibili = sonoAllenatore
|
||||
? criteri.filter((c) => c.id !== "cacchePartita")
|
||||
: criteri;
|
||||
const mese = usePresenzeUltimoMeseTutti();
|
||||
const scoutMatches = useScoutMatches();
|
||||
const { voti: pagelle } = usePagelle();
|
||||
@@ -115,9 +130,36 @@ function Squadra() {
|
||||
const contenutoRosa = useMemo(
|
||||
() => (
|
||||
<div className="space-y-2">
|
||||
{allenatori.map((a) => (
|
||||
<article
|
||||
key={a.id}
|
||||
className="flex min-h-11 items-center gap-3 rounded-3xl bg-card p-3 shadow-card"
|
||||
>
|
||||
<Avatar
|
||||
id={a.id}
|
||||
fallback={inizialiDa(nomeCompleto(a))}
|
||||
className="h-11 w-11 text-lg"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-bold leading-tight">
|
||||
{nomeCompleto(a)}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2">
|
||||
<RuoloBadge ruolo={ruoloVisibile(a)} />
|
||||
{a.nascita ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Cake className="h-3.5 w-3.5" /> {formatData(a.nascita)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</article>
|
||||
))}
|
||||
{rosa.map((g) => {
|
||||
const stati = badgeGiocatore(g);
|
||||
const sbloccati = [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
|
||||
const sbloccati = sonoAllenatore
|
||||
? []
|
||||
: [...stati.filter((b) => b.grado !== null), ...badgeSegretiSbloccati(g)];
|
||||
const isOpen = aperto === g.id;
|
||||
return (
|
||||
<article key={g.id} className="overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
@@ -185,7 +227,9 @@ function Squadra() {
|
||||
{ l: "Presenze di fila", v: g.streak },
|
||||
{ l: "Media voto", v: g.mediaVoto || "—" },
|
||||
{ l: "MVP", v: g.mvp },
|
||||
{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" },
|
||||
...(sonoAllenatore
|
||||
? []
|
||||
: [{ l: "Cacche/partita 💩", v: g.cacchePartita || "—" }]),
|
||||
].map((s) => (
|
||||
<div key={s.l} className="rounded-2xl bg-secondary p-2.5 text-center">
|
||||
<p className="font-display text-xl leading-none">{s.v}</p>
|
||||
@@ -196,37 +240,45 @@ function Squadra() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
|
||||
</p>
|
||||
{sbloccati.length === 0 ? (
|
||||
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
|
||||
Nessun badge sbloccato per ora.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{sbloccati.map((b) => {
|
||||
const Icon = b.def.icon;
|
||||
const meta = gradoMeta[b.grado!];
|
||||
return (
|
||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
||||
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
|
||||
<Icon className={cn("h-4 w-4", meta.text)} />
|
||||
<p className="mt-1 text-xs font-bold leading-tight">{b.def.nome}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{b.valore} {b.def.unita}
|
||||
{b.prossimaSoglia
|
||||
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}>
|
||||
{meta.label}
|
||||
</p>
|
||||
</div>
|
||||
</BadgeDrawer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{sonoAllenatore ? null : (
|
||||
<>
|
||||
<p className="mt-4 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
Badge sbloccati · {collezioneBadge(g).ottenuti}/{collezioneBadge(g).totali}
|
||||
</p>
|
||||
{sbloccati.length === 0 ? (
|
||||
<p className="mt-2 rounded-2xl bg-secondary/50 p-3 text-xs text-muted-foreground">
|
||||
Nessun badge sbloccato per ora.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{sbloccati.map((b) => {
|
||||
const Icon = b.def.icon;
|
||||
const meta = gradoMeta[b.grado!];
|
||||
return (
|
||||
<BadgeDrawer key={b.def.id} def={b.def} stato={b}>
|
||||
<div className={cn("rounded-2xl p-2.5 ring-1", meta.bg, meta.ring)}>
|
||||
<Icon className={cn("h-4 w-4", meta.text)} />
|
||||
<p className="mt-1 text-xs font-bold leading-tight">
|
||||
{b.def.nome}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{b.valore} {b.def.unita}
|
||||
{b.prossimaSoglia
|
||||
? ` · ${b.prossimaSoglia} per ${gradoMeta[b.prossimo!].label.toLowerCase()}`
|
||||
: ""}
|
||||
</p>
|
||||
<p
|
||||
className={cn("mt-1.5 text-xs font-bold uppercase", meta.text)}
|
||||
>
|
||||
{meta.label}
|
||||
</p>
|
||||
</div>
|
||||
</BadgeDrawer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -235,7 +287,7 @@ function Squadra() {
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
[rosa, aperto, mese],
|
||||
[rosa, aperto, mese, allenatori, sonoAllenatore],
|
||||
);
|
||||
|
||||
const contenutoStats = useMemo(
|
||||
@@ -271,7 +323,7 @@ function Squadra() {
|
||||
<DrawerTitle>Classifica per</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="flex flex-col gap-1 px-4 pb-6">
|
||||
{criteri.map((c) => (
|
||||
{criteriVisibili.map((c) => (
|
||||
<DrawerClose key={c.id} asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -326,7 +378,18 @@ function Squadra() {
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
[mediaPresenze, matchGiocati, pagelle, team, filtroAperto, criterio, ordinati, rank, max],
|
||||
[
|
||||
mediaPresenze,
|
||||
matchGiocati,
|
||||
pagelle,
|
||||
team,
|
||||
filtroAperto,
|
||||
criterio,
|
||||
ordinati,
|
||||
rank,
|
||||
max,
|
||||
criteriVisibili,
|
||||
],
|
||||
);
|
||||
|
||||
const contenutoObiettivi = useMemo(
|
||||
@@ -450,8 +513,12 @@ function Squadra() {
|
||||
voci={[
|
||||
{ id: "rosa", label: "Rosa", contenuto: contenutoRosa },
|
||||
{ id: "stats", label: "Stats", contenuto: contenutoStats },
|
||||
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
|
||||
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
|
||||
...(sonoAllenatore
|
||||
? []
|
||||
: [
|
||||
{ id: "obiettivi", label: "Obiettivi", contenuto: contenutoObiettivi },
|
||||
{ id: "badge", label: "Badge", contenuto: contenutoBadge },
|
||||
]),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -66,8 +66,19 @@ if (!locale) {
|
||||
body: JSON.stringify({ user_id: idAdmin, role: "admin" }),
|
||||
});
|
||||
|
||||
// Il ruolo allenatore lo scrive il trigger di M21 al collegamento dello slot; qui basta
|
||||
// la riga in `user_roles`, che è ciò che la route legge (DD-034).
|
||||
const emailAllenatore = `test-route-allenatore-${Date.now()}@example.test`;
|
||||
const idAllenatore = await creaUtente(emailAllenatore);
|
||||
await fetch(`${SUPABASE}/rest/v1/user_roles`, {
|
||||
method: "POST",
|
||||
headers: { ...authAdmin, "content-type": "application/json" },
|
||||
body: JSON.stringify({ user_id: idAllenatore, role: "allenatore" }),
|
||||
});
|
||||
|
||||
const tokenGiocatore = await accedi(emailGiocatore);
|
||||
const tokenAdmin = await accedi(emailAdmin);
|
||||
const tokenAllenatore = await accedi(emailAllenatore);
|
||||
|
||||
const server = await avviaServer();
|
||||
console.log(`permessi route su ${server.baseUrl} (database ${SUPABASE})`);
|
||||
@@ -108,6 +119,20 @@ if (!locale) {
|
||||
}
|
||||
});
|
||||
|
||||
await prova(
|
||||
"l'allenatore sollecita le presenze ma non manda palloni né sondaggio",
|
||||
async () => {
|
||||
const sollecito = await chiama("/api/public/sollecita-presenze", {
|
||||
authorization: `Bearer ${tokenAllenatore}`,
|
||||
});
|
||||
assert.equal(sollecito.status, 404, "sollecito: superato l'accesso, evento inesistente");
|
||||
for (const percorso of ["/api/public/apri-sondaggio", "/api/public/promemoria-palloni"]) {
|
||||
const res = await chiama(percorso, { authorization: `Bearer ${tokenAllenatore}` });
|
||||
assert.equal(res.status, 403, `${percorso}: restano all'admin`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/** Messaggio libero: stesso controllo d'accesso, corpo diverso. */
|
||||
const chiamaMessaggio = (intestazioni: Record<string, string> = {}) =>
|
||||
fetch(`${server.baseUrl}/api/public/notifica-personalizzata`, {
|
||||
@@ -125,6 +150,11 @@ if (!locale) {
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
await prova("l'allenatore non manda messaggi personalizzati", async () => {
|
||||
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAllenatore}` });
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
await prova("un amministratore manda il messaggio (nessun dispositivo iscritto)", async () => {
|
||||
const res = await chiamaMessaggio({ authorization: `Bearer ${tokenAdmin}` });
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
/** Check dell'anagrafica squadra: `bun test/unit/giocatori-squadra.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
daRigaSquadra,
|
||||
inRosa,
|
||||
isAllenatore,
|
||||
nomeCompleto,
|
||||
numeroGiaUsato,
|
||||
prossimoIdGiocatore,
|
||||
rosaFallback,
|
||||
ruoloVisibile,
|
||||
slotDi,
|
||||
slotPerEmail,
|
||||
validaDatiSquadra,
|
||||
@@ -24,6 +28,7 @@ const riga = (parziale: Partial<GiocatoreSquadra> = {}): GiocatoreSquadra => ({
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
nascita: null,
|
||||
tipo: "giocatore",
|
||||
...parziale,
|
||||
});
|
||||
|
||||
@@ -92,7 +97,14 @@ assert.equal(
|
||||
assert.equal(slotPerEmail(rosaEmail, ""), null, "stringa vuota trattata come nessuna email");
|
||||
|
||||
// --- validaDatiSquadra ----------------------------------------------------------------
|
||||
const datiOk = { nome: "Mario", cognome: "Rossi", numero: 7, ruolo: "schiacciatore", email: null };
|
||||
const datiOk = {
|
||||
nome: "Mario",
|
||||
cognome: "Rossi",
|
||||
numero: 7,
|
||||
ruolo: "schiacciatore",
|
||||
email: null,
|
||||
tipo: "giocatore" as const,
|
||||
};
|
||||
assert.equal(validaDatiSquadra(datiOk), null, "dati validi: nessun errore");
|
||||
assert.match(validaDatiSquadra({ ...datiOk, nome: "" })!, /nome/i);
|
||||
assert.match(
|
||||
@@ -155,5 +167,56 @@ assert.equal(
|
||||
"conta solo il 7 attivo di g1, non serve escludere g3",
|
||||
);
|
||||
assert.equal(numeroGiaUsato(rosaNumeri, "g4", 11), false, "un numero libero non risulta usato");
|
||||
assert.equal(
|
||||
numeroGiaUsato([riga({ id: "g5", numero: 0, tipo: "allenatore" })], "g4", 0),
|
||||
false,
|
||||
"l'allenatore non occupa numeri di maglia",
|
||||
);
|
||||
|
||||
// --- allenatore (DD-034) ---------------------------------------------------------
|
||||
const allenatore = riga({ id: "g20", numero: 0, ruolo: "", tipo: "allenatore" });
|
||||
assert.equal(isAllenatore(allenatore), true);
|
||||
assert.equal(isAllenatore(riga()), false);
|
||||
assert.equal(isAllenatore(null), false, "senza slot non si è allenatori");
|
||||
assert.equal(inRosa(riga()), true, "giocatore attivo: in rosa");
|
||||
assert.equal(inRosa(riga({ attivo: false })), false, "giocatore disattivato: fuori");
|
||||
assert.equal(inRosa(allenatore), false, "l'allenatore non è nella rosa di gioco");
|
||||
assert.equal(ruoloVisibile(allenatore), "Allenatore", "dove c'è il ruolo in campo");
|
||||
assert.equal(ruoloVisibile(riga({ ruolo: "Libero" })), "Libero");
|
||||
assert.equal(
|
||||
validaDatiSquadra({ ...datiOk, tipo: "allenatore", numero: 0, ruolo: "" }),
|
||||
null,
|
||||
"l'allenatore non ha numero né ruolo",
|
||||
);
|
||||
assert.match(
|
||||
validaDatiSquadra({ ...datiOk, tipo: "allenatore", nome: " " })!,
|
||||
/nome/i,
|
||||
"il nome resta obbligatorio anche per l'allenatore",
|
||||
);
|
||||
assert.match(
|
||||
validaDatiSquadra({ ...datiOk, tipo: "allenatore", email: "senza-chiocciola" })!,
|
||||
/email/i,
|
||||
);
|
||||
const rigaDb = {
|
||||
id: "g20",
|
||||
nome: "Carla",
|
||||
cognome: "Neri",
|
||||
numero: null,
|
||||
ruolo: "",
|
||||
auth_user_id: null,
|
||||
attivo: true,
|
||||
email: "carla@gmail.com",
|
||||
numero_tessera: null,
|
||||
data_tessera: null,
|
||||
nascita: null,
|
||||
tipo: "allenatore" as const,
|
||||
};
|
||||
assert.equal(daRigaSquadra(rigaDb).numero, 0, "numero NULL a database diventa 0");
|
||||
assert.equal(daRigaSquadra(rigaDb).tipo, "allenatore");
|
||||
assert.equal(daRigaSquadra({ ...rigaDb, numero: 7, tipo: "giocatore" }).numero, 7);
|
||||
assert.ok(
|
||||
rosaFallback().every((g) => g.tipo === "giocatore"),
|
||||
"il fallback contiene solo giocatori",
|
||||
);
|
||||
|
||||
console.log("giocatori-squadra: ok");
|
||||
@@ -120,11 +120,12 @@ assert.equal(totaliEventiGiocatore("g1", conRistretti, OGGI), 5, "chi non è con
|
||||
|
||||
// --- destinatari del sollecito: chi non ha risposto, più i "forse" ------------
|
||||
const squadra = [
|
||||
{ id: "g1", attivo: true },
|
||||
{ id: "g2", attivo: true },
|
||||
{ id: "g3", attivo: true },
|
||||
{ id: "g4", attivo: true },
|
||||
{ id: "g5", attivo: false }, // uscito dalla squadra: non lo si disturba più
|
||||
{ id: "g1", attivo: true, tipo: "giocatore" as const },
|
||||
{ id: "g2", attivo: true, tipo: "giocatore" as const },
|
||||
{ id: "g3", attivo: true, tipo: "giocatore" as const },
|
||||
{ id: "g4", attivo: true, tipo: "giocatore" as const },
|
||||
{ id: "g5", attivo: false, tipo: "giocatore" as const }, // uscito: non lo si disturba più
|
||||
{ id: "g6", attivo: true, tipo: "allenatore" as const }, // non risponde: mai sollecitato (DD-034)
|
||||
];
|
||||
|
||||
const risposte = [
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
aRigaProfilo,
|
||||
completamento,
|
||||
completamentoAllenatore,
|
||||
csvTesseramento,
|
||||
daRigaProfilo,
|
||||
sezioniComplete,
|
||||
@@ -115,6 +116,7 @@ const squadra: GiocatoreSquadra[] = [
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
nascita: null,
|
||||
tipo: "giocatore",
|
||||
},
|
||||
{
|
||||
id: "g2",
|
||||
@@ -128,6 +130,7 @@ const squadra: GiocatoreSquadra[] = [
|
||||
numeroTessera: null,
|
||||
dataTessera: null,
|
||||
nascita: null,
|
||||
tipo: "giocatore",
|
||||
},
|
||||
];
|
||||
const csv = csvTesseramento(squadra, { g1: completo });
|
||||
@@ -155,7 +158,14 @@ assert.equal(slotDi(squadra, "u2")?.id, "g2");
|
||||
assert.equal(slotDi(squadra, "sconosciuto"), null);
|
||||
|
||||
// --- dati squadra modificabili dall'admin (DD-017) ---------------------------
|
||||
const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda", email: null };
|
||||
const datiOk = {
|
||||
nome: "Ivan",
|
||||
cognome: "Cacciari",
|
||||
numero: 23,
|
||||
ruolo: "Banda",
|
||||
email: null,
|
||||
tipo: "giocatore" as const,
|
||||
};
|
||||
assert.equal(validaDatiSquadra(datiOk), null);
|
||||
assert.match(validaDatiSquadra({ ...datiOk, nome: " " }) ?? "", /nome/i);
|
||||
assert.match(validaDatiSquadra({ ...datiOk, cognome: "" }) ?? "", /cognome/i);
|
||||
@@ -184,4 +194,24 @@ assert.ok(
|
||||
"il fallback non può collegare account",
|
||||
);
|
||||
|
||||
// --- completamento dell'allenatore (DD-034) --------------------------------------
|
||||
assert.equal(completamentoAllenatore(null), 0, "senza profilo: 0%");
|
||||
assert.equal(
|
||||
completamentoAllenatore({ ...vuoto, dataNascita: "1980-01-01", telefono: "333" }),
|
||||
50,
|
||||
"due campi su quattro",
|
||||
);
|
||||
assert.equal(
|
||||
completamentoAllenatore({
|
||||
...vuoto,
|
||||
dataNascita: "1980-01-01",
|
||||
luogoNascita: "Bologna",
|
||||
telefono: "333",
|
||||
email: "a@b.it",
|
||||
}),
|
||||
100,
|
||||
"bastano i quattro dati personali: niente indirizzo, documento, certificato, foto",
|
||||
);
|
||||
assert.equal(completamentoAllenatore({ ...vuoto, telefono: " " }), 0, "spazi soli non contano");
|
||||
|
||||
console.log("profili-core: ok");
|
||||
Reference in new issue
Block a user