Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6defc1a49 | ||
|
|
0a2112a9f3 | ||
|
|
a51cdb43e4 | ||
|
|
49d4d9de31 |
No files matched your search
+4
-4
@@ -1,6 +1,6 @@
|
||||
# Project State
|
||||
|
||||
Ultimo aggiornamento: 24/09/2026
|
||||
Ultimo aggiornamento: 27/09/2026
|
||||
|
||||
## Stato generale
|
||||
|
||||
@@ -178,9 +178,9 @@ collega uno slot lo occupa anche in produzione, e va liberato da un admin.
|
||||
|
||||
## Prossimo sviluppo
|
||||
|
||||
Niente di assegnato: tutto quello che era in lavorazione è chiuso, tesseramento CSI incluso
|
||||
(numero e data di tessera registrabili da `/admin`, migration `m8_tesseramento_csi`). Le voci
|
||||
ancora aperte stanno in [docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
Niente di assegnato: l'avviso certificati in scadenza (DD-035) è implementato e versione
|
||||
1.1.0 (vedi `docs/CHANGELOG.md`). Le voci ancora aperte stanno in
|
||||
[docs/ROADMAP.md](docs/ROADMAP.md), sotto «Prossimo».
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -13,6 +13,16 @@ versioni `0.y.z` erano pre-release.
|
||||
|
||||
## [Non rilasciato]
|
||||
|
||||
## [1.1.0] - 2026-09-27
|
||||
|
||||
### Aggiunto
|
||||
|
||||
- **Avviso certificati in scadenza** — in Home, giallo nei 7 giorni prima e nero dopo la
|
||||
scadenza del certificato medico. Gli admin vedono i nomi di tutta la rosa (non
|
||||
cliccabile); il giocatore interessato vede solo il proprio e il tap apre
|
||||
`/profilo?tab=documenti`. Calcolato al volo da `certificato_scadenza`: nessuna migration,
|
||||
nessuna push (DD-035).
|
||||
|
||||
## [1.0.0] - 2026-09-24
|
||||
|
||||
Prima versione stabile. Oltre al ruolo allenatore riunisce il lavoro fatto dalla 0.9.2:
|
||||
|
||||
@@ -50,6 +50,7 @@ Serve a rispondere a domande del tipo:
|
||||
| [DD-032](#dd-032--la-stagione-csi-si-ricava-dalle-date-delle-gare) | Stagione CSI dalle date delle gare |
|
||||
| [DD-033](#dd-033--gestione-eventi-solo-calendario-giorno-fissato-dal-tocco) | Gestione eventi solo da calendario |
|
||||
| [DD-034](#dd-034--lallenatore-è-uno-slot-della-squadra-con-tipo-diverso-non-un-giocatore) | Ruolo allenatore |
|
||||
| [DD-035](#dd-035--avviso-certificati-calcolato-in-home-visibile-solo-al-titolare-e-agli-admin) | Avviso certificati in Home |
|
||||
|
||||
**In valutazione**
|
||||
|
||||
@@ -1451,3 +1452,61 @@ collega uno slot di tipo allenatore: è quindi l'admin a concederlo, registrando
|
||||
**Riesame**
|
||||
Se arriva un secondo tipo di staff (dirigente, segreteria) o se i filtri sulla rosa diventano
|
||||
fonte di bug, passare alla tabella `staff` separata.
|
||||
|
||||
### DD-035 — Avviso certificati: calcolato in Home, visibile solo al titolare e agli admin
|
||||
|
||||
**Data:** 24 settembre 2026
|
||||
**Stato:** Accettata (implementata, versione 1.1.0)
|
||||
|
||||
**Contesto**
|
||||
Il certificato medico ha una data di scadenza (`profili_giocatore.certificato_scadenza`), ma
|
||||
oggi ci si accorge che è scaduto solo aprendo la tab Profili di `/admin`: spesso quando il
|
||||
giocatore è già fuori regola. La richiesta è un avviso in Home, giallo nei 7 giorni prima
|
||||
della scadenza e nero dopo, con i nomi dei giocatori, che sparisca da solo quando il
|
||||
certificato viene rinnovato. Specifica completa in
|
||||
[modules/profilo-giocatore.md](modules/profilo-giocatore.md#avviso-certificati).
|
||||
|
||||
**Decisione**
|
||||
|
||||
1. L'avviso si **calcola al volo** dalla data già salvata, con una funzione pura in
|
||||
`profili-core.ts`: nessuna tabella, nessuno stato «avvisato», nessuna migration.
|
||||
2. Lo vedono **solo gli admin**, con i nomi di tutta la rosa, e **il giocatore interessato**,
|
||||
riferito solo a sé. L'allenatore e i compagni non lo vedono.
|
||||
3. L'avviso dello staff **non è cliccabile**; quello personale apre
|
||||
`/profilo?tab=documenti`.
|
||||
4. Soglia fissa di **7 giorni** in una costante; il giorno della scadenza il certificato vale
|
||||
ancora (come `statoScadenza()`).
|
||||
5. **Niente push**: l'avviso vive solo in Home.
|
||||
|
||||
**Alternative scartate**
|
||||
|
||||
- Mostrarlo anche all'allenatore → il certificato è un dato sanitario che oggi l'allenatore
|
||||
non vede (DD-034); servirebbe una funzione `SECURITY DEFINER` apposta per un'informazione
|
||||
che all'allenatore non serve ad agire.
|
||||
- Salvare gli avvisi in una tabella o nel centro notifiche (DD-030) → uno stato da tenere
|
||||
allineato alla data, che si disallinea appena il giocatore corregge la scadenza; calcolarlo
|
||||
è gratis e sempre giusto.
|
||||
- Avviso dello staff cliccabile verso `/admin?tab=profili` → l'admin non può caricare il file
|
||||
al posto del giocatore (DD-017), quindi il tocco non gli farebbe fare nulla in più, e
|
||||
richiederebbe di aggiungere a `/admin` la lettura della tab dall'URL.
|
||||
- Mostrare al massimo alcuni nomi e un «e altri N» → a inizio stagione ne scadono molti
|
||||
insieme ed è proprio lì che l'elenco completo serve.
|
||||
- Push quotidiana a chi ha il certificato in scadenza → servirebbe un cron, che oggi non
|
||||
esiste per nessuna push (DD-025), e le push vanno riservate agli eventi importanti
|
||||
([EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md)).
|
||||
- Includere i certificati mancanti → è un problema diverso (profilo incompleto), già coperto
|
||||
dal widget «Completa il tuo profilo» e dalla tab Profili.
|
||||
|
||||
**Conseguenze**
|
||||
|
||||
- Nessuna query nuova: si riusa `useProfili()`, già in cache per Profilo e `/admin`, e le RLS
|
||||
esistenti decidono quanti profili arrivano (tutti all'admin, il proprio al giocatore).
|
||||
- Il rinnovo non richiede nessuna azione di chiusura: cambiata la data, l'avviso si aggiorna
|
||||
da solo.
|
||||
- Chi non apre l'app non viene avvisato: l'avviso raggiunge solo chi entra in Home.
|
||||
- Un admin che è anche giocatore vede solo l'avviso dello staff, dove compare il suo nome.
|
||||
|
||||
**Riesame**
|
||||
Se gli avvisi in Home non bastano (certificati che scadono comunque senza essere rinnovati),
|
||||
valutare una push, legata a un cron o a un invio manuale dell'admin come per i palloni. Se 7
|
||||
giorni si rivelano pochi per prenotare una visita, alzare la costante o renderla regolabile.
|
||||
@@ -38,6 +38,9 @@ Tutto quello che è rilasciato, fino alla versione 1.0.0 (vedi `CHANGELOG.md`).
|
||||
calcolata dal CSI
|
||||
- [x] Ruolo allenatore — gestisce gli eventi e sollecita le presenze, profilo ridotto senza
|
||||
stagione e badge ([specifica](modules/allenatore.md), DD-034)
|
||||
- [x] Avviso certificati in scadenza — in Home, giallo nei 7 giorni prima e nero dopo la
|
||||
scadenza: agli admin con i nomi dei giocatori, al giocatore solo per il proprio
|
||||
([specifica](modules/profilo-giocatore.md#avviso-certificati), DD-035)
|
||||
|
||||
## Prossimo
|
||||
|
||||
|
||||
+16
-14
@@ -69,7 +69,8 @@ quando quei campi sono compilati.
|
||||
La Home dell'allenatore tiene ciò che riguarda la squadra e toglie ciò che riguarda il
|
||||
giocatore: restano classifica e bilancio, prossimo impegno e ultima partita; spariscono lo
|
||||
streak personale, il promemoria palloni, «Da confermare», l'obiettivo di squadra e il
|
||||
«Colpo d'occhio» con le statistiche personali.
|
||||
«Colpo d'occhio» con le statistiche personali. Non compare nemmeno l'avviso certificati: lo
|
||||
vedono solo gli admin e il giocatore interessato (DD-035).
|
||||
|
||||
## Squadra
|
||||
|
||||
@@ -148,19 +149,20 @@ giocatore).
|
||||
|
||||
## Permessi (riepilogo)
|
||||
|
||||
| Azione | Giocatore | Allenatore | Admin |
|
||||
| ----------------------------------------------- | --------- | ------------------ | ------------------ |
|
||||
| Modificare nome e cognome | no | sì, i propri | tutti |
|
||||
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
|
||||
| Documento, certificato, foto tessera | i propri | — | lettura/download |
|
||||
| Creare/modificare/eliminare eventi, convocati | no | sì | sì |
|
||||
| Sollecitare le presenze | no | sì | sì |
|
||||
| Promemoria palloni, notifica personalizzata | no | no | sì |
|
||||
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
|
||||
| Tab Stagione e Badge del profilo | sì | no | sì |
|
||||
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
|
||||
| Badge e classifiche/dati sulle cacche | sì | no | sì |
|
||||
| Dashboard `/admin` | no | no | sì |
|
||||
| Azione | Giocatore | Allenatore | Admin |
|
||||
| ----------------------------------------------- | ---------- | ------------------ | ------------------ |
|
||||
| Modificare nome e cognome | no | sì, i propri | tutti |
|
||||
| Dati personali e foto profilo | i propri | i propri (ridotti) | tutti (non i file) |
|
||||
| Documento, certificato, foto tessera | i propri | — | lettura/download |
|
||||
| Avviso certificati in Home | il proprio | no | tutta la rosa |
|
||||
| Creare/modificare/eliminare eventi, convocati | no | sì | sì |
|
||||
| Sollecitare le presenze | no | sì | sì |
|
||||
| Promemoria palloni, notifica personalizzata | no | no | sì |
|
||||
| Rispondere alle presenze, votare, essere votato | sì | no | sì (se giocatore) |
|
||||
| Tab Stagione e Badge del profilo | sì | no | sì |
|
||||
| Tab Obiettivi e Badge di Squadra | sì | no | sì |
|
||||
| Badge e classifiche/dati sulle cacche | sì | no | sì |
|
||||
| Dashboard `/admin` | no | no | sì |
|
||||
|
||||
## Implementazione
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ indipendenti:
|
||||
- **Notifiche smart** — notifiche locali mostrate solo ad app aperta, generate da badge,
|
||||
serie e obiettivi appena raggiunti; non è un canale push separato.
|
||||
|
||||
Gli avvisi fissi in Home (turno palloni, certificati in scadenza) non passano da nessuno dei
|
||||
due: sono card calcolate dai dati, che spariscono da sole quando la condizione finisce. In
|
||||
particolare il certificato in scadenza **non** genera push né voci nel centro notifiche
|
||||
(DD-035, specifica in [profilo-giocatore.md](profilo-giocatore.md#avviso-certificati)).
|
||||
|
||||
---
|
||||
|
||||
## Dati
|
||||
|
||||
@@ -64,7 +64,7 @@ Se il profilo non è completo compare automaticamente un widget di completamento
|
||||
|
||||
## Home
|
||||
|
||||
Il giocatore visualizza un widget dedicato.
|
||||
Il giocatore visualizza un widget dedicato e, quando serve, l'avviso sul certificato medico.
|
||||
|
||||
### Completa il tuo profilo
|
||||
|
||||
@@ -80,6 +80,128 @@ Quando tutte le sezioni sono complete il widget scompare automaticamente.
|
||||
Il tap apre Profilo sulla sottosezione **Documenti** (`/profilo?tab=documenti`), non
|
||||
sulla tab Stagione.
|
||||
|
||||
### Avviso certificati
|
||||
|
||||
**Stato:** implementato (DD-035).
|
||||
|
||||
Un avviso in Home segnala i certificati medici in scadenza o scaduti, così nessuno se ne
|
||||
accorge quando il giocatore è già fuori regola. Si calcola al volo dalla data di scadenza
|
||||
già salvata: niente tabella, niente migration, niente push.
|
||||
|
||||
#### Chi lo vede
|
||||
|
||||
| Utente | Cosa vede | Al tocco |
|
||||
| --------------------------- | --------------------------------------------------------------- | ----------------------------- |
|
||||
| Admin | l'avviso **dello staff**: i nomi di tutti i giocatori coinvolti | niente, non è cliccabile |
|
||||
| Giocatore interessato | l'avviso **personale**: solo il proprio certificato | apre `/profilo?tab=documenti` |
|
||||
| Admin che è anche giocatore | solo l'avviso dello staff, dove compare già il suo nome | niente |
|
||||
| Altri giocatori | niente | — |
|
||||
| Allenatore | niente: non vede i certificati altrui e non ne ha uno (DD-034) | — |
|
||||
|
||||
L'avviso dello staff non è cliccabile, come quello dei palloni: l'admin non può caricare il
|
||||
certificato al posto del giocatore (DD-017), quindi può solo sollecitarlo, e il giocatore
|
||||
riceve già il proprio avviso. Quello personale porta invece dove si carica il certificato
|
||||
nuovo, come il widget «Completa il tuo profilo».
|
||||
|
||||
I certificati sono dati sanitari: fuori da `/admin` li legge solo il titolare (DD-016).
|
||||
Nessuna policy cambia, perché `useProfili()` restituisce già tutti i profili a un admin e il
|
||||
solo proprio a un giocatore.
|
||||
|
||||
#### Quando compare
|
||||
|
||||
`giorni` è la differenza in giorni di calendario tra la scadenza e oggi (ora locale), soglia
|
||||
`GIORNI_AVVISO_CERTIFICATO = 7`.
|
||||
|
||||
| Condizione | Stato | Avviso |
|
||||
| -------------------- | ----------- | ------------------ |
|
||||
| `giorni > 7` | valido | nessuno |
|
||||
| `0 ≤ giorni ≤ 7` | in scadenza | giallo (`warning`) |
|
||||
| `giorni < 0` | scaduto | nero (`primary`) |
|
||||
| data o file mancanti | mancante | nessuno |
|
||||
|
||||
- Il giorno della scadenza il certificato vale ancora, coerente con `statoScadenza()`: il
|
||||
giocatore compare nel giallo con «scade oggi» e passa al nero dal giorno dopo.
|
||||
- L'avviso nero resta finché il giocatore non aggiorna la data: non c'è un limite di tempo.
|
||||
- Il certificato **mancante** non genera avviso: è un problema diverso, già visibile nella
|
||||
tab Profili di `/admin` e nel widget di completamento del giocatore.
|
||||
- Sono esclusi i giocatori disattivati e gli allenatori: conta solo la rosa (`inRosa()`,
|
||||
cioè `attivo` e `tipo = 'giocatore'`).
|
||||
|
||||
#### Testi
|
||||
|
||||
| Avviso | Titolo (staff / personale) | Riga |
|
||||
| ----------------- | -------------------------- | ------------------------------------------------------------------------ |
|
||||
| Giallo, staff | «Certificati in scadenza» | «Mario Rossi — scade tra 5 giorni», «… — scade domani», «… — scade oggi» |
|
||||
| Nero, staff | «Certificati scaduti» | «Mario Rossi — scaduto il 12/09/2026» |
|
||||
| Giallo, personale | «Certificato in scadenza» | «Il tuo certificato medico scade tra 5 giorni» / «domani» / «oggi» |
|
||||
| Nero, personale | «Certificato scaduto» | «Il tuo certificato medico è scaduto il 12/09/2026: caricane uno nuovo» |
|
||||
|
||||
Il numero di giorni scende da solo ogni giorno (7, 6, 5… domani, oggi): l'avviso non
|
||||
conserva niente, rilegge la data a ogni apertura della Home.
|
||||
|
||||
#### Più giocatori
|
||||
|
||||
- Più giocatori nello stesso stato stanno in un **unico avviso**, una riga ciascuno, e
|
||||
compaiono **tutti**: nessun limite al numero di nomi, anche a inizio stagione quando ne
|
||||
scadono molti insieme.
|
||||
- Se ci sono sia certificati in scadenza sia scaduti compaiono **due avvisi**: prima quello
|
||||
nero, poi quello giallo.
|
||||
- Ordine delle righe: dalla scadenza più vicina nel giallo, dal certificato scaduto da più
|
||||
tempo nel nero; a parità di data, alfabetico per cognome e poi per nome. L'ordine è
|
||||
stabile, quindi l'elenco non si rimescola tra un'apertura e l'altra.
|
||||
|
||||
#### Aspetto e posizione
|
||||
|
||||
- Stessa forma del banner palloni (`PromemoriaPalloni`): card arrotondata a tutta larghezza
|
||||
con icona e titolo in `font-display` maiuscolo, righe in testo piccolo sotto.
|
||||
- Giallo: sfondo `bg-warning`, testo `text-warning-foreground`. Nero: sfondo `bg-primary`,
|
||||
testo `text-primary-foreground`. L'app è solo chiara (DD-022), quindi il nero non rischia
|
||||
di confondersi con lo sfondo.
|
||||
- In Home sta subito **sotto il banner palloni** e sopra «Completa il tuo profilo»: prima
|
||||
ciò che scade oggi, poi ciò che manca.
|
||||
|
||||
#### Rinnovo
|
||||
|
||||
Il giocatore rinnova dal proprio profilo, sottosezione Documenti: carica il file nuovo e
|
||||
aggiorna la data. Il salvataggio aggiorna la cache dei profili (`setQueryData`), quindi il
|
||||
suo avviso personale sparisce subito. Nell'avviso dello staff il nome sparisce alla lettura
|
||||
successiva dei profili da parte dell'admin (cache di 30 minuti, `useProfili()`), senza
|
||||
nessuna azione da parte sua. Quando non resta nessuno, l'avviso non compare più.
|
||||
|
||||
Basta aggiornare la data: l'avviso guarda solo quella (e la presenza di un file). Che il
|
||||
file caricato sia davvero quello nuovo resta un controllo dell'admin, come oggi. Anche l'admin
|
||||
può correggere la data dalla scheda del giocatore (DD-017, non il file): l'avviso segue la
|
||||
data, chiunque l'abbia scritta.
|
||||
|
||||
#### Caricamento
|
||||
|
||||
Finché ruoli e profili non sono arrivati, l'avviso non compare: così un admin che è anche
|
||||
giocatore non vede per un attimo l'avviso personale prima di quello dello staff. Se la
|
||||
lettura dei profili fallisce l'avviso semplicemente non compare, senza messaggio d'errore:
|
||||
la Home non deve rompersi per un dato accessorio.
|
||||
|
||||
#### Implementazione
|
||||
|
||||
| Pezzo | Ruolo |
|
||||
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GIORNI_AVVISO_CERTIFICATO` (`profili-core.ts`) | la soglia, 7 |
|
||||
| `giorniAllaScadenza(scadenza, oggi)` | differenza in giorni di calendario tra due date `AAAA-MM-GG`, negativa se scaduto |
|
||||
| `avvisiCertificati(rosa, profili, oggi)` | funzione pura: restituisce `{ scaduti, inScadenza }`, ognuno una lista ordinata di `{ giocatoreId, nome, cognome, scadenza, giorni }` |
|
||||
| `testoScadenza(giorni)` | «scade oggi» / «scade domani» / «scade tra N giorni» |
|
||||
| `src/components/crapp/AvvisoCertificati.tsx` | legge `useGiocatoriSquadra()`, `useProfili()`, `useIsAdmin()` e l'utente corrente; sceglie avviso dello staff o personale e disegna le card |
|
||||
| `src/routes/index.tsx` | monta `<AvvisoCertificati />` subito dopo `<PromemoriaPalloni />` |
|
||||
|
||||
- "Oggi" viene da `oggiISO()` (`palloni-core.ts`), in ora locale. Non da
|
||||
`new Date().toISOString()`, che è in UTC e tra mezzanotte e le 2 darebbe il giorno prima.
|
||||
- Nessuna query nuova: `useProfili()` ha la stessa chiave di cache usata da Profilo e
|
||||
`/admin`, quindi al massimo una lettura di `profili_giocatore` per sessione (vedi
|
||||
[EFFICIENZA_CLOUD.md](../EFFICIENZA_CLOUD.md)). Per l'allenatore il componente non legge
|
||||
i profili.
|
||||
- Test in `test/unit/profili-core.test.ts`: soglia (8 giorni no, 7 sì, 0 sì, −1 scaduto),
|
||||
certificato senza file o senza data escluso, giocatore disattivato e allenatore esclusi,
|
||||
ordinamento (per data, poi alfabetico), testi al singolare e al plurale, cambio di mese e
|
||||
di anno nel calcolo dei giorni.
|
||||
|
||||
## Profilo
|
||||
|
||||
Il profilo viene suddiviso in sette aree.
|
||||
@@ -132,6 +254,10 @@ Upload.
|
||||
|
||||
Il giocatore può aggiornare liberamente sia la data sia il file.
|
||||
|
||||
Quando mancano 7 giorni o meno alla scadenza, o il certificato è scaduto, il giocatore
|
||||
riceve un avviso nella propria Home e compare in quello degli admin (vedi
|
||||
[Avviso certificati](#avviso-certificati)).
|
||||
|
||||
Lo storico non viene mantenuto nella prima versione.
|
||||
|
||||
### Foto tessera
|
||||
@@ -255,6 +381,9 @@ Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
|
||||
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
|
||||
Non carica file al posto di altri.
|
||||
|
||||
**Avviso certificati** — il giocatore vede solo il proprio, l'admin i nomi di tutta la rosa,
|
||||
nessun altro ne vede (vedi [Avviso certificati](#avviso-certificati)).
|
||||
|
||||
## Versione 1
|
||||
|
||||
- Profilo giocatore
|
||||
@@ -269,6 +398,7 @@ Non carica file al posto di altri.
|
||||
## Versioni future
|
||||
|
||||
- Storico certificati medici
|
||||
- Avviso certificati anche via push, o con una soglia regolabile dall'admin (DD-035, Riesame)
|
||||
- Gestione documenti aggiuntivi
|
||||
- Consensi privacy
|
||||
- Firma digitale
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "crapp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useProfili } from "@/lib/profili";
|
||||
import {
|
||||
avvisiCertificati,
|
||||
formatDataBreve,
|
||||
testoScadenza,
|
||||
type AvvisoCertificato,
|
||||
} from "@/lib/profili-core";
|
||||
import { useGiocatoriSquadra } from "@/lib/giocatori-squadra";
|
||||
import { oggiISO } from "@/lib/palloni-core";
|
||||
import { useIsAdmin, useRuoliPronti } from "@/lib/ruoli";
|
||||
import { useGiocatoreBase } from "@/lib/user-store";
|
||||
|
||||
function Card({
|
||||
colore,
|
||||
titolo,
|
||||
righe,
|
||||
cliccabile,
|
||||
}: {
|
||||
colore: "warning" | "primary";
|
||||
titolo: string;
|
||||
righe: string[];
|
||||
cliccabile?: boolean;
|
||||
}) {
|
||||
const classi = `mx-5 mt-4 block rounded-3xl p-4 shadow-pop ${
|
||||
colore === "warning"
|
||||
? "bg-warning text-warning-foreground"
|
||||
: "bg-primary text-primary-foreground"
|
||||
} ${cliccabile ? "premi" : ""}`;
|
||||
const contenuto = (
|
||||
<>
|
||||
<p className="flex items-center gap-2 font-display text-lg uppercase leading-none">
|
||||
<AlertCircle className="h-5 w-5" /> {titolo}
|
||||
</p>
|
||||
{righe.map((r) => (
|
||||
<p key={r} className="mt-2 text-xs leading-snug opacity-90">
|
||||
{r}
|
||||
</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
return cliccabile ? (
|
||||
<Link to="/profilo" search={{ tab: "documenti" }} className={classi}>
|
||||
{contenuto}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={classi}>{contenuto}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Avviso certificati in scadenza/scaduti (DD-035): calcolato al volo, niente push. */
|
||||
export function AvvisoCertificati() {
|
||||
const admin = useIsAdmin();
|
||||
const ruoliPronti = useRuoliPronti();
|
||||
const base = useGiocatoreBase();
|
||||
const { righe: rosa } = useGiocatoriSquadra();
|
||||
const { profili, isPending } = useProfili();
|
||||
|
||||
if (!ruoliPronti || isPending) return null;
|
||||
|
||||
const oggi = oggiISO();
|
||||
|
||||
if (admin) {
|
||||
const { scaduti, inScadenza } = avvisiCertificati(rosa, profili, oggi);
|
||||
if (scaduti.length === 0 && inScadenza.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{scaduti.length > 0 ? (
|
||||
<Card
|
||||
colore="primary"
|
||||
titolo="Certificati scaduti"
|
||||
righe={scaduti.map((a) => rigaStaff(a, `scaduto il ${formatDataBreve(a.scadenza)}`))}
|
||||
/>
|
||||
) : null}
|
||||
{inScadenza.length > 0 ? (
|
||||
<Card
|
||||
colore="warning"
|
||||
titolo="Certificati in scadenza"
|
||||
righe={inScadenza.map((a) => rigaStaff(a, testoScadenza(a.giorni)))}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!base) return null;
|
||||
const { scaduti, inScadenza } = avvisiCertificati([base], profili, oggi);
|
||||
const mio = scaduti[0] ?? inScadenza[0];
|
||||
if (!mio) return null;
|
||||
|
||||
return mio.giorni < 0 ? (
|
||||
<Card
|
||||
colore="primary"
|
||||
titolo="Certificato scaduto"
|
||||
cliccabile
|
||||
righe={[
|
||||
`Il tuo certificato medico è scaduto il ${formatDataBreve(mio.scadenza)}: caricane uno nuovo`,
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Card
|
||||
colore="warning"
|
||||
titolo="Certificato in scadenza"
|
||||
cliccabile
|
||||
righe={[`Il tuo certificato medico ${testoScadenza(mio.giorni)}`]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function rigaStaff(a: AvvisoCertificato, dettaglio: string): string {
|
||||
return `${a.nome} ${a.cognome} — ${dettaglio}`;
|
||||
}
|
||||
+71
-1
@@ -1,5 +1,5 @@
|
||||
import { rigaCsv } from "./scout-export";
|
||||
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
import { inRosa, nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
|
||||
/**
|
||||
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
|
||||
@@ -215,3 +215,73 @@ export function csvTesseramento(
|
||||
export function etichettaGiocatore(g: GiocatoreSquadra): string {
|
||||
return `#${g.numero} ${nomeCompleto(g)}`;
|
||||
}
|
||||
|
||||
/** Soglia dell'avviso certificati in Home (DD-035). */
|
||||
export const GIORNI_AVVISO_CERTIFICATO = 7;
|
||||
|
||||
/** Giorni di calendario tra due date `AAAA-MM-GG` (ora locale): negativo se `scadenza` è passata. */
|
||||
export function giorniAllaScadenza(scadenza: string, oggi: string): number {
|
||||
const a = new Date(`${scadenza}T00:00:00`);
|
||||
const b = new Date(`${oggi}T00:00:00`);
|
||||
return Math.round((a.getTime() - b.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** Data in formato `GG/MM/AAAA`, per i testi dell'avviso certificati. */
|
||||
export function formatDataBreve(iso: string): string {
|
||||
return new Date(`${iso}T00:00:00`).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Riga dell'avviso certificati per un giocatore (DD-035). */
|
||||
export function testoScadenza(giorni: number): string {
|
||||
if (giorni === 0) return "scade oggi";
|
||||
if (giorni === 1) return "scade domani";
|
||||
return `scade tra ${giorni} giorni`;
|
||||
}
|
||||
|
||||
export type AvvisoCertificato = {
|
||||
giocatoreId: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
scadenza: string;
|
||||
giorni: number;
|
||||
};
|
||||
|
||||
function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number {
|
||||
return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome);
|
||||
}
|
||||
|
||||
/**
|
||||
* Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da
|
||||
* `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha
|
||||
* ancora un certificato caricato (è un problema diverso, non un avviso di scadenza).
|
||||
*/
|
||||
export function avvisiCertificati(
|
||||
rosa: GiocatoreSquadra[],
|
||||
profili: Record<string, Profilo>,
|
||||
oggi: string,
|
||||
): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } {
|
||||
const scaduti: AvvisoCertificato[] = [];
|
||||
const inScadenza: AvvisoCertificato[] = [];
|
||||
for (const g of rosa) {
|
||||
if (!inRosa(g)) continue;
|
||||
const p = profili[g.id];
|
||||
if (!p?.certificatoScadenza || !p.certificatoPath) continue;
|
||||
const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
|
||||
if (giorni > GIORNI_AVVISO_CERTIFICATO) continue;
|
||||
const avviso: AvvisoCertificato = {
|
||||
giocatoreId: g.id,
|
||||
nome: g.nome,
|
||||
cognome: g.cognome,
|
||||
scadenza: p.certificatoScadenza,
|
||||
giorni,
|
||||
};
|
||||
(giorni < 0 ? scaduti : inScadenza).push(avviso);
|
||||
}
|
||||
scaduti.sort(confrontaAvvisi);
|
||||
inScadenza.sort(confrontaAvvisi);
|
||||
return { scaduti, inScadenza };
|
||||
}
|
||||
+11
-3
@@ -24,15 +24,23 @@ async function fetchRuoli(utenteId: string | null): Promise<Ruolo[] | null> {
|
||||
return (data ?? []).map((r) => r.role as Ruolo);
|
||||
}
|
||||
|
||||
function useRuoli(): Ruolo[] {
|
||||
function useRuoliQuery() {
|
||||
const { utenteId } = useSessione();
|
||||
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
|
||||
const query = useQuery({
|
||||
return useQuery({
|
||||
queryKey: [...RUOLI_KEY, utenteId],
|
||||
queryFn: () => fetchRuoli(utenteId),
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
return query.data ?? [];
|
||||
}
|
||||
|
||||
function useRuoli(): Ruolo[] {
|
||||
return useRuoliQuery().data ?? [];
|
||||
}
|
||||
|
||||
/** L'avviso certificati (DD-035) aspetta i ruoli: niente flash dell'avviso sbagliato. */
|
||||
export function useRuoliPronti(): boolean {
|
||||
return !useRuoliQuery().isPending;
|
||||
}
|
||||
|
||||
export function useIsAdmin(): boolean {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Flame, ChevronRight } from "lucide-react";
|
||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
|
||||
import { AvvisoCertificati } from "@/components/crapp/AvvisoCertificati";
|
||||
import { Card, LinkProfilo, Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
|
||||
import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
@@ -120,6 +121,8 @@ function Index() {
|
||||
|
||||
<PromemoriaPalloni />
|
||||
|
||||
<AvvisoCertificati />
|
||||
|
||||
<CompletaProfilo giocatoreId={base.id} allenatore={allenatore} indice={1} />
|
||||
|
||||
<Section
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
aRigaProfilo,
|
||||
avvisiCertificati,
|
||||
completamento,
|
||||
completamentoAllenatore,
|
||||
csvTesseramento,
|
||||
daRigaProfilo,
|
||||
formatDataBreve,
|
||||
giorniAllaScadenza,
|
||||
sezioniComplete,
|
||||
statoScadenza,
|
||||
testoScadenza,
|
||||
type Profilo,
|
||||
} from "@/lib/profili-core";
|
||||
import {
|
||||
@@ -214,4 +218,119 @@ assert.equal(
|
||||
);
|
||||
assert.equal(completamentoAllenatore({ ...vuoto, telefono: " " }), 0, "spazi soli non contano");
|
||||
|
||||
// --- avviso certificati (DD-035) ------------------------------------------------
|
||||
assert.equal(giorniAllaScadenza("2026-09-27", "2026-09-20"), 7);
|
||||
assert.equal(giorniAllaScadenza("2026-09-19", "2026-09-20"), -1, "scaduto ieri");
|
||||
assert.equal(giorniAllaScadenza("2026-09-20", "2026-09-20"), 0, "scade oggi");
|
||||
assert.equal(giorniAllaScadenza("2026-10-01", "2026-09-30"), 1, "cambio di mese");
|
||||
assert.equal(giorniAllaScadenza("2027-01-01", "2026-12-31"), 1, "cambio di anno");
|
||||
|
||||
assert.equal(formatDataBreve("2026-09-12"), "12/09/2026");
|
||||
|
||||
assert.equal(testoScadenza(0), "scade oggi");
|
||||
assert.equal(testoScadenza(1), "scade domani");
|
||||
assert.equal(testoScadenza(5), "scade tra 5 giorni");
|
||||
|
||||
const rosaCertificati: GiocatoreSquadra[] = [
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari", attivo: true, tipo: "giocatore" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni", attivo: true, tipo: "giocatore" },
|
||||
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi", attivo: true, tipo: "giocatore" },
|
||||
{ ...squadra[0]!, id: "g4", nome: "Sara", cognome: "Neri", attivo: false, tipo: "giocatore" },
|
||||
{ ...squadra[0]!, id: "g5", nome: "Luca", cognome: "Bianchi", attivo: true, tipo: "allenatore" },
|
||||
];
|
||||
const oggiTest = "2026-09-20";
|
||||
const profiliCertificati: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" }, // in scadenza, 5 giorni
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-10", certificatoPath: "p2" }, // scaduto da 10 giorni
|
||||
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-10-31", certificatoPath: "p3" }, // valido, fuori soglia
|
||||
g4: { ...vuoto, giocatoreId: "g4", certificatoScadenza: "2026-09-21", certificatoPath: "p4" }, // disattivato: escluso
|
||||
g5: { ...vuoto, giocatoreId: "g5", certificatoScadenza: "2026-09-21", certificatoPath: "p5" }, // allenatore: escluso
|
||||
};
|
||||
const avvisi = avvisiCertificati(rosaCertificati, profiliCertificati, oggiTest);
|
||||
assert.deepEqual(
|
||||
avvisi.scaduti.map((a) => a.giocatoreId),
|
||||
["g2"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
avvisi.inScadenza.map((a) => a.giocatoreId),
|
||||
["g1"],
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
avvisiCertificati(
|
||||
[{ ...squadra[0]!, id: "g6" }],
|
||||
{ g6: { ...vuoto, giocatoreId: "g6", certificatoScadenza: null, certificatoPath: null } },
|
||||
oggiTest,
|
||||
).inScadenza.length,
|
||||
0,
|
||||
"certificato mancante: nessun avviso",
|
||||
);
|
||||
|
||||
const dueInScadenza: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-27", certificatoPath: "p1" }, // 7 giorni, più lontano
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-21", certificatoPath: "p2" }, // 1 giorno, più vicino
|
||||
};
|
||||
const ordinati = avvisiCertificati(
|
||||
[
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
|
||||
],
|
||||
dueInScadenza,
|
||||
oggiTest,
|
||||
).inScadenza.map((a) => a.giocatoreId);
|
||||
assert.deepEqual(ordinati, ["g2", "g1"], "la scadenza più vicina viene prima");
|
||||
|
||||
const pariGiorni: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-25", certificatoPath: "p1" },
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-25", certificatoPath: "p2" },
|
||||
};
|
||||
const alfabetico = avvisiCertificati(
|
||||
[
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" },
|
||||
],
|
||||
pariGiorni,
|
||||
oggiTest,
|
||||
).inScadenza.map((a) => a.giocatoreId);
|
||||
assert.deepEqual(alfabetico, ["g2", "g1"], "a parità di data, ordine alfabetico per cognome");
|
||||
|
||||
// soglia esatta (DD-035): 8 giorni fuori, 7 e 0 dentro
|
||||
const rosaSoglia: GiocatoreSquadra[] = [
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Cacciari" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Bruni" },
|
||||
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
|
||||
];
|
||||
const profiliSoglia: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-28", certificatoPath: "p1" }, // 8 giorni: fuori
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-27", certificatoPath: "p2" }, // 7 giorni: dentro
|
||||
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-20", certificatoPath: "p3" }, // 0 giorni: dentro
|
||||
};
|
||||
const soglia = avvisiCertificati(rosaSoglia, profiliSoglia, oggiTest);
|
||||
assert.deepEqual(
|
||||
soglia.inScadenza.map((a) => a.giocatoreId),
|
||||
["g3", "g2"],
|
||||
"8 giorni resta valido, 7 e 0 generano avviso",
|
||||
);
|
||||
assert.equal(soglia.scaduti.length, 0);
|
||||
|
||||
// ordinamento tra scaduti: il più vecchio prima, poi alfabetico a parità di data
|
||||
const rosaScaduti: GiocatoreSquadra[] = [
|
||||
{ ...squadra[0]!, id: "g1", nome: "Ivan", cognome: "Zeta" },
|
||||
{ ...squadra[0]!, id: "g2", nome: "Anna", cognome: "Alfa" },
|
||||
{ ...squadra[0]!, id: "g3", nome: "Marco", cognome: "Verdi" },
|
||||
];
|
||||
const profiliScaduti: Record<string, Profilo> = {
|
||||
g1: { ...vuoto, giocatoreId: "g1", certificatoScadenza: "2026-09-01", certificatoPath: "p1" }, // scaduto da 19 giorni
|
||||
g2: { ...vuoto, giocatoreId: "g2", certificatoScadenza: "2026-09-01", certificatoPath: "p2" }, // pari data di g1
|
||||
g3: { ...vuoto, giocatoreId: "g3", certificatoScadenza: "2026-09-19", certificatoPath: "p3" }, // scaduto da 1 giorno
|
||||
};
|
||||
const scadutiOrdinati = avvisiCertificati(rosaScaduti, profiliScaduti, oggiTest).scaduti.map(
|
||||
(a) => a.giocatoreId,
|
||||
);
|
||||
assert.deepEqual(
|
||||
scadutiOrdinati,
|
||||
["g2", "g1", "g3"],
|
||||
"il più vecchio prima, poi alfabetico a parità di data (Alfa prima di Zeta)",
|
||||
);
|
||||
|
||||
console.log("profili-core: ok");
|
||||
Reference in new issue
Block a user