Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
990c2bbd4c | ||
|
|
764f75085e | ||
|
|
9e17c2fadd | ||
|
|
53b2252997 | ||
|
|
718ef09dfa | ||
|
|
3f52e9cc54 | ||
|
|
255afde48e | ||
|
|
da51517ffc | ||
|
|
ea29f053e3 | ||
|
|
e1e8dd5415 |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"vercel@claude-plugins-official": true,
|
||||
"supabase@claude-plugins-official": true,
|
||||
"claude-md-management@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
# Architecture Summary
|
||||
|
||||
Frontend
|
||||
|
||||
- React
|
||||
- TanStack Start
|
||||
- Tailwind
|
||||
- TypeScript
|
||||
|
||||
Backend
|
||||
|
||||
- Supabase
|
||||
|
||||
Hosting
|
||||
|
||||
- Vercel
|
||||
|
||||
Repository
|
||||
|
||||
- GitHub
|
||||
|
||||
Branch
|
||||
|
||||
- main
|
||||
- develop
|
||||
|
||||
Documentazione
|
||||
|
||||
- docs/
|
||||
|
||||
Database
|
||||
|
||||
- Supabase
|
||||
|
||||
Storage
|
||||
|
||||
- Supabase Storage
|
||||
@@ -1,12 +0,0 @@
|
||||
# Coding Style
|
||||
|
||||
Preferenze del progetto.
|
||||
|
||||
- Utilizzare TypeScript.
|
||||
- Preferire funzioni piccole.
|
||||
- Evitare duplicazione di codice.
|
||||
- Utilizzare componenti React riutilizzabili.
|
||||
- Commentare solamente il codice realmente complesso.
|
||||
- Preferire nomi descrittivi.
|
||||
- Non introdurre librerie senza reale necessità.
|
||||
- Mantenere la struttura esistente del progetto.
|
||||
@@ -1,22 +0,0 @@
|
||||
# CrAPP Context
|
||||
|
||||
CrAPP è una Progressive Web App dedicata alla gestione di una squadra di pallavolo amatoriale.
|
||||
|
||||
L'obiettivo principale NON è solamente registrare dati.
|
||||
|
||||
L'obiettivo è ridurre il lavoro amministrativo degli amministratori e aumentare il coinvolgimento dei giocatori attraverso gamification, statistiche e strumenti intelligenti.
|
||||
|
||||
Quando implementi nuove funzionalità:
|
||||
|
||||
- privilegia semplicità
|
||||
- mantieni la coerenza dell'interfaccia
|
||||
- evita duplicazioni
|
||||
- leggi sempre la documentazione presente in `docs/`
|
||||
|
||||
Prima di scrivere codice consulta:
|
||||
|
||||
- README
|
||||
- ROADMAP
|
||||
- DATABASE
|
||||
- ARCHITECTURE
|
||||
- il modulo interessato in `docs/modules`
|
||||
@@ -1,11 +0,0 @@
|
||||
# Development Workflow
|
||||
|
||||
Ogni nuova funzionalità segue questo flusso.
|
||||
|
||||
1. Discussione funzionale.
|
||||
2. Documento in `docs/modules`.
|
||||
3. Progettazione database.
|
||||
4. Implementazione su branch `develop`.
|
||||
5. Test.
|
||||
6. Merge su `main`.
|
||||
7. Deploy automatico tramite Vercel.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Project Rules
|
||||
|
||||
Queste regole devono essere rispettate per qualsiasi modifica al progetto.
|
||||
|
||||
## Regole generali
|
||||
|
||||
- Non modificare il branch `main` direttamente.
|
||||
- Tutte le nuove funzionalità vengono sviluppate su `develop`.
|
||||
- Prima di implementare una funzionalità leggere sempre la documentazione presente in `docs/`.
|
||||
- Non creare codice duplicato.
|
||||
- Riutilizzare sempre componenti già esistenti quando possibile.
|
||||
- Mantenere uno stile coerente con il progetto.
|
||||
|
||||
## Database
|
||||
|
||||
- Non modificare il database senza creare una nuova migration Supabase.
|
||||
- Non eliminare tabelle esistenti senza esplicita richiesta.
|
||||
- Preferire nuove tabelle rispetto all'aggiunta di molte colonne quando il modulo è indipendente.
|
||||
|
||||
## Componenti
|
||||
|
||||
- Preferire componenti piccoli e riutilizzabili.
|
||||
- Evitare componenti con responsabilità multiple.
|
||||
|
||||
## Documentazione
|
||||
|
||||
Ogni nuova funzionalità deve essere documentata prima dell'implementazione.
|
||||
|
||||
La documentazione tecnica si trova nella cartella `docs/`.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
description: Regole di progetto CrAPP
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Prima di qualsiasi modifica leggi @AGENTS.md e seguine le regole: sono vincolanti e valgono
|
||||
per intero.
|
||||
|
||||
- Non implementare funzionalità non documentate in `docs/`.
|
||||
- Lavora su `develop`, mai direttamente su `main`.
|
||||
- Codice, commenti e documentazione in italiano.
|
||||
|
||||
Non aggiungere regole in questo file: una regola nuova va in `AGENTS.md`, che leggono anche
|
||||
Claude Code e Codex. Vale per qualsiasi aggiunta o modifica — regola, funzionalità, decisione,
|
||||
schema database: prima di considerare finito il lavoro esegui la checklist «Fine lavoro» di
|
||||
`AGENTS.md`.
|
||||
@@ -1,231 +1,141 @@
|
||||
# AGENTS.md
|
||||
|
||||
# CrAPP - AI Development Guide
|
||||
|
||||
Questo documento definisce le regole che qualsiasi assistente AI (Cursor, Claude Code, Codex, ChatGPT o altri) deve seguire quando lavora su questo progetto.
|
||||
|
||||
---
|
||||
|
||||
# Obiettivo del progetto
|
||||
|
||||
CrAPP è una Progressive Web App sviluppata per digitalizzare completamente la gestione di una squadra di pallavolo.
|
||||
|
||||
L'obiettivo principale è:
|
||||
|
||||
- ridurre il lavoro amministrativo degli amministratori;
|
||||
- aumentare il coinvolgimento dei giocatori;
|
||||
- centralizzare tutte le informazioni della squadra;
|
||||
- utilizzare l'intelligenza artificiale solo quando porta un reale beneficio.
|
||||
|
||||
---
|
||||
|
||||
# Prima di modificare il codice
|
||||
|
||||
Prima di implementare qualsiasi modifica leggere sempre:
|
||||
|
||||
1. docs/README.md
|
||||
2. docs/VISION.md
|
||||
3. docs/ROADMAP.md
|
||||
4. docs/ARCHITECTURE.md
|
||||
5. docs/DATABASE.md
|
||||
6. docs/DESIGN_DECISIONS.md
|
||||
7. docs/TODO.md
|
||||
8. il documento interessato in docs/modules/
|
||||
|
||||
Non implementare funzionalità non documentate.
|
||||
|
||||
---
|
||||
|
||||
# Workflow di sviluppo
|
||||
|
||||
Ogni nuova funzionalità segue sempre questo processo.
|
||||
|
||||
Idea
|
||||
|
||||
↓
|
||||
|
||||
Progettazione
|
||||
|
||||
↓
|
||||
|
||||
Documentazione
|
||||
|
||||
↓
|
||||
|
||||
Database
|
||||
|
||||
↓
|
||||
|
||||
Implementazione
|
||||
|
||||
↓
|
||||
|
||||
Test
|
||||
|
||||
↓
|
||||
|
||||
Merge su main
|
||||
|
||||
↓
|
||||
|
||||
Deploy automatico
|
||||
|
||||
---
|
||||
|
||||
# Git
|
||||
|
||||
Il repository utilizza due branch principali.
|
||||
|
||||
## main
|
||||
|
||||
Versione stabile.
|
||||
|
||||
Qualsiasi modifica deve mantenere l'app perfettamente funzionante.
|
||||
|
||||
## develop
|
||||
|
||||
Branch utilizzato per lo sviluppo delle nuove funzionalità.
|
||||
|
||||
Tutte le nuove implementazioni devono essere realizzate qui.
|
||||
|
||||
---
|
||||
|
||||
# Architettura
|
||||
|
||||
Frontend
|
||||
|
||||
- React
|
||||
- TypeScript
|
||||
- TanStack Start
|
||||
- Tailwind CSS
|
||||
|
||||
Backend
|
||||
|
||||
- Supabase
|
||||
|
||||
Hosting
|
||||
|
||||
- Vercel
|
||||
|
||||
Repository
|
||||
|
||||
- GitHub
|
||||
|
||||
---
|
||||
|
||||
# Database
|
||||
|
||||
Il database utilizza Supabase.
|
||||
|
||||
Regole:
|
||||
|
||||
- non eliminare tabelle esistenti;
|
||||
- non modificare lo schema senza creare una migration;
|
||||
- preferire strutture scalabili;
|
||||
- evitare duplicazione dei dati.
|
||||
|
||||
Fare sempre riferimento a:
|
||||
|
||||
docs/DATABASE.md
|
||||
|
||||
---
|
||||
|
||||
# Componenti
|
||||
|
||||
Preferire:
|
||||
|
||||
- componenti piccoli;
|
||||
- componenti riutilizzabili;
|
||||
- responsabilità singola;
|
||||
- codice semplice da mantenere.
|
||||
|
||||
Evitare duplicazioni.
|
||||
|
||||
---
|
||||
|
||||
# Interfaccia
|
||||
|
||||
Lo stile dell'app deve rimanere coerente.
|
||||
|
||||
Principi:
|
||||
|
||||
- semplice;
|
||||
- moderna;
|
||||
- pulita;
|
||||
- veloce;
|
||||
- ottimizzata per smartphone;
|
||||
- poche schermate;
|
||||
- pochi click.
|
||||
|
||||
---
|
||||
|
||||
# Documentazione
|
||||
|
||||
Ogni nuova funzionalità deve essere documentata prima dello sviluppo.
|
||||
|
||||
La documentazione dei moduli si trova in:
|
||||
|
||||
docs/modules/
|
||||
|
||||
Aggiornare sempre, quando necessario:
|
||||
|
||||
- ROADMAP.md
|
||||
- CHANGELOG.md
|
||||
- TODO.md
|
||||
- DATABASE.md (se il database cambia)
|
||||
- DESIGN_DECISIONS.md (se si prende una decisione architetturale importante)
|
||||
---
|
||||
|
||||
# Struttura della documentazione
|
||||
|
||||
La cartella `docs/` rappresenta la documentazione ufficiale del progetto.
|
||||
|
||||
## Documenti principali
|
||||
|
||||
- README.md → panoramica del progetto
|
||||
- VISION.md → obiettivi e filosofia
|
||||
- ROADMAP.md → evoluzione prevista
|
||||
- ARCHITECTURE.md → architettura tecnica
|
||||
- DATABASE.md → struttura del database
|
||||
- DESIGN_DECISIONS.md → registro delle decisioni di progetto
|
||||
- CHANGELOG.md → cronologia delle modifiche
|
||||
- TODO.md → attività pianificate
|
||||
|
||||
## Moduli
|
||||
|
||||
La cartella `docs/modules/` contiene una specifica funzionale per ogni modulo dell'applicazione.
|
||||
|
||||
Ogni nuovo modulo deve essere progettato e documentato prima dell'implementazione.
|
||||
---
|
||||
|
||||
# Regole
|
||||
|
||||
L'AI non deve:
|
||||
# AGENTS.md — CrAPP
|
||||
|
||||
Regole che qualsiasi assistente AI (Claude Code, Codex, Cursor, ChatGPT o altri) deve seguire
|
||||
quando lavora su questo progetto. **È l'unica fonte delle regole**: `CLAUDE.md` e
|
||||
`.cursor/rules/` rimandano qui, non ripetono nulla.
|
||||
|
||||
> **Qualsiasi cosa venga aggiunta o modificata — una regola, una funzionalità, una decisione,
|
||||
> una tabella — va registrata nei file di riferimento del progetto prima di considerare il
|
||||
> lavoro finito**, indipendentemente dall'assistente con cui è stata fatta. Vedi
|
||||
> [Fine lavoro](#fine-lavoro-cosa-aggiornare-sempre): non è un passaggio opzionale.
|
||||
|
||||
CrAPP è una Progressive Web App per la gestione di una squadra di pallavolo amatoriale (CRAP
|
||||
Volley). Obiettivi e principi in [docs/VISION.md](docs/VISION.md), architettura in
|
||||
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
**Codice, commenti, nomi di variabili e documentazione sono in italiano**: mantieni questa
|
||||
convenzione.
|
||||
|
||||
## Prima di modificare il codice
|
||||
|
||||
Leggere sempre, nell'ordine:
|
||||
|
||||
1. [docs/README.md](docs/README.md) — indice della documentazione
|
||||
2. [docs/VISION.md](docs/VISION.md)
|
||||
3. [docs/ROADMAP.md](docs/ROADMAP.md)
|
||||
4. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||
5. [docs/DATABASE.md](docs/DATABASE.md)
|
||||
6. [docs/DESIGN_DECISIONS.md](docs/DESIGN_DECISIONS.md)
|
||||
7. [docs/TODO.md](docs/TODO.md)
|
||||
8. il documento del modulo interessato in [docs/modules/](docs/modules/)
|
||||
|
||||
**Non implementare funzionalità non documentate** (DD-002).
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
idea → progettazione → documento in docs/modules/ → database → implementazione su develop
|
||||
→ test → merge su main → deploy automatico Vercel
|
||||
```
|
||||
|
||||
- `main` = produzione, sempre funzionante: **non si modifica direttamente**. Qualsiasi
|
||||
modifica deve mantenere l'app perfettamente funzionante.
|
||||
- `develop` = sviluppo e preview; tutte le nuove implementazioni nascono qui.
|
||||
|
||||
### Commit
|
||||
|
||||
- **Si committa solo quando l'utente lo chiede**, mai di propria iniziativa. Lo stesso vale
|
||||
per il push, che su `develop` fa partire un deploy di preview.
|
||||
- Quando l'utente lo chiede, **il messaggio lo scrive l'assistente in autonomia**, senza
|
||||
farlo approvare prima.
|
||||
- **Il messaggio è in inglese**, all'imperativo presente (`Add medical certificate expiry`),
|
||||
riga di riepilogo sotto i 72 caratteri. È l'unica eccezione all'italiano: codice, commenti
|
||||
e documentazione restano in italiano. I commit precedenti sono in italiano e non vanno
|
||||
riscritti.
|
||||
- Se il lavoro attua una decisione registrata, il messaggio la cita: `DD-017: ...`.
|
||||
- Nel commit entrano insieme codice e documentazione: la checklist
|
||||
[Fine lavoro](#fine-lavoro-cosa-aggiornare-sempre) va eseguita prima, non in un commit a parte.
|
||||
|
||||
## Vincoli tecnici da non violare
|
||||
|
||||
- `src/routeTree.gen.ts` e `src/integrations/supabase/{client.ts,types.ts}` sono **generati**:
|
||||
non modificarli a mano.
|
||||
- **Non ri-aggiungere** i plugin Vite (devtools, tanstackStart, viteReact, tailwind,
|
||||
tsconfig-paths, nitro) già inclusi da `@lovable.dev/vite-tanstack-config` in
|
||||
`vite.config.ts`: l'app si rompe.
|
||||
- Nessun accesso al database dai componenti: solo tramite i moduli in `src/lib/`, così il
|
||||
backend resta sostituibile in un solo punto (DD-013).
|
||||
- Efficienza cloud: niente polling, cache lunga, e dopo una mutazione `setQueryData` invece di
|
||||
`invalidateQueries`. Regole complete in [docs/EFFICIENZA_CLOUD.md](docs/EFFICIENZA_CLOUD.md).
|
||||
- Badge e statistiche si calcolano a runtime, non si persistono (DD-007); la gamification
|
||||
resta equa tra ruoli (DD-008): niente metriche che favoriscano attaccanti o liberi.
|
||||
- L'app deve poter girare su Node.js + PostgreSQL standard: niente servizi esclusivi
|
||||
Lovable/Vercel (DD-001, DD-013, [docs/PORTABILITA.md](docs/PORTABILITA.md)).
|
||||
|
||||
## Database
|
||||
|
||||
- Non eliminare tabelle esistenti.
|
||||
- Non modificare lo schema senza creare una migration in `supabase/migrations/`.
|
||||
- Preferire una nuova tabella all'aggiunta di molte colonne, quando il modulo è indipendente.
|
||||
- Preferire strutture scalabili; evitare duplicazione dei dati.
|
||||
- Riferimento: [docs/DATABASE.md](docs/DATABASE.md), da aggiornare nella stessa modifica che
|
||||
cambia lo schema.
|
||||
|
||||
## Codice e componenti
|
||||
|
||||
- TypeScript, funzioni piccole, nomi descrittivi.
|
||||
- Componenti piccoli, riutilizzabili, a responsabilità singola.
|
||||
- Nessuna duplicazione: riusare sempre i componenti e i moduli esistenti.
|
||||
- Commentare solo il codice realmente complesso.
|
||||
- Nessuna nuova dipendenza senza reale necessità; mantenere la struttura esistente.
|
||||
- Le dipendenze si installano con **bun**; `bunfig.toml` impone `minimumReleaseAge = 24h` come
|
||||
guardia supply-chain: aggiungere un pacchetto a `minimumReleaseAgeExcludes` richiede
|
||||
conferma esplicita dell'utente.
|
||||
|
||||
## Interfaccia
|
||||
|
||||
Stile coerente con l'esistente: semplice, moderna, pulita, veloce, ottimizzata per
|
||||
smartphone, poche schermate e pochi click (DD-005). Riusare i componenti in
|
||||
`src/components/crapp/` (`ui-bits.tsx` per `PageHeader`, `Section`, `StatTile`) e le primitive
|
||||
shadcn in `src/components/ui/`.
|
||||
|
||||
## Fine lavoro: cosa aggiornare sempre
|
||||
|
||||
Il lavoro **non è finito** finché non è registrato dove va. Vale per tutti gli assistenti allo
|
||||
stesso modo: chi fa la modifica aggiorna i file, chiunque la stia facendo e da qualunque
|
||||
strumento. Un cambiamento che vive solo nel codice o solo nella chat è un cambiamento perso.
|
||||
|
||||
| Cosa hai aggiunto o cambiato | Dove va registrato |
|
||||
|---|---|
|
||||
| Una **regola** per gli assistenti (convenzione, divieto, vincolo di lavoro) | **Questo file, e solo questo.** Mai in `CLAUDE.md` o `.cursor/rules/`: rimandano qui, e una regola scritta lì la vedrebbe un assistente solo |
|
||||
| Una **funzionalità** | `docs/modules/<modulo>.md` (**prima** di scrivere il codice), poi `docs/ROADMAP.md` e `docs/CHANGELOG.md` |
|
||||
| Una **decisione** architetturale o di prodotto | `docs/DESIGN_DECISIONS.md`, formato `DD-XXX` (copiare `docs/_template-dd.md`) e aggiungerla all'indice in cima |
|
||||
| Una **modifica allo schema** del database | una migration in `supabase/migrations/` **e** `docs/DATABASE.md`, nella stessa modifica |
|
||||
| Lavoro iniziato, sospeso o concluso | `docs/TODO.md` e `PROJECT_STATE.md` |
|
||||
| Un **comando** o uno script nuovo | `docs/ARCHITECTURE.md` (sezione Comandi) e `CLAUDE.md` |
|
||||
|
||||
Quale informazione vive in quale file — e perché non va duplicata altrove — è spiegato in
|
||||
[docs/README.md](docs/README.md).
|
||||
|
||||
## Cosa l'AI non deve fare
|
||||
|
||||
- introdurre librerie senza necessità;
|
||||
- modificare il database senza motivazione;
|
||||
- eliminare funzionalità esistenti;
|
||||
- modificare il comportamento dell'app senza richiesta esplicita.
|
||||
|
||||
L'AI deve:
|
||||
## Cosa l'AI deve fare
|
||||
|
||||
- spiegare le modifiche importanti;
|
||||
- mantenere compatibilità con il codice esistente;
|
||||
- privilegiare la semplicità;
|
||||
- riutilizzare i componenti esistenti.
|
||||
|
||||
---
|
||||
## Filosofia
|
||||
|
||||
# Filosofia del progetto
|
||||
Prima di scrivere codice, chiedersi sempre:
|
||||
|
||||
Prima di scrivere codice chiedersi sempre:
|
||||
|
||||
Questa modifica rende CrAPP più semplice?
|
||||
|
||||
Riduce il lavoro degli amministratori?
|
||||
|
||||
Migliora l'esperienza dei giocatori?
|
||||
|
||||
È coerente con la documentazione?
|
||||
|
||||
Se almeno una risposta è negativa, rivalutare la soluzione proposta.
|
||||
- questa modifica rende CrAPP più semplice?
|
||||
- riduce il lavoro degli amministratori?
|
||||
- migliora l'esperienza dei giocatori?
|
||||
- è coerente con la documentazione?
|
||||
|
||||
@@ -1,54 +1,26 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Le regole di progetto stanno in @AGENTS.md: valgono integralmente e non sono ripetute qui.
|
||||
La documentazione tecnica è indicizzata in [docs/README.md](docs/README.md); l'architettura
|
||||
in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
CrAPP — PWA per la gestione di una squadra di pallavolo amatoriale (CRAP Volley). Codice, commenti, nomi di variabili e documentazione sono **in italiano**: mantieni questa convenzione.
|
||||
|
||||
## Regole di progetto
|
||||
|
||||
[AGENTS.md](AGENTS.md) contiene le regole vincolanti per gli assistenti AI. In sintesi:
|
||||
|
||||
- **Document-first**: nessuna funzionalità va implementata se non è già documentata in [docs/](docs/) (in particolare `docs/modules/<modulo>.md`). Leggi il documento del modulo prima di scrivere codice.
|
||||
- Lavora sul branch `develop`, mai direttamente su `main` (`main` = produzione, deploy automatico Vercel).
|
||||
- Dopo una modifica aggiorna, quando pertinente: `docs/ROADMAP.md`, `docs/CHANGELOG.md`, `docs/TODO.md`, `docs/DATABASE.md` (se cambia lo schema), `docs/DESIGN_DECISIONS.md` (decisioni architetturali, formato DD-XXX con indice in fondo al file), `PROJECT_STATE.md`.
|
||||
- Nessuna nuova dipendenza senza reale necessità; riusa i componenti esistenti.
|
||||
- Il DB si modifica solo con una nuova migration in `supabase/migrations/`; non eliminare tabelle.
|
||||
- [docs/PORTABILITA.md](docs/PORTABILITA.md) / DD-001 / DD-013: l'app deve poter girare su Node.js + PostgreSQL standard. Evita servizi esclusivi Lovable/Vercel.
|
||||
**Non aggiungere regole in questo file.** Una regola nuova va in `AGENTS.md`, che leggono
|
||||
anche Codex e Cursor; scritta qui la vedrebbe solo Claude Code. Vale per qualsiasi aggiunta o
|
||||
modifica: prima di dire che hai finito, esegui la checklist «Fine lavoro» di `AGENTS.md`.
|
||||
|
||||
## Comandi
|
||||
|
||||
```bash
|
||||
npm run dev # vite dev su http://localhost:8080
|
||||
npm run build # build di produzione (nitro)
|
||||
npm run lint # eslint (include prettier come regola)
|
||||
npm run format # prettier --write .
|
||||
npm run dev # vite dev su http://localhost:8080
|
||||
npm run build # build di produzione (nitro)
|
||||
npm run lint # eslint (include prettier come regola)
|
||||
npm run format # prettier --write .
|
||||
npm run test # suite di test (test/); npm run test:all per quella completa
|
||||
|
||||
npx supabase start # database locale in Docker (migration applicate + seed)
|
||||
npx supabase stop # spegne i container
|
||||
npx supabase db reset # ricrea il database locale da zero
|
||||
npx supabase db push # applica le migration al progetto cloud
|
||||
```
|
||||
|
||||
Non esiste una suite di test automatici: la verifica è manuale via `npm run dev` + `npm run lint`.
|
||||
|
||||
Le dipendenze sono installate con **bun** (`bun.lock`, `bunfig.toml`). `bunfig.toml` impone `minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a `minimumReleaseAgeExcludes` richiede conferma esplicita dell'utente.
|
||||
|
||||
## Architettura
|
||||
|
||||
**Stack**: React 19 + TanStack Start (SSR) + Vite 8 + Tailwind 4 + Radix/shadcn, Supabase come backend, Vercel per l'hosting.
|
||||
|
||||
- **Routing**: file-based in [src/routes/](src/routes/); `src/routeTree.gen.ts` è generato — non modificarlo a mano.
|
||||
- **Configurazione Vite**: [vite.config.ts](vite.config.ts) usa `@lovable.dev/vite-tanstack-config`, che include già devtools, tanstackStart, viteReact, tailwind, tsconfig-paths, nitro e l'alias `@` → `src/`. **Non ri-aggiungere questi plugin** o l'app si rompe.
|
||||
- **Entry point server**: [src/server.ts](src/server.ts) avvolge l'entry di TanStack Start per intercettare gli errori SSR che h3 trasforma silenziosamente in un 500 JSON, e renderizza `renderErrorPage()`. [src/start.ts](src/start.ts) registra i middleware globali (error handler, CSRF sui server functions, `attachSupabaseAuth`).
|
||||
- **Supabase**: `src/integrations/supabase/client.ts` (browser/SSR, chiave publishable — file generato) e `client.server.ts` (`supabaseAdmin`, solo server). `types.ts` è generato dallo schema.
|
||||
|
||||
**Livello dati** — tutta la logica di dominio sta in [src/lib/](src/lib/), un file per modulo (`presenze`, `eventi`, `pagelle`, `mvp-voti`, `palloni`, `cacche`, `badges`, `scout-*`, `infortuni`, …). Il pattern ricorrente:
|
||||
|
||||
- ogni modulo esporta hook TanStack Query (`useX`) con `staleTime` lungo e mutation che invalidano la propria chiave;
|
||||
- le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs `palloni.ts`, `mediePagelle()` vs `usePagelle()`);
|
||||
- [src/lib/rosa.ts](src/lib/rosa.ts) è l'aggregatore: compone tutti gli hook e restituisce la rosa completa con le statistiche derivate, **senza query aggiuntive** rispetto a quelle già in cache. Le route consumano `useRosa()`, non i singoli moduli.
|
||||
|
||||
Vincoli di efficienza cloud (vedi `mem/`): niente polling, cache lunga, aggregati precalcolati.
|
||||
|
||||
**Badge e statistiche** sono calcolati a runtime dai dati, non persistiti (DD-007). La gamification deve restare equa tra ruoli (DD-008): niente metriche che favoriscano attaccanti o liberi.
|
||||
|
||||
La rosa è tuttora **hardcoded** in `src/lib/crapp-data.ts` (`rosaCSI`); la migrazione verso la tabella `giocatori_squadra` (migration `20260828170400_m1_giocatori_squadra.sql`) è in corso — vedi DD-015 e DD-016.
|
||||
|
||||
## UI
|
||||
|
||||
Componenti condivisi in [src/components/crapp/](src/components/crapp/) (`ui-bits.tsx` per `PageHeader`, `Section`, `StatTile`), primitive shadcn in `src/components/ui/`, animazioni in `src/components/motion/`. Mobile-first (DD-005): poche schermate, pochi click.
|
||||
Verifica minima prima di consegnare: `npm run lint` + `npm run test`.
|
||||
|
||||
+45
-5
@@ -6,7 +6,9 @@ Ultimo aggiornamento: 30/08/2026
|
||||
|
||||
Fase corrente:
|
||||
|
||||
Backend migrato al nuovo Supabase proprietario. M1 completata. Profilo Giocatore da implementare (M2).
|
||||
Backend migrato al nuovo Supabase proprietario. M1 completata. M2 e M3 scritte e da applicare.
|
||||
Autenticazione Google, dashboard amministratore e Profilo Giocatore (lato giocatore e lato
|
||||
admin) implementati su `develop`, da attivare in produzione seguendo i passaggi più sotto.
|
||||
|
||||
---
|
||||
|
||||
@@ -47,18 +49,56 @@ Backend migrato al nuovo Supabase proprietario. M1 completata. Profilo Giocatore
|
||||
- Pagelle
|
||||
- MVP
|
||||
- Notifiche
|
||||
- Profilo Giocatore (su `develop`, specifica in `docs/modules/profilo-giocatore.md`)
|
||||
|
||||
---
|
||||
|
||||
## Modulo da implementare
|
||||
## Autenticazione e dashboard amministratore
|
||||
|
||||
Profilo Giocatore (specifica in `docs/modules/profilo-giocatore.md`)
|
||||
Implementate su `develop`, **non ancora attive in produzione**. Il codice è additivo: finché
|
||||
i passaggi qui sotto non sono fatti, l'app si comporta esattamente come prima.
|
||||
|
||||
---
|
||||
**Stato in sviluppo (30/08/2026):** il codice c'è ed è completo, ma il login non funziona
|
||||
ancora. Con `npm run dev`, «Accedi con Google» risponde:
|
||||
|
||||
```
|
||||
{"code":400,"error_code":"validation_failed","msg":"Unsupported provider: provider is not enabled"}
|
||||
```
|
||||
|
||||
Non è un difetto dell'app: l'errore arriva da Supabase, dove il provider Google è spento.
|
||||
È il passo 1 qui sotto, ancora da fare. Tutto il resto dell'app in dev funziona normalmente,
|
||||
perché l'accesso avviene ancora scegliendo il proprio nome.
|
||||
|
||||
Passaggi in ordine, nessuno dei quali è reversibile a metà:
|
||||
|
||||
1. **Provider Google in Supabase** — Google Cloud Console: consent screen *External* (scope
|
||||
`email` e `profile`, non sensibili: nessuna verifica richiesta, e la modalità *Testing*
|
||||
regge fino a 100 utenti, più che sufficiente per la squadra), credenziale
|
||||
*Web application* con redirect URI
|
||||
`https://kfkcldwncxqaixetsjes.supabase.co/auth/v1/callback`. Client ID e
|
||||
secret in *Authentication → Providers → Google*. In *URL Configuration*: Site URL di
|
||||
produzione, più `localhost:8080` e il wildcard delle preview Vercel tra i Redirect URLs.
|
||||
Per provare sullo stack locale invece che sul cloud servono anche `enabled = true` in
|
||||
`[auth.external.google]` di `supabase/config.toml`, le due variabili
|
||||
`SUPABASE_AUTH_GOOGLE_*` in `.env` e una credenziale con redirect URI
|
||||
`http://127.0.0.1:54321/auth/v1/callback`.
|
||||
2. **Migration M2 e M3** (`supabase db push`). Sono `CREATE` puri: si possono applicare in
|
||||
produzione senza toccare il comportamento attuale.
|
||||
3. **Primo admin**, dopo il primo login (l'ID esiste solo da quel momento):
|
||||
`INSERT INTO public.user_roles (user_id, role) SELECT id, 'admin' FROM auth.users WHERE email = '<mail>';`
|
||||
4. **Collegamento dei 17 account**: ciascuno accede con Google e sceglie il proprio nome una
|
||||
volta sola. Uno slot già collegato può essere liberato solo da un admin.
|
||||
5. **Solo a squadra collegata**: `VITE_AUTH_OBBLIGATORIA=true` su Vercel (fa sparire la
|
||||
selezione libera del giocatore), poi la migration che rimuove le policy `anon` dalle
|
||||
tabelle v1.0. È l'unico passo che cambia il comportamento per tutti.
|
||||
|
||||
Attenzione: dev e produzione condividono lo stesso progetto Supabase. Un account di prova che
|
||||
collega uno slot lo occupa anche in produzione, e va liberato da un admin.
|
||||
|
||||
## Prossimo sviluppo
|
||||
|
||||
**M2** — `profili_giocatore` + Supabase Storage privato + RLS
|
||||
Gestione tesseramenti CSI: la raccolta dati e l'export CSV sono pronti, manca il
|
||||
tracciamento di chi è già tesserato (numero e data di tessera).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+102
-50
@@ -1,71 +1,123 @@
|
||||
# Architettura del progetto
|
||||
|
||||
## Frontend
|
||||
Come è fatta CrAPP: stack, organizzazione del codice, flusso di sviluppo. È il documento di
|
||||
riferimento tecnico — `CLAUDE.md` non ripete questi contenuti, li richiama.
|
||||
|
||||
- React 19
|
||||
- TypeScript
|
||||
- TanStack Start
|
||||
- Vite
|
||||
- Tailwind CSS
|
||||
- Radix UI
|
||||
## Stack
|
||||
|
||||
---
|
||||
| Livello | Tecnologie |
|
||||
|---|---|
|
||||
| Frontend | React 19, TypeScript, TanStack Start (SSR), Vite 8, Tailwind CSS 4, Radix UI / shadcn |
|
||||
| Backend | Supabase (PostgreSQL, Auth, Storage) |
|
||||
| Hosting | Vercel |
|
||||
| Versionamento | Git, GitHub |
|
||||
|
||||
## Backend
|
||||
|
||||
- Supabase
|
||||
|
||||
---
|
||||
|
||||
## Hosting
|
||||
|
||||
- Vercel
|
||||
|
||||
---
|
||||
|
||||
## Versionamento
|
||||
|
||||
- Git
|
||||
- GitHub
|
||||
|
||||
---
|
||||
|
||||
## Branch
|
||||
|
||||
- main → Produzione
|
||||
- develop → Sviluppo
|
||||
|
||||
---
|
||||
Le dipendenze sono installate con **bun** (`bun.lock`, `bunfig.toml`). `bunfig.toml` impone
|
||||
`minimumReleaseAge = 24h` come guardia supply-chain: aggiungere un pacchetto a
|
||||
`minimumReleaseAgeExcludes` richiede conferma esplicita.
|
||||
|
||||
## Struttura del progetto
|
||||
|
||||
```
|
||||
src/
|
||||
components/ componenti condivisi (crapp/, ui/, motion/)
|
||||
routes/ routing file-based
|
||||
lib/ logica di dominio, un file per modulo
|
||||
integrations/ client Supabase e integrazioni esterne
|
||||
hooks/
|
||||
assets/
|
||||
supabase/ migration SQL
|
||||
test/ suite di test (unit, integration, end-to-end)
|
||||
docs/ documentazione ufficiale
|
||||
```
|
||||
|
||||
- components/
|
||||
- routes/
|
||||
- lib/
|
||||
- integrations/
|
||||
- hooks/
|
||||
- assets/
|
||||
## Punti fermi
|
||||
|
||||
supabase/
|
||||
- **Routing**: file-based in `src/routes/`. `src/routeTree.gen.ts` è **generato**, non si
|
||||
modifica a mano.
|
||||
- **Configurazione Vite**: `vite.config.ts` usa `@lovable.dev/vite-tanstack-config`, che
|
||||
include già devtools, tanstackStart, viteReact, tailwind, tsconfig-paths, nitro e l'alias
|
||||
`@` → `src/`. **Non ri-aggiungere questi plugin**: l'app si rompe.
|
||||
- **Entry point server**: `src/server.ts` avvolge l'entry di TanStack Start per intercettare
|
||||
gli errori SSR che h3 trasformerebbe in un 500 JSON silenzioso, e renderizza
|
||||
`renderErrorPage()`. `src/start.ts` registra i middleware globali (error handler, CSRF sui
|
||||
server functions, `attachSupabaseAuth`).
|
||||
- **Supabase**: `src/integrations/supabase/client.ts` (browser/SSR, chiave publishable — file
|
||||
generato) e `client.server.ts` (`supabaseAdmin`, solo server). `types.ts` è generato dallo
|
||||
schema; finché non viene rigenerato, le tabelle introdotte da M1/M2 si usano tramite
|
||||
`client-nuove-tabelle.ts`, con i tipi di riga dichiarati nei moduli di `src/lib/`.
|
||||
- **Autenticazione**: login Google via Supabase Auth (`src/lib/auth.ts`, DD-011); i permessi
|
||||
di amministrazione arrivano da `user_roles` (`src/lib/ruoli.ts`). La variabile
|
||||
`VITE_AUTH_OBBLIGATORIA` decide se `/benvenuto` accetta ancora la selezione libera del
|
||||
giocatore: finché è spenta, login e vecchio accesso convivono.
|
||||
|
||||
docs/
|
||||
## Livello dati
|
||||
|
||||
---
|
||||
Tutta la logica di dominio sta in `src/lib/`, un file per modulo (`presenze`, `eventi`,
|
||||
`pagelle`, `mvp-voti`, `palloni`, `cacche`, `badges`, `scout-*`, `infortuni`, …). Il pattern
|
||||
ricorrente:
|
||||
|
||||
## Flusso di sviluppo
|
||||
- ogni modulo esporta hook TanStack Query (`useX`); i default globali stanno in
|
||||
`src/router.tsx` (`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect`
|
||||
disattivati, `retry: 1`);
|
||||
- dopo una mutazione la cache si aggiorna con `setQueryData`, **non** con
|
||||
`invalidateQueries`: invalidare provoca una rilettura e costa una query in più (unica
|
||||
eccezione oggi: `scout-live.ts`);
|
||||
- le funzioni pure di calcolo sono separate dagli hook (es. `palloni-core.ts` vs
|
||||
`palloni.ts`, `mediePagelle()` vs `usePagelle()`);
|
||||
- `src/lib/rosa.ts` è l'aggregatore: compone tutti gli hook e restituisce la rosa completa
|
||||
con le statistiche derivate, **senza query aggiuntive** rispetto a quelle già in cache. Le
|
||||
route consumano `useRosa()`, non i singoli moduli.
|
||||
|
||||
develop
|
||||
Nessun accesso al database dai componenti: solo attraverso i moduli in `src/lib/`, così il
|
||||
backend resta sostituibile in un solo punto (DD-013, [PORTABILITA.md](PORTABILITA.md)).
|
||||
|
||||
↓
|
||||
Vincoli di efficienza cloud — niente polling, cache lunga, `setQueryData` invece di
|
||||
`invalidateQueries` — in [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md).
|
||||
|
||||
Test
|
||||
Badge e statistiche sono calcolati a runtime dai dati, non persistiti (DD-007). La
|
||||
gamification deve restare equa tra ruoli (DD-008).
|
||||
|
||||
↓
|
||||
La rosa è tuttora **hardcoded** in `src/lib/crapp-data.ts` (`rosaCSI`); la migrazione verso
|
||||
la tabella `giocatori_squadra` è in corso — vedi DD-015 e DD-016.
|
||||
|
||||
Merge su main
|
||||
## UI
|
||||
|
||||
↓
|
||||
Componenti condivisi in `src/components/crapp/` (`ui-bits.tsx` per `PageHeader`, `Section`,
|
||||
`StatTile`), primitive shadcn in `src/components/ui/`, animazioni in
|
||||
`src/components/motion/`. Mobile-first (DD-005): poche schermate, pochi click.
|
||||
|
||||
Deploy automatico su Vercel
|
||||
## Comandi
|
||||
|
||||
```bash
|
||||
npm run dev # vite dev su http://localhost:8080
|
||||
npm run build # build di produzione (nitro)
|
||||
npm run lint # eslint (include prettier come regola)
|
||||
npm run format # prettier --write .
|
||||
npm run test # test unit (veloci, senza rete né database)
|
||||
npm run test:integration # route server vere
|
||||
npm run test:e2e # percorsi sull'app servita
|
||||
npm run test:all # tutto
|
||||
```
|
||||
|
||||
Database di sviluppo in locale (Docker), alternativo al progetto Supabase cloud:
|
||||
|
||||
```bash
|
||||
npx supabase start # avvia lo stack locale e applica tutte le migration
|
||||
npx supabase stop # spegne i container
|
||||
npx supabase db reset # ricrea il database da zero: migration + supabase/seed.sql
|
||||
npx supabase db push # applica le migration al progetto cloud
|
||||
```
|
||||
|
||||
`supabase/seed.sql` popola qualche profilo di prova e gira **solo in locale**. Serve perché
|
||||
il progetto cloud è uno solo, condiviso tra sviluppo e produzione: lo stack locale è il posto
|
||||
dove provare le migration distruttive senza toccare i dati veri.
|
||||
|
||||
## Branch e flusso di sviluppo
|
||||
|
||||
- `main` → produzione, deploy automatico su Vercel.
|
||||
- `develop` → sviluppo; si lavora qui, mai direttamente su `main` (DD-003).
|
||||
|
||||
```
|
||||
develop → test → merge su main → deploy automatico su Vercel
|
||||
```
|
||||
|
||||
+27
-17
@@ -1,10 +1,30 @@
|
||||
# Changelog
|
||||
|
||||
Tutte le modifiche significative del progetto vengono registrate in questo documento.
|
||||
Tutte le modifiche significative del progetto vengono registrate in questo documento, in
|
||||
ordine dalla più recente. L'elenco delle funzionalità disponibili e previste non si ripete
|
||||
qui: sta in [ROADMAP.md](ROADMAP.md).
|
||||
|
||||
---
|
||||
## Versione attuale — agosto 2026
|
||||
|
||||
## Versione attuale
|
||||
### Autenticazione e dashboard amministratore (su `develop`, non ancora in produzione)
|
||||
|
||||
- Login con Google tramite Supabase Auth (DD-011). Al primo accesso l'account si collega a
|
||||
un giocatore di `giocatori_squadra`, e il collegamento non è più modificabile dal
|
||||
giocatore stesso (DD-016 regola 2).
|
||||
- I permessi di amministrazione arrivano da `user_roles` (`src/lib/ruoli.ts`) e non più
|
||||
dalla lista di nomi in `crapp-data.ts`, che resta solo come ponte finché
|
||||
`VITE_AUTH_OBBLIGATORIA` non viene acceso.
|
||||
- Profilo giocatore: da `/profilo` ognuno compila i propri dati anagrafici e carica
|
||||
documento, certificato medico e foto tessera con le relative scadenze
|
||||
([modules/profilo-giocatore.md](modules/profilo-giocatore.md)).
|
||||
- Nuova schermata `/admin`: stato dei profili della squadra, download di documento,
|
||||
certificato e foto tessera, export CSV per il tesseramento CSI.
|
||||
- Migration `m2_profili_giocatore` (tabella dei profili) e `m3_bucket_profili` (bucket
|
||||
privato), entrambe additive.
|
||||
- Dalla dashboard l'amministratore modifica i dati squadra (nome, cognome, numero, ruolo),
|
||||
compila i dati personali al posto di un giocatore e scollega un account da un profilo
|
||||
(DD-017). I file restano esclusi: li carica solo il giocatore. Nessuna migration: le
|
||||
policy di M1 e M2 lo consentivano già.
|
||||
|
||||
### Test
|
||||
|
||||
@@ -18,7 +38,7 @@ Tutte le modifiche significative del progetto vengono registrate in questo docum
|
||||
- Classifica e risultati ufficiali letti dal portale Livescore CSI Bologna
|
||||
(stagione 2025/26, Campionato Open Misto Eccellenza, Girone B).
|
||||
- La pagina Campionato non usa più dati dimostrativi.
|
||||
- Dettagli e limiti in `docs/modules/collegamento-csi.md`.
|
||||
- Dettagli e limiti in [modules/collegamento-csi.md](modules/collegamento-csi.md).
|
||||
|
||||
### Infrastruttura
|
||||
|
||||
@@ -28,17 +48,7 @@ Tutte le modifiche significative del progetto vengono registrate in questo docum
|
||||
- Deploy automatico tramite Vercel.
|
||||
- Branch main e develop.
|
||||
|
||||
---
|
||||
## Versione 1.0 — luglio 2026
|
||||
|
||||
## Funzionalità implementate
|
||||
|
||||
- Gestione squadra
|
||||
- Calendario
|
||||
- Presenze
|
||||
- Scout Live
|
||||
- Badge
|
||||
- Obiettivi di squadra
|
||||
- Pagelle
|
||||
- Badge social
|
||||
- Serie di presenze
|
||||
- Notifiche intelligenti
|
||||
Prima versione usata dalla squadra. Funzionalità incluse: vedi
|
||||
[ROADMAP.md § Versione 1.0](ROADMAP.md#versione-10--rilasciata).
|
||||
|
||||
+47
-140
@@ -1,159 +1,66 @@
|
||||
# Database CrAPP
|
||||
|
||||
## Obiettivo
|
||||
Struttura del database Supabase (PostgreSQL) e ruolo di ogni tabella. Lo schema autoritativo
|
||||
sono le migration in `supabase/migrations/`: **una tabella nuova va documentata qui nella
|
||||
stessa modifica che la crea**. Le funzionalità future stanno in [ROADMAP.md](ROADMAP.md),
|
||||
non in questo file.
|
||||
|
||||
Questo documento descrive la struttura del database Supabase e il ruolo di ogni tabella.
|
||||
## Anagrafica e utenti
|
||||
|
||||
---
|
||||
| Tabella | Scopo | Note |
|
||||
|---|---|---|
|
||||
| `giocatori_squadra` | Anagrafica operativa della squadra, con ID testuali (`g1`…`gN`), dati gestiti dagli admin (nome, cognome, numero, ruolo) e collegamento all'account (`auth_user_id`). | Introdotta dalla migration `m1_giocatori_squadra`, già popolata (17 giocatori) ma **non ancora letta dal codice**: la rosa arriva tuttora da `src/lib/crapp-data.ts`, che resta il fallback anche dopo il passaggio. Destinata a diventare la source of truth. Vedi DD-015 e DD-016. |
|
||||
| `giocatori` | Anagrafica giocatori con UUID. | Presente ma **non usata** dal codice attuale: la convergenza è rinviata (DD-012, DD-014). |
|
||||
| `profili_giocatore` | Dati personali, metadati del documento d'identità, certificato medico e path dei file, in relazione 1:1 con `giocatori_squadra`. | Creata dalla migration `m2_profili_giocatore` (DD-016). Letta da `src/lib/profili.ts`; le policy mostrano al giocatore solo il proprio profilo e all'admin tutti. I file non stanno qui: la tabella conserva i path nel bucket. |
|
||||
| `user_roles` | Ruoli applicativi (es. amministratore, giocatore). | Fonte dei permessi di amministrazione, letta da `src/lib/ruoli.ts` (DD-011). Il primo admin va inserito a mano; vedi [PROJECT_STATE.md](../PROJECT_STATE.md). |
|
||||
|
||||
# Utenti
|
||||
`giocatori_squadra` / `giocatori` sono usate da: Squadra, Profili, Presenze, Scout, Badge, Pagelle.
|
||||
|
||||
## giocatori
|
||||
## Storage
|
||||
|
||||
Contiene l'anagrafica dei giocatori.
|
||||
| Bucket | Scopo | Note |
|
||||
|---|---|---|
|
||||
| `profili-giocatore` | Documento d'identità, certificato medico e foto tessera, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato** e destinato a restare tale: contiene documenti e dati sanitari, che non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella, l'admin può scaricare tutto tramite signed URL a scadenza breve. Creato dalla migration `m3_bucket_profili`. |
|
||||
|
||||
Utilizzato da:
|
||||
## Eventi e presenze
|
||||
|
||||
- Squadra
|
||||
- Profili
|
||||
- Presenze
|
||||
- Scout
|
||||
- Badge
|
||||
- Pagelle
|
||||
| Tabella | Scopo | Note |
|
||||
|---|---|---|
|
||||
| `eventi_app` | Eventi gestionali utilizzati dall'app. | Modello in uso dal codice attuale. |
|
||||
| `risposte_presenze` | Risposte dei giocatori agli eventi. | Modello in uso dal codice attuale. |
|
||||
| `eventi` | Calendario generale: allenamenti, partite, eventi della squadra. | Modello "nuovo" con autenticazione e vincoli, non ancora adottato (DD-014). |
|
||||
| `presenze` | Presenze agli eventi. | Come sopra (DD-014). |
|
||||
|
||||
---
|
||||
## Scout
|
||||
|
||||
## user_roles
|
||||
| Tabella | Scopo | Note |
|
||||
|---|---|---|
|
||||
| `scout_sessioni` | Sessioni di Scout Live: una sessione corrisponde a una partita. | **Non ancora usata dal codice**: oggi lo stato della sessione vive in `localStorage` (`src/lib/scout-live.ts`, `scout-store.ts`) e sul database finiscono solo le azioni in `scout_live`. |
|
||||
| `scout_live` | Eventi registrati durante lo Scout Live. | Serve esclusivamente per statistiche di squadra, mai per classifiche individuali (DD-008). |
|
||||
|
||||
Definisce i ruoli applicativi.
|
||||
## Votazioni
|
||||
|
||||
Esempi:
|
||||
| Tabella | Scopo | Note |
|
||||
|---|---|---|
|
||||
| `mvp_voti` | Voti MVP assegnati a fine partita. | |
|
||||
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. |
|
||||
| `badge_social_voti` | Voti social per i badge. | |
|
||||
|
||||
- amministratore
|
||||
- giocatore
|
||||
## Turni e notifiche
|
||||
|
||||
---
|
||||
| Tabella | Scopo | Note |
|
||||
|---|---|---|
|
||||
| `turni_palloni` | Gestione dei turni palloni. | |
|
||||
| `push_subscriptions` | Dispositivi registrati per le notifiche Push. | |
|
||||
| `promemoria_push` | Storico dei promemoria inviati. | |
|
||||
|
||||
# Eventi
|
||||
## Funzioni speciali
|
||||
|
||||
## eventi
|
||||
| Tabella | Scopo | Note |
|
||||
|---|---|---|
|
||||
| `cacche_partita` | Sondaggio prepartita. | Usato per statistiche e badge segreti. |
|
||||
|
||||
Calendario generale.
|
||||
## Badge
|
||||
|
||||
Comprende:
|
||||
|
||||
- allenamenti
|
||||
- partite
|
||||
- eventi della squadra
|
||||
|
||||
---
|
||||
|
||||
## eventi_app
|
||||
|
||||
Eventi gestionali utilizzati dall'app.
|
||||
|
||||
---
|
||||
|
||||
# Presenze
|
||||
|
||||
## presenze
|
||||
|
||||
Gestisce le presenze agli eventi.
|
||||
|
||||
---
|
||||
|
||||
## risposte_presenze
|
||||
|
||||
Memorizza le risposte dei giocatori.
|
||||
|
||||
---
|
||||
|
||||
# Scout
|
||||
|
||||
## scout_sessioni
|
||||
|
||||
Sessioni di Scout Live.
|
||||
|
||||
Una sessione corrisponde ad una partita.
|
||||
|
||||
---
|
||||
|
||||
## scout_live
|
||||
|
||||
Eventi registrati durante lo Scout Live.
|
||||
|
||||
Serve esclusivamente per statistiche di squadra.
|
||||
|
||||
---
|
||||
|
||||
# Votazioni
|
||||
|
||||
## mvp_voti
|
||||
|
||||
Voti MVP assegnati a fine partita.
|
||||
|
||||
---
|
||||
|
||||
## pagelle_voti
|
||||
|
||||
Voti anonimi assegnati ai giocatori.
|
||||
|
||||
Utilizzati per il voto medio.
|
||||
|
||||
---
|
||||
|
||||
## badge_social_voti
|
||||
|
||||
Voti social per i badge.
|
||||
|
||||
---
|
||||
|
||||
# Badge
|
||||
|
||||
Attualmente i badge vengono calcolati dall'applicazione.
|
||||
|
||||
Non esiste una tabella dedicata.
|
||||
|
||||
---
|
||||
|
||||
# Turni
|
||||
|
||||
## turni_palloni
|
||||
|
||||
Gestione dei turni palloni.
|
||||
|
||||
---
|
||||
|
||||
# Notifiche
|
||||
|
||||
## push_subscriptions
|
||||
|
||||
Dispositivi registrati per le notifiche Push.
|
||||
|
||||
---
|
||||
|
||||
## promemoria_push
|
||||
|
||||
Storico dei promemoria inviati.
|
||||
|
||||
---
|
||||
|
||||
# Funzioni speciali
|
||||
|
||||
## cacche_partita
|
||||
|
||||
Sondaggio prepartita.
|
||||
|
||||
Utilizzato per statistiche e badge segreti.
|
||||
|
||||
---
|
||||
|
||||
# Moduli futuri
|
||||
|
||||
Da implementare
|
||||
|
||||
- Certificati medici
|
||||
- Tesseramenti CSI
|
||||
- Database allenamenti
|
||||
- AI Allenamenti
|
||||
- Integrazione CSI
|
||||
Non esiste una tabella dedicata: i badge vengono **calcolati a runtime** dall'applicazione a
|
||||
partire dai dati esistenti (DD-007).
|
||||
|
||||
+73
-54
@@ -12,6 +12,37 @@ Serve a rispondere a domande del tipo:
|
||||
|
||||
---
|
||||
|
||||
## Indice
|
||||
|
||||
**Accettate**
|
||||
|
||||
| ID | Titolo |
|
||||
|---|---|
|
||||
| [DD-001](#dd-001--crapp-deve-restare-indipendente-da-lovable) | Indipendenza da Lovable |
|
||||
| [DD-002](#dd-002--sviluppo-document-first) | Sviluppo document-first |
|
||||
| [DD-003](#dd-003--due-branch-main-stabile-develop-per-il-lavoro) | Branch main / develop |
|
||||
| [DD-004](#dd-004--ogni-versione-aggiunge-non-riscrive) | Ogni versione aggiunge, non riscrive |
|
||||
| [DD-005](#dd-005--mobile-first-pochi-click-pochi-schermi) | Mobile-first |
|
||||
| [DD-006](#dd-006--intelligenza-artificiale-solo-se-porta-beneficio-reale) | AI solo se utile |
|
||||
| [DD-007](#dd-007--badge-calcolati-dallapp-non-salvati-nel-database) | Badge calcolati, non in DB |
|
||||
| [DD-008](#dd-008--gamification-equa-tra-ruoli) | Gamification equa tra ruoli |
|
||||
| [DD-009](#dd-009--tesseramento-csi-manuale-in-v11-integrazione-api-in-v20) | CSI manuale v1.1, API v2.0 |
|
||||
| [DD-010](#dd-010--profilo-giocatore-niente-storico-certificati-in-v1) | Niente storico certificati v1 |
|
||||
| [DD-011](#dd-011--autenticazione-reale-prima-del-profilo-amministrativo-completo) | Auth reale prima del profilo |
|
||||
| [DD-012](#dd-012--non-migrare-gli-id-giocatore-in-v11) | Non migrare ID in v1.1 |
|
||||
| [DD-013](#dd-013--portabilità-lapp-non-deve-dipendere-da-servizi-esclusivi) | Portabilità dello stack |
|
||||
| [DD-016](#dd-016--schema-dati-profilo-giocatore-v11-f0) | Schema dati Profilo Giocatore v1.1 |
|
||||
| [DD-017](#dd-017--lamministratore-può-compilare-i-dati-al-posto-del-giocatore) | L'admin scrive al posto del giocatore |
|
||||
|
||||
**In valutazione**
|
||||
|
||||
| ID | Titolo |
|
||||
|---|---|
|
||||
| [DD-014](#dd-014--convergenza-schema-database-eventi-e-presenze) | Convergenza schema DB |
|
||||
| [DD-015](#dd-015--rosa-anagrafica-da-codice-hardcoded-a-database) | Rosa da hardcoded a DB |
|
||||
|
||||
---
|
||||
|
||||
## Come usare questo registro
|
||||
|
||||
Ogni decisione segue lo stesso schema:
|
||||
@@ -39,6 +70,11 @@ Ogni decisione segue lo stesso schema:
|
||||
- scelte estetiche minori;
|
||||
- bugfix o correzioni puntuali.
|
||||
|
||||
**Come registrare una nuova decisione**
|
||||
|
||||
Copiare [`_template-dd.md`](_template-dd.md) in fondo al documento, assegnare il primo ID
|
||||
libero e aggiungerlo all'indice.
|
||||
|
||||
---
|
||||
|
||||
## Decisioni accettate
|
||||
@@ -401,6 +437,43 @@ Regole vincolanti:
|
||||
|
||||
---
|
||||
|
||||
### DD-017 — L'amministratore può compilare i dati al posto del giocatore
|
||||
|
||||
**Data:** agosto 2026
|
||||
**Stato:** Accettata
|
||||
|
||||
**Contesto**
|
||||
Il modulo Profilo Giocatore era costruito su un confine netto: ognuno scrive solo la propria riga, l'amministratore legge e scarica. Nella pratica quel confine blocca il lavoro che il modulo doveva togliere: se metà squadra non compila i propri dati, l'export per il tesseramento CSI resta incompleto e l'admin torna a chiedere le informazioni in chat — esattamente ciò che CrAPP deve eliminare. Inoltre le docs assegnavano già agli admin la gestione dei dati squadra (nome, cognome, numero, ruolo) e il reset del collegamento all'account (DD-016 regola 2), senza che esistesse una schermata per farlo.
|
||||
|
||||
**Decisione**
|
||||
Dalla dashboard amministratore, un admin può:
|
||||
|
||||
1. modificare i **dati squadra** di qualsiasi giocatore (nome, cognome, numero, ruolo);
|
||||
2. compilare e correggere i **dati personali e del documento** di qualsiasi giocatore;
|
||||
3. **scollegare** un account da un profilo, liberando lo slot.
|
||||
|
||||
Restano fuori, e non cambiano:
|
||||
|
||||
- i **file** (documento, certificato, foto): l'admin li scarica ma non li carica né li sostituisce. Un documento d'identità lo produce il suo titolare, e la catena di responsabilità deve restare leggibile;
|
||||
- il **giocatore**, che continua a non poter toccare i propri dati squadra.
|
||||
|
||||
**Alternative scartate**
|
||||
- Lasciare tutto al giocatore → l'export CSI resta incompleto e il lavoro amministrativo torna in chat, contro la missione del progetto.
|
||||
- Dare all'admin anche l'upload dei file → confonde chi ha fornito un documento, su dati sanitari e d'identità dove serve il contrario.
|
||||
- Un ruolo intermedio (segreteria) per i soli dati personali → un ruolo in più per una squadra sola, con gli stessi tre amministratori di adesso.
|
||||
|
||||
**Conseguenze**
|
||||
- Il modello dei permessi non è più "ognuno i suoi": è "ognuno i suoi, più l'admin su tutti, tranne i file". Le policy RLS di M1 e M2 lo consentivano già, quindi non servono migration.
|
||||
- Un admin può correggere un errore di battitura in un numero di documento senza inseguire il giocatore.
|
||||
- Un admin vede e scrive dati personali altrui: è un potere reale, dato a tre persone su diciassette. Va assegnato con la stessa cura di prima (una riga in `user_roles`, nessuna auto-promozione).
|
||||
- Il completamento del profilo smette di essere un indicatore di *chi ha risposto* e diventa un indicatore di *quali dati mancano*, chiunque li abbia inseriti.
|
||||
|
||||
**Riesame**
|
||||
- Se la squadra cresce al punto da rendere sensato un ruolo di sola segreteria.
|
||||
- Se serve tracciare *chi* ha modificato un dato: oggi non c'è audit, e con la scrittura condivisa la domanda prima o poi arriva.
|
||||
|
||||
---
|
||||
|
||||
## Decisioni in valutazione
|
||||
|
||||
---
|
||||
@@ -442,57 +515,3 @@ Il profilo v1.1 può agganciarsi agli ID attuali; la migrazione rosa può essere
|
||||
In parallelo o subito dopo il rollout auth.
|
||||
|
||||
---
|
||||
|
||||
## Template per nuove decisioni
|
||||
|
||||
Copiare questo blocco in fondo al documento quando serve registrare una nuova scelta.
|
||||
|
||||
---
|
||||
|
||||
### DD-XXX — [Titolo breve della decisione]
|
||||
|
||||
**Data:**
|
||||
**Stato:** Accettata · In valutazione · Sostituita · Obsoleta
|
||||
|
||||
**Contesto**
|
||||
[Quale problema stavamo risolvendo?]
|
||||
|
||||
**Decisione**
|
||||
[Cosa abbiamo scelto?]
|
||||
|
||||
**Alternative scartate**
|
||||
- [Alternativa 1] → [perché no]
|
||||
- [Alternativa 2] → [perché no]
|
||||
|
||||
**Conseguenze**
|
||||
[Cosa cambia per utenti, admin e team di sviluppo]
|
||||
|
||||
**Riesame**
|
||||
[Quando o in quali condizioni rivedere la decisione]
|
||||
|
||||
---
|
||||
|
||||
## Indice rapido
|
||||
|
||||
| ID | Titolo | Stato |
|
||||
|---|---|---|
|
||||
| DD-001 | Indipendenza da Lovable | Accettata |
|
||||
| DD-002 | Sviluppo document-first | Accettata |
|
||||
| DD-003 | Branch main / develop | Accettata |
|
||||
| DD-004 | Ogni versione aggiunge, non riscrive | Accettata |
|
||||
| DD-005 | Mobile-first | Accettata |
|
||||
| DD-006 | AI solo se utile | Accettata |
|
||||
| DD-007 | Badge calcolati, non in DB | Accettata |
|
||||
| DD-008 | Gamification equa tra ruoli | Accettata |
|
||||
| DD-009 | CSI manuale v1.1, API v2.0 | Accettata |
|
||||
| DD-010 | Niente storico certificati v1 | Accettata |
|
||||
| DD-011 | Auth reale prima del profilo | Accettata |
|
||||
| DD-012 | Non migrare ID in v1.1 | Accettata |
|
||||
| DD-013 | Portabilità dello stack | Accettata |
|
||||
| DD-016 | Schema dati Profilo Giocatore v1.1 | Accettata |
|
||||
| DD-014 | Convergenza schema DB | In valutazione |
|
||||
| DD-015 | Rosa da hardcoded a DB | In valutazione |
|
||||
|
||||
---
|
||||
|
||||
*Ultimo aggiornamento: 28 agosto 2026*
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Efficienza cloud
|
||||
|
||||
Regola di progetto: CrAPP gira su un piano cloud minimo (Supabase + Vercel) per ~17 utenti.
|
||||
Query, traffico e invocazioni vanno tenuti al minimo **per costruzione**, non ottimizzati dopo.
|
||||
|
||||
## Regole da rispettare
|
||||
|
||||
1. **Niente polling**: mai `refetchInterval` verso il database. Per sincronizzare più schede
|
||||
aperte si usano `BroadcastChannel` o gli eventi di `storage`.
|
||||
2. **Cache lunga e passiva**: i default del `QueryClient` stanno in `src/router.tsx`
|
||||
(`staleTime` 5 min, `gcTime` 30 min, `refetchOnWindowFocus/Mount/Reconnect` disattivati,
|
||||
`retry: 1`). Non alzare la frequenza di refetch modulo per modulo.
|
||||
3. **Dopo una mutazione si aggiorna la cache con `setQueryData`**, non con
|
||||
`invalidateQueries`: invalidare costa una rilettura. Unica eccezione oggi:
|
||||
`src/lib/scout-live.ts`.
|
||||
4. **Scout Live**: scrive solo chi sta segnando; gli altri leggono dati già salvati.
|
||||
5. **Write once, read many**: statistiche, badge e classifiche si calcolano una volta e non
|
||||
si ricalcolano a ogni apertura di pagina. I badge restano calcolati a runtime dai dati già
|
||||
in cache, senza query aggiuntive (DD-007): `src/lib/rosa.ts` aggrega ciò che è già stato
|
||||
letto.
|
||||
6. **Push solo per eventi importanti**: convocazioni, promemoria allenamento/partita, turno
|
||||
palloni, esito finale.
|
||||
7. **Niente funzionalità pesanti**: foto, video, chat.
|
||||
8. **Indici** sui campi usati per filtri e relazioni in ogni nuova migration.
|
||||
|
||||
## Obiettivi non ancora attuati
|
||||
|
||||
Questi punti sono stati definiti come direzione, ma **non sono implementati**: non descrivono
|
||||
il comportamento attuale.
|
||||
|
||||
- **Dati CSI**: sincronizzazione periodica server-side salvata su una tabella locale, con
|
||||
l'app che legge solo dal database interno. Oggi la lettura è live dal portale a ogni
|
||||
richiesta, tramite `/api/public/csi` (vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)).
|
||||
- **Aggregati persistiti**: uno schema con `statistiche_aggregate` e `classifica_csi` è stato
|
||||
ipotizzato ma non esiste; nessuna di quelle tabelle è in `supabase/migrations/`. Va valutato
|
||||
con una decisione dedicata, perché tocca DD-007 (badge e statistiche calcolati a runtime).
|
||||
@@ -0,0 +1,44 @@
|
||||
# Documentazione CrAPP
|
||||
|
||||
Indice della documentazione ufficiale del progetto. Ogni file risponde a una domanda
|
||||
precisa: se l'informazione che cerchi non è nel file indicato, probabilmente non esiste
|
||||
ancora e va **prima documentata** (vedi [DD-002](DESIGN_DECISIONS.md#dd-002--sviluppo-document-first)).
|
||||
|
||||
## Dove sta cosa
|
||||
|
||||
| Documento | Risponde a |
|
||||
|---|---|
|
||||
| [VISION.md](VISION.md) | Perché esiste CrAPP, quali principi deve rispettare una funzionalità |
|
||||
| [ROADMAP.md](ROADMAP.md) | Cosa è fatto e cosa è previsto, versione per versione |
|
||||
| [ARCHITECTURE.md](ARCHITECTURE.md) | Com'è fatta l'app: stack, struttura del codice, flusso di sviluppo |
|
||||
| [DATABASE.md](DATABASE.md) | Quali tabelle esistono, a cosa servono, chi le usa |
|
||||
| [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) | Perché abbiamo scelto così, cosa abbiamo escluso e quando riaprire la scelta |
|
||||
| [PORTABILITA.md](PORTABILITA.md) | Cosa lega l'app a un fornitore e cosa no, come spostarla su server proprio |
|
||||
| [EFFICIENZA_CLOUD.md](EFFICIENZA_CLOUD.md) | Come tenere basso il consumo cloud: cache, query, push |
|
||||
| [TODO.md](TODO.md) | A cosa si sta lavorando adesso |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | Cosa è cambiato e quando |
|
||||
| [modules/](modules/) | Specifica funzionale di ogni modulo, una per file |
|
||||
|
||||
Le regole vincolanti per gli assistenti AI stanno in [AGENTS.md](../AGENTS.md);
|
||||
lo stato corrente del lavoro in [PROJECT_STATE.md](../PROJECT_STATE.md).
|
||||
|
||||
## Ordine di lettura
|
||||
|
||||
Prima di modificare il codice, nell'ordine: questo indice → `VISION.md` → `ROADMAP.md` →
|
||||
`ARCHITECTURE.md` → `DATABASE.md` → `DESIGN_DECISIONS.md` → `TODO.md` → il documento del
|
||||
modulo interessato in `modules/`.
|
||||
|
||||
## Regole di manutenzione
|
||||
|
||||
Ogni informazione ha **una sola casa**, per evitare che le copie divergano:
|
||||
|
||||
- l'elenco delle funzionalità (fatte e previste) sta solo in `ROADMAP.md`;
|
||||
- `CHANGELOG.md` registra *quando* qualcosa è stato rilasciato, non ripete l'elenco;
|
||||
- `TODO.md` contiene solo il lavoro in corso o imminente, e rimanda alla roadmap;
|
||||
- lo schema del database sta solo in `DATABASE.md`, allineato alle migration in
|
||||
`supabase/migrations/`: una tabella nuova si documenta nella stessa modifica che la crea;
|
||||
- le motivazioni stanno solo in `DESIGN_DECISIONS.md`, in voci `DD-XXX`; per aggiungerne
|
||||
una si copia [\_template-dd.md](_template-dd.md).
|
||||
|
||||
Convenzioni di scrittura: un solo titolo `#` per file (le sezioni interne partono da `##`),
|
||||
niente `---` come riempitivo tra i paragrafi, tabelle al posto degli elenchi ripetitivi.
|
||||
+21
-14
@@ -1,25 +1,33 @@
|
||||
# Roadmap
|
||||
|
||||
## Versione 1.0
|
||||
Elenco unico delle funzionalità di CrAPP, fatte e previste. È la fonte di riferimento per
|
||||
il *cosa*: `CHANGELOG.md` registra *quando* una voce è stata rilasciata, `TODO.md` cosa si
|
||||
sta facendo adesso.
|
||||
|
||||
## Versione 1.0 — rilasciata
|
||||
|
||||
- [x] Gestione squadra
|
||||
- [x] Calendario
|
||||
- [x] Presenze
|
||||
- [x] Serie di presenze
|
||||
- [x] Scout Live
|
||||
- [x] Badge
|
||||
- [x] Badge social
|
||||
- [x] Pagelle
|
||||
- [x] Obiettivi di squadra
|
||||
- [x] Notifiche Push
|
||||
|
||||
---
|
||||
- [x] Notifiche Push (promemoria intelligenti)
|
||||
|
||||
## Versione 1.1
|
||||
|
||||
- [ ] Certificati medici
|
||||
- [ ] Gestione tesseramenti CSI
|
||||
- [ ] Dashboard amministratore
|
||||
- [ ] Download CSV dati
|
||||
Le voci spuntate sono implementate su `develop` e non ancora attive in produzione: lo stato
|
||||
di attivazione sta in [PROJECT_STATE.md](../PROJECT_STATE.md).
|
||||
|
||||
---
|
||||
- [x] Certificati medici — caricamento, scadenza, stato e download; lo storico dei
|
||||
certificati resta un'estensione futura
|
||||
- [ ] Gestione tesseramenti CSI — raccolta dati ed export CSV pronti, manca il tracciamento
|
||||
di chi è già tesserato (numero e data di tessera)
|
||||
- [x] Dashboard amministratore
|
||||
- [x] Download CSV dati
|
||||
|
||||
## Versione 1.2
|
||||
|
||||
@@ -27,16 +35,13 @@
|
||||
- [ ] AI Allenamenti
|
||||
- [ ] Archivio allenamenti
|
||||
|
||||
---
|
||||
|
||||
## Versione 2.0
|
||||
|
||||
- [x] Collegamento CSI (stagione 2025/26)
|
||||
- [x] Classifica automatica
|
||||
- [x] Risultati campionato
|
||||
- [ ] Calendario ufficiale
|
||||
|
||||
---
|
||||
- [ ] Calendario ufficiale — i dati delle gare future arrivano già dal feed CSI, la pagina
|
||||
Campionato usa solo quelle giocate
|
||||
|
||||
## Idee future
|
||||
|
||||
@@ -44,3 +49,5 @@
|
||||
- [ ] Calendario Google
|
||||
- [ ] Backup automatici
|
||||
- [ ] Analisi statistiche avanzate
|
||||
- [ ] Widget meteo
|
||||
- [ ] Analisi Scout con AI
|
||||
|
||||
+33
-18
@@ -1,30 +1,45 @@
|
||||
# TODO
|
||||
|
||||
Solo il lavoro in corso o imminente. L'elenco completo delle funzionalità previste sta in
|
||||
[ROADMAP.md](ROADMAP.md); le idee non ancora valutate pure.
|
||||
|
||||
## In corso
|
||||
|
||||
- Documentazione tecnica del progetto.
|
||||
|
||||
---
|
||||
- Autenticazione Google e dashboard amministratore: implementate su `develop`, ma in dev il
|
||||
login risponde ancora `provider is not enabled` perché il provider Google non è stato
|
||||
acceso in Supabase. Restano da
|
||||
fare, in quest'ordine: configurazione del provider Google in Supabase, applicazione delle
|
||||
migration M2/M3, inserimento del primo admin in `user_roles`, collegamento dei 17 account,
|
||||
e solo alla fine `VITE_AUTH_OBBLIGATORIA=true` + rimozione delle policy `anon`.
|
||||
Stato di dettaglio in [PROJECT_STATE.md](../PROJECT_STATE.md).
|
||||
|
||||
## Prossimo
|
||||
|
||||
- Certificati medici.
|
||||
- Gestione tesseramenti CSI.
|
||||
- Gestione tesseramenti CSI (roadmap v1.1): la raccolta dati e l'export CSV ci sono, manca
|
||||
il tracciamento di chi è già tesserato (numero e data di tessera).
|
||||
|
||||
---
|
||||
Il profilo giocatore lato giocatore e i certificati medici sono fatti: `ProfiloAmministrativo`
|
||||
in `src/routes/profilo.tsx` carica documento, certificato e foto con le date di scadenza, e
|
||||
la dashboard amministratore legge quei dati.
|
||||
|
||||
## Debito di documentazione
|
||||
|
||||
Moduli v1.0 in produzione senza scheda in [modules/](modules/) — DD-002 ne prevede la
|
||||
retro-documentazione: Presenze, Scout Live, Badge, Pagelle, MVP, Palloni, Obiettivi di
|
||||
squadra, Notifiche, Serie di presenze, Infortuni (`src/lib/infortuni.ts`, usato ma non
|
||||
documentato in nessun punto).
|
||||
|
||||
Non documentate nemmeno le route API pubbliche in `src/routes/api/public/` (`csi`,
|
||||
`promemoria-palloni`, `push-config`, `push-messaggio`, `push-subscribe`,
|
||||
`sollecita-presenze`).
|
||||
|
||||
## Manutenzione ricorrente
|
||||
|
||||
- Collegamento CSI: aggiornare `project_id` e `team_id` a inizio stagione 2026/27
|
||||
(vedi [modules/collegamento-csi.md](modules/collegamento-csi.md)).
|
||||
|
||||
## Backlog
|
||||
|
||||
- AI Allenamenti.
|
||||
- Dashboard amministratore.
|
||||
- Collegamento CSI: aggiornare `project_id` e `team_id` per la stagione 2026/27
|
||||
(base implementata, vedi `docs/modules/collegamento-csi.md`).
|
||||
|
||||
---
|
||||
|
||||
## Idee
|
||||
|
||||
- Gestione quote.
|
||||
- Widget meteo.
|
||||
- Analisi Scout con AI.
|
||||
- Backup automatici.
|
||||
- AI Allenamenti (roadmap v1.2).
|
||||
- Backup automatici (roadmap, idee future).
|
||||
|
||||
@@ -6,8 +6,6 @@ CrAPP nasce con un obiettivo semplice:
|
||||
|
||||
Digitalizzare completamente la gestione di una squadra di pallavolo, eliminando il maggior numero possibile di attività manuali e aumentando il coinvolgimento dei giocatori attraverso strumenti moderni e intuitivi.
|
||||
|
||||
---
|
||||
|
||||
## Principi del progetto
|
||||
|
||||
Ogni funzionalità sviluppata deve rispettare almeno uno di questi principi:
|
||||
@@ -18,8 +16,6 @@ Ogni funzionalità sviluppata deve rispettare almeno uno di questi principi:
|
||||
- Automatizzare le attività ripetitive.
|
||||
- Sfruttare l'intelligenza artificiale solo quando porta un reale beneficio.
|
||||
|
||||
---
|
||||
|
||||
## Filosofia
|
||||
|
||||
CrAPP deve essere:
|
||||
@@ -31,8 +27,6 @@ CrAPP deve essere:
|
||||
- Accessibile da smartphone
|
||||
- Utilizzabile anche da persone poco esperte
|
||||
|
||||
---
|
||||
|
||||
## Obiettivo finale
|
||||
|
||||
Diventare il punto di riferimento per la gestione quotidiana della squadra, sostituendo chat, fogli Excel e strumenti separati con un'unica applicazione.
|
||||
@@ -0,0 +1,20 @@
|
||||
### DD-XXX — [Titolo breve della decisione]
|
||||
|
||||
**Data:**
|
||||
**Stato:** Accettata · In valutazione · Sostituita · Obsoleta
|
||||
|
||||
**Contesto**
|
||||
[Quale problema stavamo risolvendo?]
|
||||
|
||||
**Decisione**
|
||||
[Cosa abbiamo scelto?]
|
||||
|
||||
**Alternative scartate**
|
||||
- [Alternativa 1] → [perché no]
|
||||
- [Alternativa 2] → [perché no]
|
||||
|
||||
**Conseguenze**
|
||||
[Cosa cambia per utenti, admin e team di sviluppo]
|
||||
|
||||
**Riesame**
|
||||
[Quando o in quali condizioni rivedere la decisione]
|
||||
@@ -1,4 +1,4 @@
|
||||
# Profilo Giocatore
|
||||
# Modulo — Profilo Giocatore
|
||||
|
||||
## Obiettivo
|
||||
|
||||
@@ -6,11 +6,9 @@ Il modulo "Profilo Giocatore" raccoglie tutte le informazioni personali, amminis
|
||||
|
||||
L'obiettivo è centralizzare in un'unica schermata tutti i dati necessari sia al giocatore sia agli amministratori, eliminando la gestione tramite chat, documenti cartacei e fogli Excel.
|
||||
|
||||
---
|
||||
## Utenti
|
||||
|
||||
# Utenti
|
||||
|
||||
## Giocatore
|
||||
### Giocatore
|
||||
|
||||
Può:
|
||||
|
||||
@@ -20,9 +18,7 @@ Può:
|
||||
- aggiornare i documenti
|
||||
- caricare le immagini richieste
|
||||
|
||||
---
|
||||
|
||||
## Amministratore
|
||||
### Amministratore
|
||||
|
||||
Può:
|
||||
|
||||
@@ -30,36 +26,32 @@ Può:
|
||||
- scaricare documenti e certificati
|
||||
- esportare i dati necessari al tesseramento CSI
|
||||
- verificare lo stato di completamento dei profili
|
||||
- modificare i dati squadra di qualsiasi giocatore (nome, cognome, numero, ruolo)
|
||||
- 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
|
||||
|
||||
---
|
||||
Non può caricare o sostituire i file altrui: documento, certificato e foto restano
|
||||
responsabilità del giocatore che li fornisce.
|
||||
|
||||
# Flusso utente
|
||||
## Flusso utente
|
||||
|
||||
## Primo accesso
|
||||
### Primo accesso
|
||||
|
||||
1. Login tramite Google oppure Email.
|
||||
1. Login tramite Google oppure Email. *Implementato con il solo Google: la squadra ha tutti
|
||||
un account Google, e un secondo metodo è additivo (un bottone in più sulla stessa
|
||||
schermata) il giorno che serve.*
|
||||
2. Selezione del proprio giocatore.
|
||||
3. Accesso alla Home.
|
||||
|
||||
Se il profilo non è completo compare automaticamente un widget di completamento.
|
||||
|
||||
---
|
||||
|
||||
# Home
|
||||
## Home
|
||||
|
||||
Il giocatore visualizza un widget dedicato.
|
||||
|
||||
## Completa il tuo profilo
|
||||
### Completa il tuo profilo
|
||||
|
||||
Viene mostrata una barra di avanzamento.
|
||||
|
||||
Esempio
|
||||
|
||||
Profilo completato
|
||||
|
||||
85%
|
||||
|
||||
La barra è composta dalle seguenti sezioni.
|
||||
Viene mostrata una barra di avanzamento (esempio: *Profilo completato — 85%*), composta dalle seguenti sezioni.
|
||||
|
||||
- Dati personali
|
||||
- Documento di identità
|
||||
@@ -68,32 +60,20 @@ La barra è composta dalle seguenti sezioni.
|
||||
|
||||
Quando tutte le sezioni sono complete il widget scompare automaticamente.
|
||||
|
||||
---
|
||||
|
||||
# Profilo
|
||||
## Profilo
|
||||
|
||||
Il profilo viene suddiviso in cinque aree.
|
||||
|
||||
## Dati Giocatore
|
||||
### Dati Giocatore
|
||||
|
||||
Contiene.
|
||||
|
||||
### Dati squadra
|
||||
|
||||
Solo lettura.
|
||||
**Dati squadra** — solo lettura, gestiti esclusivamente dagli amministratori.
|
||||
|
||||
- Nome
|
||||
- Cognome
|
||||
- Numero di maglia
|
||||
- Ruolo
|
||||
|
||||
Questi dati sono gestiti esclusivamente dagli amministratori.
|
||||
|
||||
---
|
||||
|
||||
### Dati personali
|
||||
|
||||
Modificabili dal giocatore.
|
||||
**Dati personali** — modificabili dal giocatore.
|
||||
|
||||
- Data di nascita
|
||||
- Luogo di nascita
|
||||
@@ -101,9 +81,7 @@ Modificabili dal giocatore.
|
||||
- Telefono
|
||||
- Email
|
||||
|
||||
---
|
||||
|
||||
## Documento di identità
|
||||
### Documento di identità
|
||||
|
||||
Campi.
|
||||
|
||||
@@ -118,9 +96,7 @@ Upload.
|
||||
- Foto fronte
|
||||
- Foto retro
|
||||
|
||||
---
|
||||
|
||||
## Certificato medico
|
||||
### Certificato medico
|
||||
|
||||
Campi.
|
||||
|
||||
@@ -134,21 +110,15 @@ Il giocatore può aggiornare liberamente sia la data sia il file.
|
||||
|
||||
Lo storico non viene mantenuto nella prima versione.
|
||||
|
||||
---
|
||||
|
||||
## Foto tessera
|
||||
### Foto tessera
|
||||
|
||||
Upload di una fotografia formato tessera.
|
||||
|
||||
Utilizzata dagli amministratori per il tesseramento CSI.
|
||||
|
||||
---
|
||||
### Statistiche
|
||||
|
||||
## Statistiche
|
||||
|
||||
Sezione già presente.
|
||||
|
||||
Contiene.
|
||||
Sezione già presente. Contiene.
|
||||
|
||||
- Presenze
|
||||
- Voto medio
|
||||
@@ -156,17 +126,13 @@ Contiene.
|
||||
- Serie
|
||||
- Altre statistiche disponibili
|
||||
|
||||
---
|
||||
|
||||
## Badge
|
||||
### Badge
|
||||
|
||||
Sezione già presente.
|
||||
|
||||
Contiene tutti i badge ottenuti e quelli ancora da sbloccare.
|
||||
|
||||
---
|
||||
|
||||
## Impostazioni
|
||||
### Impostazioni
|
||||
|
||||
Contiene.
|
||||
|
||||
@@ -174,11 +140,10 @@ Contiene.
|
||||
- Preferenze notifiche
|
||||
- Impostazioni applicazione
|
||||
|
||||
---
|
||||
## Dashboard amministratore
|
||||
|
||||
# Dashboard amministratore
|
||||
|
||||
Gli amministratori dispongono di una schermata dedicata.
|
||||
Gli amministratori dispongono di una schermata dedicata (`/admin`, raggiungibile da
|
||||
Profilo → Impostazioni).
|
||||
|
||||
Per ogni giocatore vengono mostrati.
|
||||
|
||||
@@ -189,14 +154,14 @@ Per ogni giocatore vengono mostrati.
|
||||
|
||||
Azioni disponibili.
|
||||
|
||||
- Visualizza profilo
|
||||
- Visualizza profilo (la scheda si apre in linea nell'elenco: nessuna schermata separata)
|
||||
- Scarica certificato
|
||||
- Scarica documento
|
||||
- Scarica foto tessera
|
||||
- Modifica dati squadra e dati personali del giocatore (DD-017)
|
||||
- Scollega account, per liberare uno slot assegnato per errore
|
||||
|
||||
---
|
||||
|
||||
# Esportazione CSI
|
||||
## Esportazione CSI
|
||||
|
||||
Gli amministratori possono esportare un file CSV contenente esclusivamente i dati richiesti per il tesseramento.
|
||||
|
||||
@@ -215,51 +180,28 @@ Campi esportati.
|
||||
- Data emissione
|
||||
- Data scadenza
|
||||
|
||||
---
|
||||
|
||||
# Completamento profilo
|
||||
## Completamento profilo
|
||||
|
||||
Ogni sezione contribuisce alla percentuale di completamento.
|
||||
|
||||
## Pesi
|
||||
|
||||
Dati personali
|
||||
|
||||
30%
|
||||
|
||||
Documento di identità
|
||||
|
||||
30%
|
||||
|
||||
Certificato medico
|
||||
|
||||
30%
|
||||
|
||||
Foto tessera
|
||||
|
||||
10%
|
||||
| Sezione | Peso |
|
||||
|---|---|
|
||||
| Dati personali | 30% |
|
||||
| Documento di identità | 30% |
|
||||
| Certificato medico | 30% |
|
||||
| Foto tessera | 10% |
|
||||
|
||||
Quando tutte le sezioni risultano complete il profilo raggiunge il 100%.
|
||||
|
||||
---
|
||||
## Permessi
|
||||
|
||||
# Permessi
|
||||
**Giocatore** — può modificare esclusivamente il proprio profilo.
|
||||
|
||||
## Giocatore
|
||||
**Amministratore** — può visualizzare tutti i profili, scaricare tutti i documenti, esportare i
|
||||
dati, modificare dati squadra e dati personali di chiunque e scollegare un account (DD-017).
|
||||
Non carica file al posto di altri.
|
||||
|
||||
Può modificare esclusivamente il proprio profilo.
|
||||
|
||||
## Amministratore
|
||||
|
||||
Può visualizzare tutti i profili.
|
||||
|
||||
Può scaricare tutti i documenti.
|
||||
|
||||
Può esportare i dati.
|
||||
|
||||
---
|
||||
|
||||
# Versione 1
|
||||
## Versione 1
|
||||
|
||||
- Profilo giocatore
|
||||
- Completamento profilo
|
||||
@@ -270,9 +212,7 @@ Può esportare i dati.
|
||||
- Dashboard amministratore
|
||||
- Esportazione CSV CSI
|
||||
|
||||
---
|
||||
|
||||
# Versioni future
|
||||
## Versioni future
|
||||
|
||||
- Storico certificati medici
|
||||
- Gestione documenti aggiuntivi
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: Efficienza Cloud
|
||||
description: Regole per minimizzare query, traffico e invocazioni Cloud (piano 20 crediti/mese, 17 utenti)
|
||||
type: feature
|
||||
---
|
||||
- Nessun polling (`refetchInterval`) verso il database; sincronizzazione locale via BroadcastChannel/storage dove possibile.
|
||||
- QueryClient globale: staleTime 5 min, gcTime 30 min, refetchOnWindowFocus/Mount/Reconnect disattivati, retry 1.
|
||||
- Dopo una mutazione aggiornare la cache con `setQueryData`, non `invalidateQueries` (evita riletture).
|
||||
- Scout live: scrive solo l'utente che segna; gli altri leggono dati già salvati.
|
||||
- Statistiche, badge e classifiche: "write once, read many" — calcolate e salvate una volta a fine partita, mai ricalcolate a ogni apertura pagina.
|
||||
- Dati CSI: sincronizzazione periodica server-side salvata su tabella locale; l'app legge solo dal database interno.
|
||||
- Push solo per eventi importanti: convocazioni, promemoria allenamento/partita, turno palloni, esito finale.
|
||||
- Niente foto/video/chat o funzionalità pesanti.
|
||||
- Schema target: team_id, eventi, presenze, azioni_scout, statistiche_aggregate, classifica_csi, notifiche, con indici sui campi di filtro/relazione.
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
name: Portabilità su Node.js + PostgreSQL
|
||||
description: Vincolo di architettura — l'app deve girare su un normale server Node.js con PostgreSQL, senza servizi esclusivi Lovable Cloud
|
||||
type: constraint
|
||||
---
|
||||
L'app deve restare completamente portabile: ogni funzionalità deve poter girare su un normale server Node.js con PostgreSQL.
|
||||
|
||||
Regole:
|
||||
- Accesso ai dati solo tramite i moduli in `src/lib/*.ts`; i componenti non parlano mai direttamente col database.
|
||||
- Vietato usare funzionalità proprietarie Lovable/Supabase non self-hostable (edge functions proprietarie, auth Lovable come unico login, storage proprietario). `src/integrations/lovable/*` resta opzionale e non importato.
|
||||
- SQL standard PostgreSQL nelle migrazioni; niente estensioni esclusive del provider.
|
||||
- Configurazione solo via variabili d'ambiente standard; niente valori hardcoded.
|
||||
- Job pianificati sempre richiamabili con un semplice HTTP POST, così funzionano con qualsiasi scheduler.
|
||||
- Web push implementato con Web Crypto (compatibile Node 18+), non con SDK proprietari.
|
||||
|
||||
Dettaglio e guida di migrazione: `docs/PORTABILITA.md`.
|
||||
+4
-2
@@ -1,2 +1,4 @@
|
||||
- [Efficienza Cloud](mem://features/cloud-efficienza) — Regole anti-consumo: niente polling, cache lunga, aggregati precalcolati, sync CSI server-side
|
||||
- [Portabilità](mem://features/portabilita) — L'app deve girare su Node.js + PostgreSQL standard, nessun servizio esclusivo Lovable Cloud
|
||||
Le regole di progetto non vivono più qui: sono in `docs/`.
|
||||
|
||||
- Efficienza cloud → `docs/EFFICIENZA_CLOUD.md`
|
||||
- Portabilità → `docs/PORTABILITA.md`
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Check, Eye, Loader2, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Section } from "@/components/crapp/ui-bits";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import {
|
||||
caricaFile,
|
||||
scaricaFile,
|
||||
useProfili,
|
||||
useSalvaProfilo,
|
||||
type SezioneFile,
|
||||
} from "@/lib/profili";
|
||||
import {
|
||||
completamento,
|
||||
profiloVuoto,
|
||||
sezioniComplete,
|
||||
type Profilo,
|
||||
type Sezione,
|
||||
} from "@/lib/profili-core";
|
||||
|
||||
const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"];
|
||||
|
||||
const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm";
|
||||
|
||||
function Campo({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-1 block">{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">{titolo}</h3>
|
||||
{completa ? <Check className="h-4 w-4 text-success" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CampoFile({
|
||||
label,
|
||||
path,
|
||||
sezione,
|
||||
giocatoreId,
|
||||
onCaricato,
|
||||
}: {
|
||||
label: string;
|
||||
path: string | null;
|
||||
sezione: SezioneFile;
|
||||
giocatoreId: string;
|
||||
onCaricato: (path: string) => Promise<void>;
|
||||
}) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
|
||||
async function scegli(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setInCorso(true);
|
||||
try {
|
||||
const nuovo = await caricaFile(giocatoreId, sezione, file, path);
|
||||
await onCaricato(nuovo);
|
||||
toast.success(`${label} caricato`);
|
||||
} catch (errore) {
|
||||
toast.error(errore instanceof Error ? errore.message : "Caricamento non riuscito");
|
||||
} finally {
|
||||
setInCorso(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 py-2">
|
||||
<span className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-6 w-6 shrink-0 place-items-center rounded-lg",
|
||||
path ? "bg-success text-success-foreground" : "bg-secondary text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{path ? <Check className="h-3.5 w-3.5" /> : <Upload className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
{path ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void scaricaFile(path)}
|
||||
className="premi rounded-xl bg-secondary p-2 text-muted-foreground"
|
||||
aria-label={`Vedi ${label}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => input.current?.click()}
|
||||
disabled={inCorso}
|
||||
className="premi rounded-xl bg-primary px-3 py-2 text-xs font-bold text-primary-foreground disabled:opacity-60"
|
||||
>
|
||||
{inCorso ? <Loader2 className="h-4 w-4 animate-spin" /> : path ? "Sostituisci" : "Carica"}
|
||||
</button>
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,application/pdf"
|
||||
onChange={scegli}
|
||||
className="hidden"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* I campi del profilo, condivisi tra il giocatore e la dashboard amministratore (DD-017).
|
||||
* Gli upload arrivano come slot: l'admin non carica file al posto di altri, quindi da lì
|
||||
* quelle righe semplicemente non compaiono.
|
||||
*/
|
||||
export function CampiProfilo({
|
||||
corrente,
|
||||
aggiorna,
|
||||
sezioni,
|
||||
fileDocumento,
|
||||
fileCertificato,
|
||||
fileFoto,
|
||||
}: {
|
||||
corrente: Profilo;
|
||||
aggiorna: (patch: Partial<Profilo>) => void;
|
||||
sezioni: Record<Sezione, boolean>;
|
||||
fileDocumento?: React.ReactNode;
|
||||
fileCertificato?: React.ReactNode;
|
||||
fileFoto?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Intestazione titolo="Dati personali" completa={sezioni.dati} />
|
||||
<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>
|
||||
<Campo label="Indirizzo di residenza">
|
||||
<input
|
||||
value={corrente.indirizzo ?? ""}
|
||||
maxLength={120}
|
||||
onChange={(e) => aggiorna({ indirizzo: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<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>
|
||||
|
||||
<Intestazione titolo="Documento di identità" completa={sezioni.documento} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Tipo">
|
||||
<select
|
||||
value={corrente.documentoTipo ?? ""}
|
||||
onChange={(e) => aggiorna({ documentoTipo: e.target.value })}
|
||||
className={classiInput}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{TIPI_DOCUMENTO.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Campo>
|
||||
<Campo label="Numero">
|
||||
<input
|
||||
value={corrente.documentoNumero ?? ""}
|
||||
maxLength={40}
|
||||
onChange={(e) => aggiorna({ documentoNumero: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<Campo label="Rilasciato da">
|
||||
<input
|
||||
value={corrente.documentoRilasciatoDa ?? ""}
|
||||
maxLength={80}
|
||||
onChange={(e) => aggiorna({ documentoRilasciatoDa: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Data emissione">
|
||||
<input
|
||||
type="date"
|
||||
value={corrente.documentoEmissione ?? ""}
|
||||
onChange={(e) => aggiorna({ documentoEmissione: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Data scadenza">
|
||||
<input
|
||||
type="date"
|
||||
value={corrente.documentoScadenza ?? ""}
|
||||
onChange={(e) => aggiorna({ documentoScadenza: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
{fileDocumento}
|
||||
|
||||
<Intestazione titolo="Certificato medico" completa={sezioni.certificato} />
|
||||
<Campo label="Data di scadenza">
|
||||
<input
|
||||
type="date"
|
||||
value={corrente.certificatoScadenza ?? ""}
|
||||
onChange={(e) => aggiorna({ certificatoScadenza: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
{fileCertificato}
|
||||
{fileFoto}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function ProfiloAmministrativo({
|
||||
giocatoreId,
|
||||
indice = 0,
|
||||
}: {
|
||||
giocatoreId: string;
|
||||
indice?: number;
|
||||
}) {
|
||||
const { profili } = useProfili();
|
||||
const salva = useSalvaProfilo();
|
||||
const [bozza, setBozza] = useState<Profilo | null>(null);
|
||||
|
||||
const salvato = profili[giocatoreId];
|
||||
const corrente = bozza ?? salvato ?? profiloVuoto(giocatoreId);
|
||||
const sporco = bozza !== null;
|
||||
const perc = completamento(corrente);
|
||||
const sezioni = sezioniComplete(corrente);
|
||||
|
||||
function aggiorna(patch: Partial<Profilo>) {
|
||||
setBozza({ ...corrente, ...patch });
|
||||
}
|
||||
|
||||
async function scrivi(profilo: Profilo) {
|
||||
await salva.mutateAsync(profilo);
|
||||
setBozza(null);
|
||||
}
|
||||
|
||||
async function salvaBozza() {
|
||||
try {
|
||||
await scrivi(corrente);
|
||||
toast.success("Profilo aggiornato");
|
||||
} catch (errore) {
|
||||
toast.error(errore instanceof Error ? errore.message : "Salvataggio non riuscito");
|
||||
}
|
||||
}
|
||||
|
||||
// Un file caricato va persistito subito, insieme a quello che si stava scrivendo.
|
||||
const caricato = (campo: keyof Profilo) => async (path: string) =>
|
||||
scrivi({ ...corrente, [campo]: path });
|
||||
|
||||
return (
|
||||
<Section
|
||||
titolo="Dati per il tesseramento"
|
||||
indice={indice}
|
||||
azione={<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>}
|
||||
>
|
||||
<div className="space-y-3 rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-grad transition-all"
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro.
|
||||
</p>
|
||||
|
||||
<CampiProfilo
|
||||
corrente={corrente}
|
||||
aggiorna={aggiorna}
|
||||
sezioni={sezioni}
|
||||
fileDocumento={
|
||||
<div className="divide-y divide-border">
|
||||
<CampoFile
|
||||
label="Foto fronte"
|
||||
path={corrente.documentoFrontePath}
|
||||
sezione="documento-fronte"
|
||||
giocatoreId={giocatoreId}
|
||||
onCaricato={caricato("documentoFrontePath")}
|
||||
/>
|
||||
<CampoFile
|
||||
label="Foto retro"
|
||||
path={corrente.documentoRetroPath}
|
||||
sezione="documento-retro"
|
||||
giocatoreId={giocatoreId}
|
||||
onCaricato={caricato("documentoRetroPath")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
fileCertificato={
|
||||
<CampoFile
|
||||
label="Certificato medico"
|
||||
path={corrente.certificatoPath}
|
||||
sezione="certificato"
|
||||
giocatoreId={giocatoreId}
|
||||
onCaricato={caricato("certificatoPath")}
|
||||
/>
|
||||
}
|
||||
fileFoto={
|
||||
<>
|
||||
<Intestazione titolo="Foto tessera" completa={sezioni.foto} />
|
||||
<CampoFile
|
||||
label="Foto tessera"
|
||||
path={corrente.fotoPath}
|
||||
sezione="foto"
|
||||
giocatoreId={giocatoreId}
|
||||
onCaricato={caricato("fotoPath")}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={salvaBozza}
|
||||
disabled={!sporco || salva.isPending}
|
||||
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"
|
||||
>
|
||||
{salva.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{sporco ? "Salva" : "Salvato"}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget di Home: sparisce da solo quando il profilo è completo
|
||||
* (docs/modules/profilo-giocatore.md § Home).
|
||||
*/
|
||||
export function CompletaProfilo({
|
||||
giocatoreId,
|
||||
indice = 0,
|
||||
}: {
|
||||
giocatoreId: string;
|
||||
indice?: number;
|
||||
}) {
|
||||
const { profili, isPending } = useProfili();
|
||||
const perc = completamento(profili[giocatoreId]);
|
||||
if (isPending || perc === 100) return null;
|
||||
|
||||
return (
|
||||
<Reveal indice={indice} className="px-5 pt-4">
|
||||
<Link to="/profilo" className="premi block rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-display text-sm uppercase tracking-wide">
|
||||
Completa il tuo profilo
|
||||
</span>
|
||||
<span className="text-xs font-bold tabular-nums text-muted-foreground">{perc}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-grad transition-all"
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Documento, certificato medico e foto tessera servono per il tesseramento CSI.
|
||||
</p>
|
||||
</Link>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,10 @@ import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { giocatori, isAdmin, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import { giocatori, statoMeta, type Stato } from "@/lib/crapp-data";
|
||||
import { usePresenzeEvento, useSalvaPresenza } from "@/lib/presenze";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
|
||||
const ordine: Stato[] = ["presente", "ritardo", "forse", "infortunato", "assente"];
|
||||
|
||||
@@ -14,6 +15,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
|
||||
const { risposte, isPending } = usePresenzeEvento(eventoId);
|
||||
const salva = useSalvaPresenza();
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useIsAdmin();
|
||||
const [sollecito, setSollecito] = useState(false);
|
||||
|
||||
const mancanti = giocatori.filter((g) => !risposte[g.id]);
|
||||
@@ -105,7 +107,7 @@ export function RosaPresenze({ eventoId }: { eventoId: string }) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{io && isAdmin(io.id) ? (
|
||||
{admin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={sollecita}
|
||||
|
||||
@@ -3,16 +3,17 @@ import { ChevronRight, Lock, Radio } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { sessioneScaduta, usePartitaDiOggi, useSessioneScout } from "@/lib/scout-live";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { isAdmin } from "@/lib/crapp-data";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
|
||||
/** Accesso allo scout live: attivo solo il giorno della partita e se nessun altro lo sta usando. */
|
||||
export function ScoutEntry({ variante = "grande" }: { variante?: "grande" | "compatto" }) {
|
||||
const { pronto, partita } = usePartitaDiOggi();
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useIsAdmin();
|
||||
const { data: sessione } = useSessioneScout(partita?.id ?? null);
|
||||
|
||||
// Strumento tecnico: solo i referenti/allenatori scoutizzano la partita.
|
||||
const abilitato = !!io && isAdmin(io.id);
|
||||
const abilitato = admin;
|
||||
|
||||
const attiva = sessione && !sessioneScaduta(sessione) ? sessione : null;
|
||||
const occupato = !!attiva && attiva.giocatore_id !== io?.id;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { supabase } from "./client";
|
||||
|
||||
/**
|
||||
* `types.ts` è generato dallo schema e non include ancora le tabelle introdotte dalle
|
||||
* migration M1 (`giocatori_squadra`) e M2 (`profili_giocatore`). Finché non viene
|
||||
* rigenerato si passa da qui: i tipi delle righe sono dichiarati nei moduli di `src/lib/`,
|
||||
* che restano l'unico punto di accesso al database (DD-013).
|
||||
*
|
||||
* Da eliminare quando `types.ts` sarà rigenerato: i moduli torneranno a usare `supabase`.
|
||||
*/
|
||||
export const supabaseNuoveTabelle = supabase as unknown as SupabaseClient;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Session } from "@supabase/supabase-js";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
|
||||
/**
|
||||
* Autenticazione reale con Google (DD-011). Il login non ha ancora sostituito la
|
||||
* selezione del giocatore: finché `VITE_AUTH_OBBLIGATORIA` non è `true`, `/benvenuto`
|
||||
* offre entrambe le strade, così la produzione continua a funzionare mentre la squadra
|
||||
* collega gli account.
|
||||
*/
|
||||
export function authObbligatoria(): boolean {
|
||||
return import.meta.env["VITE_AUTH_OBBLIGATORIA"] === "true";
|
||||
}
|
||||
|
||||
export function useSessione() {
|
||||
const [sessione, setSessione] = useState<Session | null>(null);
|
||||
const [pronta, setPronta] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let attivo = true;
|
||||
// Il client Supabase esplode alla costruzione se mancano le variabili d'ambiente:
|
||||
// qui va assorbito, altrimenti la schermata di accesso non si disegna proprio e
|
||||
// resta irraggiungibile anche la selezione del giocatore.
|
||||
try {
|
||||
supabase.auth
|
||||
.getSession()
|
||||
.then(({ data }) => {
|
||||
if (!attivo) return;
|
||||
setSessione(data.session);
|
||||
setPronta(true);
|
||||
})
|
||||
.catch(() => attivo && setPronta(true));
|
||||
const { data } = supabase.auth.onAuthStateChange((_evento, nuova) => setSessione(nuova));
|
||||
return () => {
|
||||
attivo = false;
|
||||
data.subscription.unsubscribe();
|
||||
};
|
||||
} catch (errore) {
|
||||
console.error("[auth] Supabase non disponibile", errore);
|
||||
setPronta(true);
|
||||
return () => {
|
||||
attivo = false;
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { sessione, pronta, utenteId: sessione?.user.id ?? null };
|
||||
}
|
||||
|
||||
export async function accediConGoogle(): Promise<void> {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: "google",
|
||||
options: { redirectTo: window.location.origin },
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function esci(): Promise<void> {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
|
||||
import { giocatori } from "./crapp-data";
|
||||
|
||||
/**
|
||||
* Anagrafica operativa della squadra (`giocatori_squadra`, migration M1). È la source of
|
||||
* truth per il collegamento account ↔ giocatore; `crapp-data.ts` resta il fallback finché
|
||||
* la migrazione non è completa (DD-016 regola 1).
|
||||
*/
|
||||
export type GiocatoreSquadra = {
|
||||
id: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
numero: number;
|
||||
ruolo: string;
|
||||
authUserId: string | null;
|
||||
attivo: boolean;
|
||||
};
|
||||
|
||||
type RigaGiocatoreSquadra = {
|
||||
id: string;
|
||||
nome: string;
|
||||
cognome: string;
|
||||
numero: number;
|
||||
ruolo: string;
|
||||
auth_user_id: string | null;
|
||||
attivo: boolean;
|
||||
};
|
||||
|
||||
export const SQUADRA_KEY = ["giocatori-squadra"] as const;
|
||||
|
||||
/** "Carlo Di Castelnuovo" -> nome "Carlo", cognome "Di Castelnuovo". */
|
||||
export function dividiNome(completo: string): { nome: string; cognome: string } {
|
||||
const spazio = completo.indexOf(" ");
|
||||
if (spazio < 0) return { nome: completo, cognome: "" };
|
||||
return { nome: completo.slice(0, spazio), cognome: completo.slice(spazio + 1) };
|
||||
}
|
||||
|
||||
/** Rosa di riserva quando il database non risponde o non è ancora popolato. */
|
||||
export function rosaFallback(): GiocatoreSquadra[] {
|
||||
return giocatori.map((g) => ({
|
||||
...dividiNome(g.nome),
|
||||
id: g.id,
|
||||
numero: g.numero,
|
||||
ruolo: g.ruolo,
|
||||
authUserId: null,
|
||||
attivo: true,
|
||||
}));
|
||||
}
|
||||
|
||||
export function nomeCompleto(g: GiocatoreSquadra): string {
|
||||
return `${g.nome} ${g.cognome}`.trim();
|
||||
}
|
||||
|
||||
/** Lo slot già collegato a questo account, se esiste. */
|
||||
export function slotDi(
|
||||
righe: GiocatoreSquadra[],
|
||||
utenteId: string | null,
|
||||
): GiocatoreSquadra | null {
|
||||
if (!utenteId) return null;
|
||||
return righe.find((g) => g.authUserId === utenteId) ?? null;
|
||||
}
|
||||
|
||||
export function slotLiberi(righe: GiocatoreSquadra[]): GiocatoreSquadra[] {
|
||||
return righe.filter((g) => g.attivo && !g.authUserId);
|
||||
}
|
||||
|
||||
async function fetchSquadra(): Promise<GiocatoreSquadra[]> {
|
||||
const { data, error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.select("id, nome, cognome, numero, ruolo, auth_user_id, attivo")
|
||||
.order("id");
|
||||
if (error) throw error;
|
||||
const righe = (data ?? []) as RigaGiocatoreSquadra[];
|
||||
return righe.map((r) => ({
|
||||
id: r.id,
|
||||
nome: r.nome,
|
||||
cognome: r.cognome,
|
||||
numero: r.numero,
|
||||
ruolo: r.ruolo,
|
||||
authUserId: r.auth_user_id,
|
||||
attivo: r.attivo,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Anagrafica squadra: una lettura per sessione, cambia raramente. */
|
||||
export function useGiocatoriSquadra() {
|
||||
const query = useQuery({ queryKey: SQUADRA_KEY, queryFn: fetchSquadra, staleTime: 30 * 60_000 });
|
||||
const righe = query.data?.length ? query.data : rosaFallback();
|
||||
return { ...query, righe, daDatabase: !!query.data?.length };
|
||||
}
|
||||
|
||||
/** Dati squadra: li gestisce solo un amministratore (DD-017). */
|
||||
export type DatiSquadra = Pick<GiocatoreSquadra, "nome" | "cognome" | "numero" | "ruolo">;
|
||||
|
||||
/**
|
||||
* Controlli che rispecchiano i vincoli della tabella (`numero > 0`, campi obbligatori):
|
||||
* meglio dirlo qui che far tornare un errore Postgres all'utente.
|
||||
* Restituisce il messaggio da mostrare, oppure `null` se va bene.
|
||||
*/
|
||||
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.";
|
||||
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.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Numeri di maglia doppi: il database li accetta, la squadra no. */
|
||||
export function numeroGiaUsato(
|
||||
righe: GiocatoreSquadra[],
|
||||
giocatoreId: string,
|
||||
numero: number,
|
||||
): boolean {
|
||||
return righe.some((g) => g.id !== giocatoreId && g.attivo && g.numero === numero);
|
||||
}
|
||||
|
||||
/** Modifica dei dati squadra. Solo un admin passa le policy di M1. */
|
||||
export function useSalvaDatiSquadra() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; dati: DatiSquadra }) => {
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update({
|
||||
nome: input.dati.nome.trim(),
|
||||
cognome: input.dati.cognome.trim(),
|
||||
numero: input.dati.numero,
|
||||
ruolo: input.dati.ruolo.trim(),
|
||||
})
|
||||
.eq("id", input.giocatoreId);
|
||||
if (error) throw error;
|
||||
return input;
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
(prec ?? []).map((g) => (g.id === input.giocatoreId ? { ...g, ...input.dati } : g)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Libera uno slot occupato per errore (DD-016 regola 2, DD-017). Il giocatore
|
||||
* potrà ricollegarsi al primo accesso; i dati del profilo restano dove sono.
|
||||
*/
|
||||
export function useScollegaAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (giocatoreId: string) => {
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update({ auth_user_id: null })
|
||||
.eq("id", giocatoreId);
|
||||
if (error) throw error;
|
||||
return giocatoreId;
|
||||
},
|
||||
onSuccess: (giocatoreId) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
(prec ?? []).map((g) => (g.id === giocatoreId ? { ...g, authUserId: null } : g)),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Collega l'account al giocatore scelto. Il trigger di M1 accetta l'operazione solo se
|
||||
* lo slot è libero e se nessun altro campo cambia (DD-016 regola 2): il vincolo vive nel
|
||||
* database, non qui.
|
||||
*/
|
||||
export function useCollegaGiocatore() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: { giocatoreId: string; utenteId: string }) => {
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("giocatori_squadra")
|
||||
.update({ auth_user_id: input.utenteId })
|
||||
.eq("id", input.giocatoreId)
|
||||
.is("auth_user_id", null);
|
||||
if (error) throw error;
|
||||
return input;
|
||||
},
|
||||
onSuccess: (input) => {
|
||||
queryClient.setQueryData<GiocatoreSquadra[]>(SQUADRA_KEY, (prec) =>
|
||||
(prec ?? []).map((g) =>
|
||||
g.id === input.giocatoreId ? { ...g, authUserId: input.utenteId } : g,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { rigaCsv } from "./scout-export";
|
||||
import { nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
||||
|
||||
/**
|
||||
* Profilo amministrativo di un giocatore (DD-016). I file veri stanno nel bucket privato
|
||||
* `profili-giocatore`: qui viaggiano solo i path.
|
||||
*/
|
||||
export type Profilo = {
|
||||
giocatoreId: string;
|
||||
dataNascita: string | null;
|
||||
luogoNascita: string | null;
|
||||
indirizzo: string | null;
|
||||
telefono: string | null;
|
||||
email: string | null;
|
||||
documentoTipo: string | null;
|
||||
documentoNumero: string | null;
|
||||
documentoRilasciatoDa: string | null;
|
||||
documentoEmissione: string | null;
|
||||
documentoScadenza: string | null;
|
||||
documentoFrontePath: string | null;
|
||||
documentoRetroPath: string | null;
|
||||
certificatoScadenza: string | null;
|
||||
certificatoPath: string | null;
|
||||
fotoPath: string | null;
|
||||
};
|
||||
|
||||
export type RigaProfilo = {
|
||||
giocatore_id: string;
|
||||
data_nascita: string | null;
|
||||
luogo_nascita: string | null;
|
||||
indirizzo: string | null;
|
||||
telefono: string | null;
|
||||
email: string | null;
|
||||
documento_tipo: string | null;
|
||||
documento_numero: string | null;
|
||||
documento_rilasciato_da: string | null;
|
||||
documento_emissione: string | null;
|
||||
documento_scadenza: string | null;
|
||||
documento_fronte_path: string | null;
|
||||
documento_retro_path: string | null;
|
||||
certificato_scadenza: string | null;
|
||||
certificato_path: string | null;
|
||||
foto_path: string | null;
|
||||
};
|
||||
|
||||
export const COLONNE_PROFILO =
|
||||
"giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email, documento_tipo, documento_numero, documento_rilasciato_da, documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path, certificato_scadenza, certificato_path, foto_path";
|
||||
|
||||
export function profiloVuoto(giocatoreId: string): Profilo {
|
||||
return {
|
||||
giocatoreId,
|
||||
dataNascita: null,
|
||||
luogoNascita: null,
|
||||
indirizzo: null,
|
||||
telefono: null,
|
||||
email: null,
|
||||
documentoTipo: null,
|
||||
documentoNumero: null,
|
||||
documentoRilasciatoDa: null,
|
||||
documentoEmissione: null,
|
||||
documentoScadenza: null,
|
||||
documentoFrontePath: null,
|
||||
documentoRetroPath: null,
|
||||
certificatoScadenza: null,
|
||||
certificatoPath: null,
|
||||
fotoPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function daRigaProfilo(r: RigaProfilo): Profilo {
|
||||
return {
|
||||
giocatoreId: r.giocatore_id,
|
||||
dataNascita: r.data_nascita,
|
||||
luogoNascita: r.luogo_nascita,
|
||||
indirizzo: r.indirizzo,
|
||||
telefono: r.telefono,
|
||||
email: r.email,
|
||||
documentoTipo: r.documento_tipo,
|
||||
documentoNumero: r.documento_numero,
|
||||
documentoRilasciatoDa: r.documento_rilasciato_da,
|
||||
documentoEmissione: r.documento_emissione,
|
||||
documentoScadenza: r.documento_scadenza,
|
||||
documentoFrontePath: r.documento_fronte_path,
|
||||
documentoRetroPath: r.documento_retro_path,
|
||||
certificatoScadenza: r.certificato_scadenza,
|
||||
certificatoPath: r.certificato_path,
|
||||
fotoPath: r.foto_path,
|
||||
};
|
||||
}
|
||||
|
||||
/** I campi vuoti tornano al database come NULL, non come stringa vuota. */
|
||||
function oNull(valore: string | null): string | null {
|
||||
const pulito = valore?.trim();
|
||||
return pulito ? pulito : null;
|
||||
}
|
||||
|
||||
export function aRigaProfilo(p: Profilo): RigaProfilo {
|
||||
return {
|
||||
giocatore_id: p.giocatoreId,
|
||||
data_nascita: oNull(p.dataNascita),
|
||||
luogo_nascita: oNull(p.luogoNascita),
|
||||
indirizzo: oNull(p.indirizzo),
|
||||
telefono: oNull(p.telefono),
|
||||
email: oNull(p.email),
|
||||
documento_tipo: oNull(p.documentoTipo),
|
||||
documento_numero: oNull(p.documentoNumero),
|
||||
documento_rilasciato_da: oNull(p.documentoRilasciatoDa),
|
||||
documento_emissione: oNull(p.documentoEmissione),
|
||||
documento_scadenza: oNull(p.documentoScadenza),
|
||||
documento_fronte_path: oNull(p.documentoFrontePath),
|
||||
documento_retro_path: oNull(p.documentoRetroPath),
|
||||
certificato_scadenza: oNull(p.certificatoScadenza),
|
||||
certificato_path: oNull(p.certificatoPath),
|
||||
foto_path: oNull(p.fotoPath),
|
||||
};
|
||||
}
|
||||
|
||||
/** Pesi delle sezioni del profilo (docs/modules/profilo-giocatore.md). */
|
||||
export const PESI = { dati: 30, documento: 30, certificato: 30, foto: 10 } as const;
|
||||
|
||||
export type Sezione = keyof typeof PESI;
|
||||
|
||||
export function sezioniComplete(p: Profilo | null | undefined): Record<Sezione, boolean> {
|
||||
return {
|
||||
dati: !!(p?.dataNascita && p.luogoNascita && p.indirizzo && p.telefono && p.email),
|
||||
documento: !!(
|
||||
p?.documentoTipo &&
|
||||
p.documentoNumero &&
|
||||
p.documentoScadenza &&
|
||||
p.documentoFrontePath &&
|
||||
p.documentoRetroPath
|
||||
),
|
||||
certificato: !!(p?.certificatoScadenza && p.certificatoPath),
|
||||
foto: !!p?.fotoPath,
|
||||
};
|
||||
}
|
||||
|
||||
/** Percentuale di completamento: calcolata a runtime, mai persistita (DD-007, DD-016). */
|
||||
export function completamento(p: Profilo | null | undefined): number {
|
||||
const complete = sezioniComplete(p);
|
||||
return (Object.keys(PESI) as Sezione[]).reduce(
|
||||
(somma, s) => somma + (complete[s] ? PESI[s] : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export type StatoScadenza = "mancante" | "scaduto" | "valido";
|
||||
|
||||
/** Un certificato scaduto blocca il tesseramento: per l'admin non vale come presente. */
|
||||
export function statoScadenza(
|
||||
scadenza: string | null | undefined,
|
||||
path: string | null | undefined,
|
||||
oggi: string,
|
||||
): StatoScadenza {
|
||||
if (!path || !scadenza) return "mancante";
|
||||
return scadenza < oggi ? "scaduto" : "valido";
|
||||
}
|
||||
|
||||
/** Colonne richieste dal tesseramento CSI, nell'ordine del documento di modulo. */
|
||||
const INTESTAZIONI = [
|
||||
"Nome",
|
||||
"Cognome",
|
||||
"Data di nascita",
|
||||
"Luogo di nascita",
|
||||
"Indirizzo",
|
||||
"Telefono",
|
||||
"Email",
|
||||
"Tipo documento",
|
||||
"Numero documento",
|
||||
"Rilasciato da",
|
||||
"Data emissione",
|
||||
"Data scadenza",
|
||||
];
|
||||
|
||||
export function csvTesseramento(
|
||||
squadra: GiocatoreSquadra[],
|
||||
profili: Record<string, Profilo>,
|
||||
): string {
|
||||
const righe = [rigaCsv(INTESTAZIONI)];
|
||||
for (const g of squadra) {
|
||||
const p = profili[g.id];
|
||||
righe.push(
|
||||
rigaCsv([
|
||||
g.nome,
|
||||
g.cognome,
|
||||
p?.dataNascita ?? "",
|
||||
p?.luogoNascita ?? "",
|
||||
p?.indirizzo ?? "",
|
||||
p?.telefono ?? "",
|
||||
p?.email ?? "",
|
||||
p?.documentoTipo ?? "",
|
||||
p?.documentoNumero ?? "",
|
||||
p?.documentoRilasciatoDa ?? "",
|
||||
p?.documentoEmissione ?? "",
|
||||
p?.documentoScadenza ?? "",
|
||||
]),
|
||||
);
|
||||
}
|
||||
return righe.join("\n");
|
||||
}
|
||||
|
||||
/** Etichetta per l'elenco della dashboard. */
|
||||
export function etichettaGiocatore(g: GiocatoreSquadra): string {
|
||||
return `#${g.numero} ${nomeCompleto(g)}`;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { supabaseNuoveTabelle } from "@/integrations/supabase/client-nuove-tabelle";
|
||||
import {
|
||||
aRigaProfilo,
|
||||
COLONNE_PROFILO,
|
||||
daRigaProfilo,
|
||||
type Profilo,
|
||||
type RigaProfilo,
|
||||
} from "./profili-core";
|
||||
|
||||
export const PROFILI_KEY = ["profili-giocatore"] as const;
|
||||
export const BUCKET = "profili-giocatore";
|
||||
|
||||
async function fetchProfili(): Promise<Record<string, Profilo>> {
|
||||
const { data, error } = await supabaseNuoveTabelle
|
||||
.from("profili_giocatore")
|
||||
.select(COLONNE_PROFILO);
|
||||
if (error) throw error;
|
||||
const mappa: Record<string, Profilo> = {};
|
||||
for (const r of (data ?? []) as RigaProfilo[]) mappa[r.giocatore_id] = daRigaProfilo(r);
|
||||
return mappa;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profili visibili all'utente corrente: le policy RLS decidono quanti sono — il proprio
|
||||
* per un giocatore, tutti per un admin. Una lettura per sessione.
|
||||
*/
|
||||
export function useProfili() {
|
||||
const query = useQuery({ queryKey: PROFILI_KEY, queryFn: fetchProfili, staleTime: 30 * 60_000 });
|
||||
return { ...query, profili: query.data ?? {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* I documenti stanno in un bucket privato e non hanno URL permanenti (DD-016 regola 4):
|
||||
* ogni download passa da una signed URL che scade in un minuto.
|
||||
*/
|
||||
export async function urlFirmato(path: string): Promise<string> {
|
||||
const { data, error } = await supabase.storage.from(BUCKET).createSignedUrl(path, 60);
|
||||
if (error) throw error;
|
||||
return data.signedUrl;
|
||||
}
|
||||
|
||||
export async function scaricaFile(path: string): Promise<void> {
|
||||
const url = await urlFirmato(path);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Salva il profilo del giocatore. Le policy RLS lasciano scrivere solo la propria riga:
|
||||
* il vincolo vive nel database, qui non serve ricontrollarlo.
|
||||
*/
|
||||
export function useSalvaProfilo() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (profilo: Profilo) => {
|
||||
const { error } = await supabaseNuoveTabelle
|
||||
.from("profili_giocatore")
|
||||
.upsert(aRigaProfilo(profilo), { onConflict: "giocatore_id" });
|
||||
if (error) throw error;
|
||||
return profilo;
|
||||
},
|
||||
// Scrittura unica e cache aggiornata a mano, senza rilettura.
|
||||
onSuccess: (profilo) => {
|
||||
queryClient.setQueryData<Record<string, Profilo>>(PROFILI_KEY, (prec) => ({
|
||||
...(prec ?? {}),
|
||||
[profilo.giocatoreId]: profilo,
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type SezioneFile = "documento-fronte" | "documento-retro" | "certificato" | "foto";
|
||||
|
||||
const MAX_BYTE = 8 * 1024 * 1024;
|
||||
const TIPI_AMMESSI = ["image/jpeg", "image/png", "image/webp", "application/pdf"];
|
||||
|
||||
function estensione(nome: string): string {
|
||||
const punto = nome.lastIndexOf(".");
|
||||
const est = punto > 0 ? nome.slice(punto + 1).toLowerCase() : "";
|
||||
return /^[a-z0-9]{1,5}$/.test(est) ? est : "bin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Carica un file nella cartella del giocatore e restituisce il path da salvare sul profilo.
|
||||
* Il controllo su tipo e dimensione sta qui perché è il confine con un file scelto
|
||||
* dall'utente; le policy dello Storage impediscono comunque di scrivere fuori dalla
|
||||
* propria cartella.
|
||||
*/
|
||||
export async function caricaFile(
|
||||
giocatoreId: string,
|
||||
sezione: SezioneFile,
|
||||
file: File,
|
||||
pathPrecedente?: string | null,
|
||||
): Promise<string> {
|
||||
if (!TIPI_AMMESSI.includes(file.type)) {
|
||||
throw new Error("Formato non ammesso: usa JPG, PNG, WEBP o PDF.");
|
||||
}
|
||||
if (file.size > MAX_BYTE) throw new Error("File troppo grande: massimo 8 MB.");
|
||||
|
||||
const path = `${giocatoreId}/${sezione}.${estensione(file.name)}`;
|
||||
const { error } = await supabase.storage
|
||||
.from(BUCKET)
|
||||
.upload(path, file, { upsert: true, contentType: file.type });
|
||||
if (error) throw error;
|
||||
|
||||
// Cambiando estensione il vecchio file resterebbe orfano nel bucket.
|
||||
if (pathPrecedente && pathPrecedente !== path) {
|
||||
await supabase.storage.from(BUCKET).remove([pathPrecedente]);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function rimuoviFile(path: string): Promise<void> {
|
||||
const { error } = await supabase.storage.from(BUCKET).remove([path]);
|
||||
if (error) throw error;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { isAdmin as nomeInListaAdmin } from "./crapp-data";
|
||||
import { useSessione } from "./auth";
|
||||
import { useGiocatoreBase } from "./user-store";
|
||||
|
||||
export const RUOLI_KEY = ["ruolo-admin"] as const;
|
||||
|
||||
/**
|
||||
* Permessi di amministrazione. La fonte è `user_roles` nel database (DD-011): la lista di
|
||||
* nomi in `crapp-data.ts` resta solo come ponte per chi non ha ancora collegato l'account,
|
||||
* e sparisce quando `VITE_AUTH_OBBLIGATORIA` viene acceso in produzione.
|
||||
*
|
||||
* ponytail: doppia fonte temporanea, si riduce a `ruoloDb` appena l'auth è obbligatoria.
|
||||
*/
|
||||
export function risolviAdmin(ruoloDb: boolean | null, giocatoreId: string | null): boolean {
|
||||
if (ruoloDb !== null) return ruoloDb;
|
||||
return giocatoreId ? nomeInListaAdmin(giocatoreId) : false;
|
||||
}
|
||||
|
||||
/** `null` = nessuna sessione, quindi il database non ha una risposta da dare. */
|
||||
async function fetchRuoloAdmin(utenteId: string | null): Promise<boolean | null> {
|
||||
if (!utenteId) return null;
|
||||
const { data, error } = await supabase
|
||||
.from("user_roles")
|
||||
.select("role")
|
||||
.eq("user_id", utenteId)
|
||||
.eq("role", "admin")
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return !!data;
|
||||
}
|
||||
|
||||
export function useIsAdmin(): boolean {
|
||||
const { utenteId } = useSessione();
|
||||
const io = useGiocatoreBase();
|
||||
// Il ruolo cambia solo quando un admin lo assegna: una lettura per sessione basta.
|
||||
const query = useQuery({
|
||||
queryKey: [...RUOLI_KEY, utenteId],
|
||||
queryFn: () => fetchRuoloAdmin(utenteId),
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
return risolviAdmin(query.data ?? null, io?.id ?? null);
|
||||
}
|
||||
+10
-9
@@ -1,7 +1,8 @@
|
||||
import { giocatori } from "./crapp-data";
|
||||
import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store";
|
||||
|
||||
function riga(campi: Array<string | number>) {
|
||||
/** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */
|
||||
export function rigaCsv(campi: Array<string | number>) {
|
||||
return campi
|
||||
.map((c) => {
|
||||
const testo = String(c);
|
||||
@@ -13,24 +14,24 @@ function riga(campi: Array<string | number>) {
|
||||
/** Esporta la scoutizzazione di una partita in CSV (separatore ";" per Excel IT). */
|
||||
export function csvScoutMatch(match: ScoutMatch): string {
|
||||
const righe: string[] = [];
|
||||
righe.push(riga(["Partita", match.casa ? "CRAP Volley" : match.avversario, "vs", match.casa ? match.avversario : "CRAP Volley"]));
|
||||
righe.push(riga(["Data", match.data, "Set", `${match.setNostri}-${match.setLoro}`]));
|
||||
righe.push(rigaCsv(["Partita", match.casa ? "CRAP Volley" : match.avversario, "vs", match.casa ? match.avversario : "CRAP Volley"]));
|
||||
righe.push(rigaCsv(["Data", match.data, "Set", `${match.setNostri}-${match.setLoro}`]));
|
||||
righe.push("");
|
||||
righe.push(riga(["Set", "Parziale nostro", "Parziale loro"]));
|
||||
match.parziali.forEach((p, i) => righe.push(riga([i + 1, p[0], p[1]])));
|
||||
righe.push(rigaCsv(["Set", "Parziale nostro", "Parziale loro"]));
|
||||
match.parziali.forEach((p, i) => righe.push(rigaCsv([i + 1, p[0], p[1]])));
|
||||
righe.push("");
|
||||
righe.push(riga(["Numero", "Giocatore", "Ruolo", "Punti", "Ace", "Muri", "Errori"]));
|
||||
righe.push(rigaCsv(["Numero", "Giocatore", "Ruolo", "Punti", "Ace", "Muri", "Errori"]));
|
||||
const totali = totaliPerGiocatore(match.azioni);
|
||||
for (const g of giocatori) {
|
||||
const t = totali.get(g.id);
|
||||
if (!t) continue;
|
||||
righe.push(riga([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori]));
|
||||
righe.push(rigaCsv([g.numero, g.nome, g.ruolo, t.punti, t.ace, t.muri, t.errori]));
|
||||
}
|
||||
righe.push("");
|
||||
righe.push(riga(["Set", "Giocatore", "Azione"]));
|
||||
righe.push(rigaCsv(["Set", "Giocatore", "Azione"]));
|
||||
for (const a of match.azioni) {
|
||||
const g = giocatori.find((x) => x.id === a.giocatoreId);
|
||||
righe.push(riga([a.set, g?.nome ?? "—", azioniMeta[a.tipo].label]));
|
||||
righe.push(rigaCsv([a.set, g?.nome ?? "—", azioniMeta[a.tipo].label]));
|
||||
}
|
||||
return righe.join("\n");
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AdminRouteImport } from './routes/admin'
|
||||
import { Route as BenvenutoRouteImport } from './routes/benvenuto'
|
||||
import { Route as CalendarioRouteImport } from './routes/calendario'
|
||||
import { Route as ClassificaRouteImport } from './routes/classifica'
|
||||
@@ -31,6 +32,11 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminRoute = AdminRouteImport.update({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const BenvenutoRoute = BenvenutoRouteImport.update({
|
||||
id: '/benvenuto',
|
||||
path: '/benvenuto',
|
||||
@@ -111,6 +117,7 @@ const ApiPublicSollecitaPresenzeRoute =
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/benvenuto': typeof BenvenutoRoute
|
||||
'/calendario': typeof CalendarioRoute
|
||||
'/classifica': typeof ClassificaRoute
|
||||
@@ -129,6 +136,7 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/benvenuto': typeof BenvenutoRoute
|
||||
'/calendario': typeof CalendarioRoute
|
||||
'/classifica': typeof ClassificaRoute
|
||||
@@ -148,6 +156,7 @@ export interface FileRoutesByTo {
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/benvenuto': typeof BenvenutoRoute
|
||||
'/calendario': typeof CalendarioRoute
|
||||
'/classifica': typeof ClassificaRoute
|
||||
@@ -168,6 +177,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/benvenuto'
|
||||
| '/calendario'
|
||||
| '/classifica'
|
||||
@@ -186,6 +196,7 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/benvenuto'
|
||||
| '/calendario'
|
||||
| '/classifica'
|
||||
@@ -204,6 +215,7 @@ export interface FileRouteTypes {
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/benvenuto'
|
||||
| '/calendario'
|
||||
| '/classifica'
|
||||
@@ -223,6 +235,7 @@ export interface FileRouteTypes {
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AdminRoute: typeof AdminRoute
|
||||
BenvenutoRoute: typeof BenvenutoRoute
|
||||
CalendarioRoute: typeof CalendarioRoute
|
||||
ClassificaRoute: typeof ClassificaRoute
|
||||
@@ -249,6 +262,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin': {
|
||||
id: '/admin'
|
||||
path: '/admin'
|
||||
fullPath: '/admin'
|
||||
preLoaderRoute: typeof AdminRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/benvenuto': {
|
||||
id: '/benvenuto'
|
||||
path: '/benvenuto'
|
||||
@@ -359,6 +379,7 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AdminRoute: AdminRoute,
|
||||
BenvenutoRoute: BenvenutoRoute,
|
||||
CalendarioRoute: CalendarioRoute,
|
||||
ClassificaRoute: ClassificaRoute,
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileText,
|
||||
IdCard,
|
||||
Image,
|
||||
Loader2,
|
||||
Lock,
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits";
|
||||
import { CampiProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import {
|
||||
nomeCompleto,
|
||||
numeroGiaUsato,
|
||||
useGiocatoriSquadra,
|
||||
useSalvaDatiSquadra,
|
||||
useScollegaAccount,
|
||||
validaDatiSquadra,
|
||||
type DatiSquadra,
|
||||
type GiocatoreSquadra,
|
||||
} from "@/lib/giocatori-squadra";
|
||||
import { scaricaFile, useProfili, useSalvaProfilo } from "@/lib/profili";
|
||||
import {
|
||||
completamento,
|
||||
csvTesseramento,
|
||||
profiloVuoto,
|
||||
sezioniComplete,
|
||||
statoScadenza,
|
||||
type Profilo,
|
||||
type StatoScadenza,
|
||||
} from "@/lib/profili-core";
|
||||
import { oggiISO } from "@/lib/palloni-core";
|
||||
import { scaricaCsv } from "@/lib/scout-export";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
|
||||
export const Route = createFileRoute("/admin")({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{ title: "Dashboard amministratore — CrAPP" },
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"Area riservata: stato dei profili, documenti e certificati della squadra, ed export dei dati per il tesseramento CSI.",
|
||||
},
|
||||
{ property: "og:title", content: "Dashboard amministratore — CrAPP" },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "Stato dei profili della squadra ed export per il tesseramento CSI.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: Dashboard,
|
||||
});
|
||||
|
||||
const statoClasse: Record<StatoScadenza | "presente" | "assente", string> = {
|
||||
valido: "bg-success text-success-foreground",
|
||||
presente: "bg-success text-success-foreground",
|
||||
scaduto: "bg-destructive text-destructive-foreground",
|
||||
mancante: "bg-secondary text-muted-foreground",
|
||||
assente: "bg-secondary text-muted-foreground",
|
||||
};
|
||||
|
||||
const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm";
|
||||
|
||||
function Campo({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span className="mt-1 block">{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pannello di modifica dell'admin (DD-017): dati squadra, dati personali e collegamento
|
||||
* all'account. I file restano fuori: l'admin li scarica, non li carica al posto di altri.
|
||||
*/
|
||||
function ModificaGiocatore({ g, profilo }: { g: GiocatoreSquadra; profilo: Profilo | undefined }) {
|
||||
const { righe } = useGiocatoriSquadra();
|
||||
const salvaSquadra = useSalvaDatiSquadra();
|
||||
const salvaProfilo = useSalvaProfilo();
|
||||
const scollega = useScollegaAccount();
|
||||
|
||||
const [datiSquadra, setDatiSquadra] = useState<DatiSquadra | null>(null);
|
||||
const [bozza, setBozza] = useState<Profilo | null>(null);
|
||||
|
||||
const squadraCorrente: DatiSquadra = datiSquadra ?? {
|
||||
nome: g.nome,
|
||||
cognome: g.cognome,
|
||||
numero: g.numero,
|
||||
ruolo: g.ruolo,
|
||||
};
|
||||
const profiloCorrente = bozza ?? profilo ?? profiloVuoto(g.id);
|
||||
|
||||
async function confermaSquadra() {
|
||||
const errore = validaDatiSquadra(squadraCorrente);
|
||||
if (errore) {
|
||||
toast.error(errore);
|
||||
return;
|
||||
}
|
||||
if (numeroGiaUsato(righe, g.id, squadraCorrente.numero)) {
|
||||
toast.warning(`Il numero ${squadraCorrente.numero} è già assegnato a un altro giocatore.`);
|
||||
}
|
||||
try {
|
||||
await salvaSquadra.mutateAsync({ giocatoreId: g.id, dati: squadraCorrente });
|
||||
setDatiSquadra(null);
|
||||
toast.success("Dati squadra aggiornati");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Salvataggio non riuscito");
|
||||
}
|
||||
}
|
||||
|
||||
async function confermaProfilo() {
|
||||
try {
|
||||
await salvaProfilo.mutateAsync(profiloCorrente);
|
||||
setBozza(null);
|
||||
toast.success("Profilo aggiornato");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Salvataggio non riuscito");
|
||||
}
|
||||
}
|
||||
|
||||
async function confermaScollega() {
|
||||
if (
|
||||
!confirm(
|
||||
`Scollegare l'account di ${nomeCompleto(g)}? Potrà ricollegarsi al prossimo accesso.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await scollega.mutateAsync(g.id);
|
||||
toast.success("Account scollegato");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Operazione non riuscita");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3 border-t border-border pt-3">
|
||||
<h3 className="font-display text-sm uppercase tracking-wide">Dati squadra</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Campo label="Nome">
|
||||
<input
|
||||
value={squadraCorrente.nome}
|
||||
maxLength={40}
|
||||
onChange={(e) => setDatiSquadra({ ...squadraCorrente, nome: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
<Campo label="Cognome">
|
||||
<input
|
||||
value={squadraCorrente.cognome}
|
||||
maxLength={40}
|
||||
onChange={(e) => setDatiSquadra({ ...squadraCorrente, cognome: e.target.value })}
|
||||
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">
|
||||
<input
|
||||
value={squadraCorrente.ruolo}
|
||||
maxLength={30}
|
||||
onChange={(e) => setDatiSquadra({ ...squadraCorrente, ruolo: e.target.value })}
|
||||
className={classiInput}
|
||||
/>
|
||||
</Campo>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confermaSquadra}
|
||||
disabled={!datiSquadra || salvaSquadra.isPending}
|
||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
Salva dati squadra
|
||||
</button>
|
||||
|
||||
<CampiProfilo
|
||||
corrente={profiloCorrente}
|
||||
aggiorna={(patch) => setBozza({ ...profiloCorrente, ...patch })}
|
||||
sezioni={sezioniComplete(profiloCorrente)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confermaProfilo}
|
||||
disabled={!bozza || salvaProfilo.isPending}
|
||||
className="premi w-full rounded-2xl bg-primary py-2.5 text-xs font-bold uppercase text-primary-foreground disabled:opacity-50"
|
||||
>
|
||||
Salva dati personali
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-3 text-xs">
|
||||
<span className="min-w-0 text-muted-foreground">
|
||||
{g.authUserId ? "Account collegato" : "Nessun account collegato"}
|
||||
</span>
|
||||
{g.authUserId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={confermaScollega}
|
||||
disabled={scollega.isPending}
|
||||
className="premi flex shrink-0 items-center gap-1.5 rounded-xl bg-destructive px-3 py-2 font-bold text-destructive-foreground disabled:opacity-50"
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" /> Scollega
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Documento({
|
||||
icona,
|
||||
label,
|
||||
stato,
|
||||
path,
|
||||
}: {
|
||||
icona: React.ReactNode;
|
||||
label: string;
|
||||
stato: StatoScadenza | "presente" | "assente";
|
||||
path: string | null;
|
||||
}) {
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
|
||||
async function scarica() {
|
||||
if (!path || inCorso) return;
|
||||
setInCorso(true);
|
||||
try {
|
||||
await scaricaFile(path);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Download non riuscito");
|
||||
} finally {
|
||||
setInCorso(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scarica}
|
||||
disabled={!path || inCorso}
|
||||
className={cn(
|
||||
"premi flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-bold uppercase disabled:opacity-60",
|
||||
statoClasse[stato],
|
||||
)}
|
||||
>
|
||||
{inCorso ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : icona}
|
||||
{label}
|
||||
{path ? <Download className="h-3 w-3" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SchedaGiocatore({
|
||||
g,
|
||||
profilo,
|
||||
oggi,
|
||||
indice,
|
||||
}: {
|
||||
g: GiocatoreSquadra;
|
||||
profilo: Profilo | undefined;
|
||||
oggi: string;
|
||||
indice: number;
|
||||
}) {
|
||||
const [aperta, setAperta] = useState(false);
|
||||
const nome = nomeCompleto(g);
|
||||
const { ruolo, numero } = g;
|
||||
const perc = completamento(profilo);
|
||||
const sezioni = sezioniComplete(profilo);
|
||||
const certificato = statoScadenza(profilo?.certificatoScadenza, profilo?.certificatoPath, oggi);
|
||||
const fronte = statoScadenza(profilo?.documentoScadenza, profilo?.documentoFrontePath, oggi);
|
||||
const retro = statoScadenza(profilo?.documentoScadenza, profilo?.documentoRetroPath, oggi);
|
||||
|
||||
return (
|
||||
<Reveal indice={indice} className="rounded-2xl bg-card p-4 shadow-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAperta((v) => !v)}
|
||||
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}
|
||||
</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}%
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform",
|
||||
aperta && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-grad transition-all"
|
||||
style={{ width: `${perc}%` }}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{aperta ? <ModificaGiocatore g={g} profilo={profilo} /> : null}
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const admin = useIsAdmin();
|
||||
const { righe: squadra } = useGiocatoriSquadra();
|
||||
const { profili, isPending } = useProfili();
|
||||
const oggi = oggiISO();
|
||||
|
||||
if (!admin) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Dashboard" sottotitolo="Area riservata" />
|
||||
<div className="px-5 pt-4">
|
||||
<p className="flex items-center justify-center gap-2 rounded-3xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
<Lock className="h-4 w-4 shrink-0" />
|
||||
Riservata agli amministratori della squadra.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const attivi = squadra.filter((g) => g.attivo);
|
||||
const completi = attivi.filter((g) => completamento(profili[g.id]) === 100).length;
|
||||
const certificatiOk = attivi.filter(
|
||||
(g) =>
|
||||
statoScadenza(profili[g.id]?.certificatoScadenza, profili[g.id]?.certificatoPath, oggi) ===
|
||||
"valido",
|
||||
).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Dashboard" sottotitolo="Profili e tesseramento" />
|
||||
|
||||
<Section titolo="Squadra">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile valore={attivi.length} label="Giocatori" />
|
||||
<StatTile valore={`${completi}/${attivi.length}`} label="Profili completi" />
|
||||
<StatTile
|
||||
valore={`${certificatiOk}/${attivi.length}`}
|
||||
label="Certificati validi"
|
||||
hint="non scaduti"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
scaricaCsv(`tesseramento-csi-${oggi}.csv`, csvTesseramento(attivi, profili))
|
||||
}
|
||||
className="premi mt-3 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"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Esporta CSV tesseramento
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
<Section titolo="Profili" indice={1}>
|
||||
{isPending ? (
|
||||
<p className="rounded-2xl bg-card p-5 text-center text-sm text-muted-foreground shadow-card">
|
||||
Caricamento…
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{attivi.map((g, i) => (
|
||||
<SchedaGiocatore key={g.id} g={g} profilo={profili[g.id]} oggi={oggi} indice={i} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+132
-25
@@ -1,7 +1,18 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LogIn } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { TeamLogo } from "@/components/crapp/ui-bits";
|
||||
import { giocatori } from "@/lib/crapp-data";
|
||||
import { accediConGoogle, authObbligatoria, useSessione } from "@/lib/auth";
|
||||
import {
|
||||
nomeCompleto,
|
||||
slotDi,
|
||||
slotLiberi,
|
||||
useCollegaGiocatore,
|
||||
useGiocatoriSquadra,
|
||||
type GiocatoreSquadra,
|
||||
} from "@/lib/giocatori-squadra";
|
||||
import { impostaGiocatore, useGiocatoreCorrente } from "@/lib/user-store";
|
||||
|
||||
export const Route = createFileRoute("/benvenuto")({
|
||||
@@ -10,27 +21,89 @@ export const Route = createFileRoute("/benvenuto")({
|
||||
{ title: "Benvenuto — CrAPP DEVELOP" },
|
||||
{
|
||||
name: "description",
|
||||
content: "Seleziona il tuo profilo giocatore per iniziare.",
|
||||
content: "Accedi e collega il tuo profilo giocatore per iniziare.",
|
||||
},
|
||||
{ property: "og:title", content: "Benvenuto — CrAPP DEVELOP" },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "Seleziona il tuo profilo giocatore per iniziare.",
|
||||
content: "Accedi e collega il tuo profilo giocatore per iniziare.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
component: Benvenuto,
|
||||
});
|
||||
|
||||
function Scheda({
|
||||
titolo,
|
||||
sottotitolo,
|
||||
onClick,
|
||||
iniziali,
|
||||
}: {
|
||||
titolo: string;
|
||||
sottotitolo: string;
|
||||
onClick: () => void;
|
||||
iniziali: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center gap-4 rounded-2xl bg-card p-4 shadow-card transition-transform active:scale-[0.98]"
|
||||
>
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-secondary font-display text-lg">
|
||||
{iniziali}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<p className="font-semibold leading-tight">{titolo}</p>
|
||||
<p className="text-xs text-muted-foreground">{sottotitolo}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Benvenuto() {
|
||||
const navigate = useNavigate();
|
||||
const giocatore = useGiocatoreCorrente();
|
||||
const { pronta, utenteId } = useSessione();
|
||||
const { righe } = useGiocatoriSquadra();
|
||||
const collega = useCollegaGiocatore();
|
||||
const [inCorso, setInCorso] = useState(false);
|
||||
|
||||
const mioSlot = slotDi(righe, utenteId);
|
||||
// Con l'auth obbligatoria si entra solo da loggati; finché non lo è, la selezione
|
||||
// diretta resta come ponte per chi non ha ancora collegato l'account (DD-011).
|
||||
const puoEntrare = !!giocatore && (!!utenteId || !authObbligatoria());
|
||||
|
||||
useEffect(() => {
|
||||
if (giocatore) {
|
||||
navigate({ to: "/" });
|
||||
if (puoEntrare) navigate({ to: "/" });
|
||||
}, [puoEntrare, navigate]);
|
||||
|
||||
// L'account è già collegato a uno slot: nessuna scelta da fare.
|
||||
useEffect(() => {
|
||||
if (mioSlot) impostaGiocatore(mioSlot.id);
|
||||
}, [mioSlot]);
|
||||
|
||||
async function accedi() {
|
||||
setInCorso(true);
|
||||
try {
|
||||
await accediConGoogle();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Accesso non riuscito");
|
||||
setInCorso(false);
|
||||
}
|
||||
}, [giocatore, navigate]);
|
||||
}
|
||||
|
||||
async function reclama(g: GiocatoreSquadra) {
|
||||
if (!utenteId) return;
|
||||
try {
|
||||
await collega.mutateAsync({ giocatoreId: g.id, utenteId });
|
||||
impostaGiocatore(g.id);
|
||||
} catch {
|
||||
toast.error("Profilo già collegato a un altro account. Chiedi a un amministratore.");
|
||||
}
|
||||
}
|
||||
|
||||
const liberi = slotLiberi(righe);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6 py-12">
|
||||
@@ -38,29 +111,63 @@ function Benvenuto() {
|
||||
<h1 className="mt-6 text-center font-display text-4xl uppercase leading-none">
|
||||
Benvenuto in CrAPP DEVELOP
|
||||
</h1>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Seleziona chi sei per personalizzare l'app.
|
||||
</p>
|
||||
<div className="mt-8 w-full max-w-sm space-y-2">
|
||||
{giocatori.map((g) => (
|
||||
|
||||
{!pronta ? null : !utenteId ? (
|
||||
<>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Accedi con il tuo account Google per collegare il profilo giocatore.
|
||||
</p>
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
onClick={() => impostaGiocatore(g.id)}
|
||||
className="flex w-full items-center gap-4 rounded-2xl bg-card p-4 shadow-card transition-transform active:scale-[0.98]"
|
||||
onClick={accedi}
|
||||
disabled={inCorso}
|
||||
className="premi mt-8 flex w-full max-w-sm items-center justify-center gap-2 rounded-2xl bg-accent-grad py-3.5 text-sm font-bold uppercase text-accent-foreground shadow-pop disabled:opacity-60"
|
||||
>
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-xl bg-secondary font-display text-lg">
|
||||
{g.iniziali}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<p className="font-semibold leading-tight">{g.nome}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
#{g.numero} · {g.ruolo}
|
||||
</p>
|
||||
</div>
|
||||
<LogIn className="h-4 w-4" /> Accedi con Google
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{authObbligatoria() ? null : (
|
||||
<div className="mt-10 w-full max-w-sm">
|
||||
<p className="mb-3 text-center text-xs text-muted-foreground">
|
||||
Oppure entra scegliendo il tuo nome, come prima.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{giocatori.map((g) => (
|
||||
<Scheda
|
||||
key={g.id}
|
||||
titolo={g.nome}
|
||||
sottotitolo={`#${g.numero} · ${g.ruolo}`}
|
||||
iniziali={g.iniziali}
|
||||
onClick={() => impostaGiocatore(g.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Sei entrato. Scegli il tuo nome: resterà collegato a questo account.
|
||||
</p>
|
||||
<div className="mt-8 w-full max-w-sm space-y-2">
|
||||
{liberi.map((g) => (
|
||||
<Scheda
|
||||
key={g.id}
|
||||
titolo={nomeCompleto(g)}
|
||||
sottotitolo={`#${g.numero} · ${g.ruolo}`}
|
||||
iniziali={`${g.nome[0] ?? ""}${g.cognome[0] ?? ""}`.toUpperCase()}
|
||||
onClick={() => void reclama(g)}
|
||||
/>
|
||||
))}
|
||||
{liberi.length === 0 ? (
|
||||
<p className="rounded-2xl bg-card p-4 text-center text-sm text-muted-foreground shadow-card">
|
||||
Nessun profilo libero: chiedi a un amministratore di collegarti.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import { Link } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { isAdmin } from "@/lib/crapp-data";
|
||||
import { compleanniEventi, useEventi, type Evento } from "@/lib/eventi";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
@@ -97,6 +97,7 @@ function Calendario() {
|
||||
const [giornoSelezionato, setGiornoSelezionato] = useState<number | null>(null);
|
||||
const [drawerAperto, setDrawerAperto] = useState(false);
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useIsAdmin();
|
||||
const { eventi } = useEventi();
|
||||
const { anno, mese, precedente, successivo } = useMeseNav();
|
||||
const { giorni, offsetLunedi } = giorniDelMese(anno, mese);
|
||||
@@ -245,7 +246,7 @@ function Calendario() {
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{io && isAdmin(io.id) ? (
|
||||
{admin ? (
|
||||
<div className="px-5 pt-4">
|
||||
<Link
|
||||
to="/eventi"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ArrowLeft, CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { formatData, giocatori, isAdmin } from "@/lib/crapp-data";
|
||||
import { formatData, giocatori } from "@/lib/crapp-data";
|
||||
import {
|
||||
categoriaEvento,
|
||||
daCategoria,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type Evento,
|
||||
} from "@/lib/eventi";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
|
||||
export const Route = createFileRoute("/eventi")({
|
||||
head: () => ({
|
||||
@@ -47,12 +48,13 @@ const tipi: Array<{ id: CategoriaEvento; label: string }> = [
|
||||
|
||||
function GestioneEventi() {
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useIsAdmin();
|
||||
const { eventi, isPending } = useEventi();
|
||||
const salva = useSalvaEvento();
|
||||
const elimina = useEliminaEvento();
|
||||
const [bozza, setBozza] = useState<Evento | null>(null);
|
||||
|
||||
if (!io || !isAdmin(io.id)) {
|
||||
if (!io || !admin) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader titolo="Gestione eventi" sottotitolo="Area riservata" />
|
||||
|
||||
+22
-14
@@ -4,6 +4,7 @@ import { EventoCard, linkPerEvento } from "@/components/crapp/EventoCard";
|
||||
import { PromemoriaPalloni } from "@/components/crapp/PromemoriaPalloni";
|
||||
import { ScoutEntry } from "@/components/crapp/ScoutEntry";
|
||||
import { Section, StatTile, TeamLogo } from "@/components/crapp/ui-bits";
|
||||
import { CompletaProfilo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
import { Barra } from "@/components/motion/Barra";
|
||||
import { Numero } from "@/components/motion/Numero";
|
||||
@@ -24,7 +25,8 @@ export const Route = createFileRoute("/")({
|
||||
{ property: "og:title", content: "CrAPP — L'app del CRAP Volley" },
|
||||
{
|
||||
property: "og:description",
|
||||
content: "Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
|
||||
content:
|
||||
"Convocazioni, presenze, statistiche e classifica del CRAP Volley in un'unica app mobile.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -105,6 +107,8 @@ function Index() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<CompletaProfilo giocatoreId={giocatore.id} indice={2} />
|
||||
|
||||
<Section titolo="Da confermare" indice={2}>
|
||||
<div className="space-y-3">
|
||||
{prossimi.slice(1).map((e) => {
|
||||
@@ -155,25 +159,29 @@ function Index() {
|
||||
titolo="Obiettivo di squadra"
|
||||
indice={4}
|
||||
azione={
|
||||
<Link to="/squadra" className="inline-flex items-center text-xs font-semibold text-accent">
|
||||
<Link
|
||||
to="/squadra"
|
||||
className="inline-flex items-center text-xs font-semibold text-accent"
|
||||
>
|
||||
Tutti <ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{obiettivo ? (
|
||||
<div className="premi rounded-3xl bg-card p-4 shadow-card">
|
||||
<div className="flex items-center gap-2 text-sm font-bold">
|
||||
<span className="text-base leading-none">{obiettivo.emoji}</span> {obiettivo.titolo}
|
||||
<div className="premi rounded-3xl bg-card p-4 shadow-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-[11px] text-muted-foreground">{obiettivo.impatto}</p>
|
||||
</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-[11px] text-muted-foreground">{obiettivo.impatto}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { ArrowLeft, MapPin, Clock, Users, Trophy, Swords, Download } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader, Section } from "@/components/crapp/ui-bits";
|
||||
import { storicoMatch, formatData, giocatori, isAdmin } from "@/lib/crapp-data";
|
||||
import { storicoMatch, formatData, giocatori } from "@/lib/crapp-data";
|
||||
import { convocatiEvento, useEvento } from "@/lib/eventi";
|
||||
import { Pagelle } from "@/components/crapp/Pagelle";
|
||||
import { SondaggioCacche } from "@/components/crapp/SondaggioCacche";
|
||||
import { useScoutMatches, totaliPerGiocatore, totaliSquadra } from "@/lib/scout-store";
|
||||
import { csvScoutMatch, scaricaCsv } from "@/lib/scout-export";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { VotazioneMvp } from "@/components/crapp/VotazioneMvp";
|
||||
import { VotoSocial } from "@/components/crapp/VotoSocial";
|
||||
import { TurnoPalloni } from "@/components/crapp/TurnoPalloni";
|
||||
@@ -36,6 +37,7 @@ function PartitaDetail() {
|
||||
const { id } = Route.useParams();
|
||||
const { evento } = useEvento(id);
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useIsAdmin();
|
||||
const scoutMatches = useScoutMatches();
|
||||
const { risposte } = usePresenzeEvento(id);
|
||||
const presentiVeri = giocatori.filter(
|
||||
@@ -224,7 +226,7 @@ function PartitaDetail() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{io && isAdmin(io.id) ? (
|
||||
{admin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scaricaCsv(`scout-${scout.data}-${scout.avversario}.csv`, csvScoutMatch(scout))}
|
||||
|
||||
+37
-2
@@ -1,13 +1,14 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Flame, Camera, Users, Trash2, Bell } from "lucide-react";
|
||||
import { Flame, Camera, Users, Trash2, Bell, LogOut, ShieldCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits";
|
||||
import { Avatar } from "@/components/crapp/Avatar";
|
||||
import { fileToAvatar, salvaAvatar, rimuoviAvatar, useAvatar } from "@/lib/avatar-store";
|
||||
import { SerieGriglia } from "@/components/crapp/SerieCard";
|
||||
import { CollezioneBadge } from "@/components/crapp/CollezioneBadge";
|
||||
import { ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
|
||||
import { useVotiSocial } from "@/lib/badge-social";
|
||||
import { useIo } from "@/lib/rosa";
|
||||
import { usePresenzeUltimoMese } from "@/lib/presenze-mese";
|
||||
@@ -18,6 +19,8 @@ import {
|
||||
statoNotifiche,
|
||||
} from "@/lib/push-client";
|
||||
import { resetGiocatore } from "@/lib/user-store";
|
||||
import { esci, useSessione } from "@/lib/auth";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { Reveal } from "@/components/motion/Reveal";
|
||||
|
||||
export const Route = createFileRoute("/profilo")({
|
||||
@@ -38,6 +41,8 @@ export const Route = createFileRoute("/profilo")({
|
||||
function Profilo() {
|
||||
const votiSocial = useVotiSocial();
|
||||
const g = useIo();
|
||||
const admin = useIsAdmin();
|
||||
const { sessione } = useSessione();
|
||||
const ultimoMese = usePresenzeUltimoMese(g?.id);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const foto = useAvatar(g?.id);
|
||||
@@ -72,6 +77,15 @@ function Profilo() {
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await esci();
|
||||
resetGiocatore();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Uscita non riuscita");
|
||||
}
|
||||
}
|
||||
|
||||
const percPresenze = Math.round((g.presenze / g.totaliEventi) * 100);
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
@@ -168,6 +182,8 @@ function Profilo() {
|
||||
<CollezioneBadge g={g} votiSocial={votiSocial.data ?? []} />
|
||||
</Section>
|
||||
|
||||
<ProfiloAmministrativo giocatoreId={g.id} indice={4} />
|
||||
|
||||
<Section titolo="Impostazioni">
|
||||
<div className="divide-y divide-border overflow-hidden rounded-3xl bg-card shadow-card">
|
||||
<button
|
||||
@@ -203,6 +219,15 @@ function Profilo() {
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
{admin ? (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="flex 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}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetGiocatore()}
|
||||
@@ -211,6 +236,16 @@ function Profilo() {
|
||||
<span className="min-w-0 truncate">Cambia giocatore</span>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
{sessione ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="flex 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">Esci</span>
|
||||
<LogOut className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
|
||||
@@ -3,9 +3,10 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Undo2, Save, CheckCircle2, Radio, Lock, CalendarX2, LogOut } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { giocatori, formatData, isAdmin } from "@/lib/crapp-data";
|
||||
import { giocatori, formatData } from "@/lib/crapp-data";
|
||||
import type { Evento } from "@/lib/eventi";
|
||||
import { useGiocatoreCorrente } from "@/lib/user-store";
|
||||
import { useIsAdmin } from "@/lib/ruoli";
|
||||
import { usePresenzeEvento } from "@/lib/presenze";
|
||||
import {
|
||||
statoIniziale,
|
||||
@@ -70,6 +71,7 @@ function Blocco({ icona, titolo, testo, children }: { icona: React.ReactNode; ti
|
||||
function Scout() {
|
||||
const { pronto, partita } = usePartitaDiOggi();
|
||||
const io = useGiocatoreCorrente();
|
||||
const admin = useIsAdmin();
|
||||
const sessione = useSessioneScout(partita?.id ?? null);
|
||||
const statoSalvato = useStatoScout(partita?.id ?? null);
|
||||
const apri = useApriSessioneScout();
|
||||
@@ -88,7 +90,7 @@ function Scout() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controllo, partita?.id, io?.id]);
|
||||
|
||||
if (io && !isAdmin(io.id)) {
|
||||
if (io && !admin) {
|
||||
return (
|
||||
<Blocco
|
||||
icona={<Lock className="h-6 w-6" />}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
main
|
||||
@@ -1 +1,22 @@
|
||||
project_id = "kfkcldwncxqaixetsjes"
|
||||
|
||||
# Configurazione dell'istanza locale (`supabase start`). Non tocca il progetto cloud:
|
||||
# lì le stesse impostazioni si mettono dalla dashboard Supabase.
|
||||
|
||||
[auth]
|
||||
site_url = "http://localhost:8080"
|
||||
additional_redirect_urls = ["http://localhost:8080"]
|
||||
|
||||
# Accesso via email con Mailpit (http://127.0.0.1:54324) per le prove locali:
|
||||
# nessuna mail esce dalla macchina e non serve confermare l'indirizzo.
|
||||
[auth.email]
|
||||
enable_signup = true
|
||||
enable_confirmations = false
|
||||
|
||||
# Google in locale: metti client id e secret in `.env` e porta `enabled` a true.
|
||||
# Il redirect da registrare in Google Cloud è http://127.0.0.1:54321/auth/v1/callback,
|
||||
# che convive con quello di produzione sulla stessa credenziale.
|
||||
[auth.external.google]
|
||||
enabled = false
|
||||
client_id = "env(SUPABASE_AUTH_GOOGLE_CLIENT_ID)"
|
||||
secret = "env(SUPABASE_AUTH_GOOGLE_SECRET)"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
-- M2 — Profilo Giocatore: dati personali, documento d'identità, certificato medico (DD-016)
|
||||
-- Migration additiva: solo CREATE, nessuna modifica alle tabelle v1.0 esistenti.
|
||||
-- I file non stanno qui: la tabella conserva solo i path dentro il bucket privato
|
||||
-- `profili-giocatore` creato dalla migration M3.
|
||||
|
||||
CREATE TABLE public.profili_giocatore (
|
||||
giocatore_id text PRIMARY KEY REFERENCES public.giocatori_squadra(id) ON DELETE CASCADE,
|
||||
|
||||
-- Dati personali richiesti dal tesseramento CSI
|
||||
data_nascita date,
|
||||
luogo_nascita text,
|
||||
indirizzo text,
|
||||
telefono text,
|
||||
email text,
|
||||
|
||||
-- Documento di identità
|
||||
documento_tipo text,
|
||||
documento_numero text,
|
||||
documento_rilasciato_da text,
|
||||
documento_emissione date,
|
||||
documento_scadenza date,
|
||||
-- Il documento si carica fronte e retro: il CSI li vuole entrambi.
|
||||
documento_fronte_path text,
|
||||
documento_retro_path text,
|
||||
|
||||
-- Certificato medico (storico non conservato in v1: DD-010)
|
||||
certificato_scadenza date,
|
||||
certificato_path text,
|
||||
|
||||
-- Foto tessera
|
||||
foto_path text,
|
||||
|
||||
creato_il timestamptz NOT NULL DEFAULT now(),
|
||||
aggiornato_il timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.profili_giocatore IS
|
||||
'Dati personali, documento e certificato di ciascun giocatore, 1:1 con giocatori_squadra. Vedi DD-016.';
|
||||
|
||||
CREATE TRIGGER update_profili_giocatore_aggiornato_il
|
||||
BEFORE UPDATE ON public.profili_giocatore
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.update_aggiornato_il();
|
||||
|
||||
ALTER TABLE public.profili_giocatore ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Il giocatore vede e modifica solo il proprio profilo: il collegamento passa
|
||||
-- da giocatori_squadra.auth_user_id, che solo un admin può riassegnare (M1).
|
||||
CREATE POLICY "Il giocatore legge il proprio profilo" ON public.profili_giocatore
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Il giocatore crea il proprio profilo" ON public.profili_giocatore
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Il giocatore aggiorna il proprio profilo" ON public.profili_giocatore
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = giocatore_id AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Gli admin leggono tutti i profili ed esportano i dati per il tesseramento.
|
||||
CREATE POLICY "Gli admin gestiscono tutti i profili" ON public.profili_giocatore
|
||||
FOR ALL TO authenticated
|
||||
USING (public.has_role(auth.uid(), 'admin'::public.app_role))
|
||||
WITH CHECK (public.has_role(auth.uid(), 'admin'::public.app_role));
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON public.profili_giocatore TO authenticated;
|
||||
GRANT ALL ON public.profili_giocatore TO service_role;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- M3 — Bucket privato per documenti, certificati e foto tessera (DD-016 regole 3 e 4)
|
||||
-- Migration additiva. Il bucket nasce privato e resta privato: documenti d'identità e
|
||||
-- dati sanitari non devono mai essere raggiungibili da un URL pubblico. L'accesso avviene
|
||||
-- solo con client autenticato o con signed URL a scadenza breve generata per gli admin.
|
||||
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('profili-giocatore', 'profili-giocatore', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Convenzione dei path: `<giocatore_id>/<sezione>.<estensione>` (es. `g4/certificato.pdf`).
|
||||
-- La prima cartella è l'ID del giocatore: è così che si riconosce il proprietario del file.
|
||||
CREATE POLICY "Il giocatore gestisce i propri file" ON storage.objects
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
bucket_id = 'profili-giocatore'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
bucket_id = 'profili-giocatore'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.giocatori_squadra g
|
||||
WHERE g.id = (storage.foldername(name))[1] AND g.auth_user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Gli admin scaricano i file di tutti, ma non li modificano: i documenti restano
|
||||
-- in mano al giocatore che li ha caricati.
|
||||
CREATE POLICY "Gli admin scaricano tutti i file dei profili" ON storage.objects
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
bucket_id = 'profili-giocatore'
|
||||
AND public.has_role(auth.uid(), 'admin'::public.app_role)
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Seed di sviluppo: gira solo in locale (`supabase start` e `supabase db reset`),
|
||||
-- mai in produzione. Serve a vedere la dashboard amministratore con dati realistici
|
||||
-- senza inserire righe finte nel database vero.
|
||||
--
|
||||
-- I path dei file puntano a oggetti che nel bucket non esistono: i pulsanti di download
|
||||
-- falliscono finché non carichi qualcosa dall'app o dallo Studio (http://127.0.0.1:54323).
|
||||
|
||||
INSERT INTO public.profili_giocatore
|
||||
(giocatore_id, data_nascita, luogo_nascita, indirizzo, telefono, email,
|
||||
documento_tipo, documento_numero, documento_rilasciato_da,
|
||||
documento_emissione, documento_scadenza, documento_fronte_path, documento_retro_path,
|
||||
certificato_scadenza, certificato_path, foto_path)
|
||||
VALUES
|
||||
-- Profilo completo al 100%.
|
||||
('g1', '1997-08-30', 'Bologna', 'Via Roma 1', '3330000001', 'g1@example.test',
|
||||
'Carta d''identità', 'CA1000001', 'Comune di Bologna',
|
||||
'2021-03-01', '2031-03-01', 'g1/documento-fronte.jpg', 'g1/documento-retro.jpg',
|
||||
'2027-06-30', 'g1/certificato.pdf', 'g1/foto.jpg'),
|
||||
|
||||
-- Certificato scaduto: in dashboard deve comparire rosso.
|
||||
('g4', '1995-05-01', 'Bologna', 'Via Verdi 2', '3330000004', 'g4@example.test',
|
||||
'Carta d''identità', 'CA1000004', 'Comune di Bologna',
|
||||
'2019-05-01', '2029-05-01', 'g4/documento-fronte.jpg', 'g4/documento-retro.jpg',
|
||||
'2025-01-01', 'g4/certificato.pdf', 'g4/foto.jpg'),
|
||||
|
||||
-- Profilo a metà: dati personali sì, documento no, certificato sì, foto no.
|
||||
('g2', '1996-12-07', 'Modena', 'Via Bianchi 3', '3330000002', 'g2@example.test',
|
||||
NULL, NULL, NULL, NULL, NULL, NULL, NULL,
|
||||
'2027-09-15', 'g2/certificato.pdf', NULL)
|
||||
ON CONFLICT (giocatore_id) DO NOTHING;
|
||||
|
||||
-- Il primo amministratore non si può seminare qui: `user_roles.user_id` punta a un utente
|
||||
-- di `auth.users`, che su un database appena creato non esiste ancora. Dopo il primo login
|
||||
-- (in locale come in produzione) basta una riga:
|
||||
--
|
||||
-- INSERT INTO public.user_roles (user_id, role)
|
||||
-- SELECT id, 'admin' FROM auth.users WHERE email = '<la tua mail>';
|
||||
+8
-1
@@ -20,7 +20,7 @@ non può inquinare gli altri.
|
||||
| Cartella | Cosa verifica | Serve rete? |
|
||||
|---|---|---|
|
||||
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa | No |
|
||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input | Sì |
|
||||
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato | Sì |
|
||||
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
|
||||
| `helpers/` | Avvio del server di test e mini-harness condiviso | — |
|
||||
|
||||
@@ -28,6 +28,13 @@ non può inquinare gli altri.
|
||||
|
||||
- **Nessun test scrive sul database.** Integration ed e2e fanno solo letture e
|
||||
verifiche di validazione: si possono lanciare anche contro l'ambiente reale.
|
||||
L'unica eccezione apparente è `schema-profili`, che *tenta* scritture da utente
|
||||
anonimo proprio per dimostrare che la RLS le respinge, e poi rilegge la riga per
|
||||
verificare che non sia cambiata: su un UPDATE a zero righe PostgREST risponde 2xx,
|
||||
quindi lo stato conta più del codice di risposta.
|
||||
- I test legati alle migration M2/M3 si **saltano da soli** dove quelle migration non
|
||||
sono ancora applicate, indicandolo nel motivo. Per vederli tutti verdi serve un
|
||||
database che le contenga: `npx supabase start` ne crea uno in locale.
|
||||
- `integration` ed `e2e` avviano da soli il server di sviluppo. Per usarne uno già
|
||||
attivo: `BASE_URL=http://localhost:8080 npm run test:e2e`.
|
||||
- Le variabili d'ambiente vengono lette da `.env`; i nomi senza prefisso
|
||||
|
||||
@@ -48,6 +48,7 @@ try {
|
||||
["/profilo", /Profilo|CrAPP/],
|
||||
["/eventi", /Eventi|CrAPP/],
|
||||
["/scout", /Scout|CrAPP/],
|
||||
["/admin", /Dashboard|CrAPP/],
|
||||
];
|
||||
for (const [percorso, atteso] of attesi) {
|
||||
assert.match(titolo(await pagina(percorso)), atteso, `${percorso}: titolo corretto`);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Schema e permessi del Profilo Giocatore: `bun test/integration/schema-profili.test.ts`.
|
||||
*
|
||||
* Verifica contro un database vero (locale con `npx supabase start`, oppure quello
|
||||
* configurato in `.env`) le tre cose che il codice dà per scontate: le colonne della
|
||||
* tabella, la chiusura verso l'utente anonimo e il bucket privato.
|
||||
*
|
||||
* Salta con un motivo esplicito quando mancano le credenziali o quando le migration
|
||||
* M2/M3 non sono ancora applicate a quel database: sono stati dell'ambiente, non difetti.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { COLONNE_PROFILO } from "@/lib/profili-core";
|
||||
import { envDaFile } from "../helpers/server";
|
||||
import { prova, riepilogo, salta } from "../helpers/prova";
|
||||
|
||||
const env = { ...envDaFile(), ...process.env };
|
||||
const URL_BASE = env["SUPABASE_URL"];
|
||||
const CHIAVE_SERVIZIO = env["SUPABASE_SERVICE_ROLE_KEY"];
|
||||
const CHIAVE_PUBBLICA = env["SUPABASE_PUBLISHABLE_KEY"];
|
||||
|
||||
/** Le chiavi nuove sono opache e viaggiano solo in `apikey`; quelle legacy sono JWT. */
|
||||
function intestazioni(chiave: string): Record<string, string> {
|
||||
const base: Record<string, string> = { apikey: chiave, "content-type": "application/json" };
|
||||
if (chiave.startsWith("eyJ")) base["Authorization"] = `Bearer ${chiave}`;
|
||||
return base;
|
||||
}
|
||||
|
||||
const rest = (percorso: string, chiave: string, init?: RequestInit) =>
|
||||
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
|
||||
...init,
|
||||
headers: { ...intestazioni(chiave), ...(init?.headers ?? {}) },
|
||||
});
|
||||
|
||||
const bucketProfili = (chiave: string) =>
|
||||
fetch(`${URL_BASE}/storage/v1/bucket/profili-giocatore`, { headers: intestazioni(chiave) });
|
||||
|
||||
console.log(`schema profili su ${URL_BASE ?? "(non configurato)"}`);
|
||||
|
||||
if (!URL_BASE || !CHIAVE_SERVIZIO || !CHIAVE_PUBBLICA) {
|
||||
salta("schema e permessi dei profili", "credenziali Supabase non configurate");
|
||||
riepilogo("schema profili");
|
||||
} else {
|
||||
try {
|
||||
// --- M1: anagrafica della squadra ------------------------------------------
|
||||
await prova("la rosa di M1 è popolata e collegabile agli account", async () => {
|
||||
const res = await rest(
|
||||
"giocatori_squadra?select=id,nome,cognome,auth_user_id,attivo",
|
||||
CHIAVE_SERVIZIO,
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const righe = (await res.json()) as Array<{ id: string }>;
|
||||
assert.ok(righe.length >= 17, `attesi almeno 17 giocatori, trovati ${righe.length}`);
|
||||
assert.ok(
|
||||
righe.every((r) => /^g[0-9]+$/.test(r.id)),
|
||||
"gli ID restano nel formato g1..gN (DD-012)",
|
||||
);
|
||||
});
|
||||
|
||||
// Attenzione al falso verde: su un UPDATE che non tocca nessuna riga PostgREST
|
||||
// risponde comunque 2xx. Quello che conta è che il dato non cambi.
|
||||
await prova("un anonimo non si collega a uno slot della rosa", async () => {
|
||||
const res = await rest("giocatori_squadra?id=eq.g1", CHIAVE_PUBBLICA, {
|
||||
method: "PATCH",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ auth_user_id: "00000000-0000-0000-0000-000000000000" }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const aggiornate = (await res.json()) as unknown[];
|
||||
assert.equal(aggiornate.length, 0, "nessuna riga aggiornata senza sessione");
|
||||
}
|
||||
const dopo = await rest("giocatori_squadra?id=eq.g1&select=auth_user_id", CHIAVE_SERVIZIO);
|
||||
const righe = (await dopo.json()) as Array<{ auth_user_id: string | null }>;
|
||||
assert.equal(righe[0]?.auth_user_id ?? null, null, "lo slot g1 è rimasto libero");
|
||||
});
|
||||
|
||||
// --- M2: tabella dei profili -----------------------------------------------
|
||||
const m2 = await rest("profili_giocatore?select=giocatore_id&limit=1", CHIAVE_SERVIZIO);
|
||||
if (!m2.ok) {
|
||||
salta("schema e permessi di profili_giocatore", "M2 non applicata (npx supabase db push)");
|
||||
} else {
|
||||
await prova("profili_giocatore espone tutte le colonne che il codice legge", async () => {
|
||||
const colonne = COLONNE_PROFILO.replace(/\s/g, "");
|
||||
const res = await rest(`profili_giocatore?select=${colonne}&limit=1`, CHIAVE_SERVIZIO);
|
||||
const corpo = await res.text();
|
||||
assert.equal(res.status, 200, corpo);
|
||||
assert.ok(Array.isArray(JSON.parse(corpo)));
|
||||
});
|
||||
|
||||
await prova("il documento ha due facciate separate", async () => {
|
||||
const res = await rest(
|
||||
"profili_giocatore?select=documento_fronte_path,documento_retro_path&limit=1",
|
||||
CHIAVE_SERVIZIO,
|
||||
);
|
||||
assert.equal(res.status, 200, "fronte e retro sono colonne distinte");
|
||||
});
|
||||
|
||||
// Il punto della RLS: senza sessione non si legge e non si scrive. È la garanzia su
|
||||
// cui si regge tutto il modulo, e a nessuno serve fidarsi della prosa.
|
||||
await prova("un anonimo non legge i profili", async () => {
|
||||
const res = await rest("profili_giocatore?select=giocatore_id", CHIAVE_PUBBLICA);
|
||||
if (res.ok) {
|
||||
const righe = (await res.json()) as unknown[];
|
||||
assert.equal(righe.length, 0, "nessuna riga visibile senza sessione");
|
||||
} else {
|
||||
assert.ok(res.status >= 400, `accesso negato (${res.status})`);
|
||||
}
|
||||
});
|
||||
|
||||
await prova("un anonimo non scrive i profili", async () => {
|
||||
const res = await rest("profili_giocatore", CHIAVE_PUBBLICA, {
|
||||
method: "POST",
|
||||
headers: { Prefer: "return=representation" },
|
||||
body: JSON.stringify({ giocatore_id: "g1", telefono: "000" }),
|
||||
});
|
||||
assert.ok(!res.ok, `la scrittura anonima deve fallire, invece ha risposto ${res.status}`);
|
||||
});
|
||||
}
|
||||
|
||||
// --- M3: bucket privato ------------------------------------------------------
|
||||
const m3 = await bucketProfili(CHIAVE_SERVIZIO);
|
||||
if (!m3.ok) {
|
||||
salta("bucket dei documenti", "M3 non applicata (npx supabase db push)");
|
||||
} else {
|
||||
await prova("il bucket dei documenti è privato", async () => {
|
||||
const bucket = (await m3.json()) as { public?: boolean };
|
||||
assert.equal(bucket.public, false, "documenti e certificati non sono mai pubblici");
|
||||
});
|
||||
|
||||
await prova("un anonimo non scarica i file dei profili", async () => {
|
||||
const res = await fetch(
|
||||
`${URL_BASE}/storage/v1/object/profili-giocatore/g1/certificato.pdf`,
|
||||
{ headers: intestazioni(CHIAVE_PUBBLICA) },
|
||||
);
|
||||
assert.ok(!res.ok, `nessun accesso anonimo allo storage (${res.status})`);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
riepilogo("schema profili");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/** Check dei profili giocatore: `bun test/unit/profili-core.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
aRigaProfilo,
|
||||
completamento,
|
||||
csvTesseramento,
|
||||
daRigaProfilo,
|
||||
sezioniComplete,
|
||||
statoScadenza,
|
||||
type Profilo,
|
||||
} from "@/lib/profili-core";
|
||||
import {
|
||||
dividiNome,
|
||||
numeroGiaUsato,
|
||||
rosaFallback,
|
||||
slotDi,
|
||||
slotLiberi,
|
||||
validaDatiSquadra,
|
||||
type GiocatoreSquadra,
|
||||
} from "@/lib/giocatori-squadra";
|
||||
|
||||
const vuoto: Profilo = {
|
||||
giocatoreId: "g1",
|
||||
dataNascita: null,
|
||||
luogoNascita: null,
|
||||
indirizzo: null,
|
||||
telefono: null,
|
||||
email: null,
|
||||
documentoTipo: null,
|
||||
documentoNumero: null,
|
||||
documentoRilasciatoDa: null,
|
||||
documentoEmissione: null,
|
||||
documentoScadenza: null,
|
||||
documentoFrontePath: null,
|
||||
documentoRetroPath: null,
|
||||
certificatoScadenza: null,
|
||||
certificatoPath: null,
|
||||
fotoPath: null,
|
||||
};
|
||||
|
||||
const completo: Profilo = {
|
||||
...vuoto,
|
||||
dataNascita: "1995-05-01",
|
||||
luogoNascita: "Bologna",
|
||||
indirizzo: "Via Roma 1",
|
||||
telefono: "3331234567",
|
||||
email: "ivan@example.com",
|
||||
documentoTipo: "Carta d'identità",
|
||||
documentoNumero: "CA12345",
|
||||
documentoRilasciatoDa: "Comune di Bologna",
|
||||
documentoEmissione: "2020-01-01",
|
||||
documentoScadenza: "2030-01-01",
|
||||
documentoFrontePath: "g1/documento-fronte.jpg",
|
||||
documentoRetroPath: "g1/documento-retro.jpg",
|
||||
certificatoScadenza: "2027-06-30",
|
||||
certificatoPath: "g1/certificato.pdf",
|
||||
fotoPath: "g1/foto.jpg",
|
||||
};
|
||||
|
||||
// --- completamento -----------------------------------------------------------
|
||||
assert.equal(completamento(null), 0, "profilo inesistente = 0%");
|
||||
assert.equal(completamento(vuoto), 0);
|
||||
assert.equal(completamento(completo), 100, "tutte le sezioni piene = 100%");
|
||||
assert.equal(completamento({ ...completo, fotoPath: null }), 90, "la foto pesa 10");
|
||||
assert.equal(completamento({ ...completo, certificatoPath: null }), 70, "il certificato pesa 30");
|
||||
assert.equal(
|
||||
completamento({ ...completo, email: null }),
|
||||
70,
|
||||
"i dati personali sono completi solo tutti insieme",
|
||||
);
|
||||
|
||||
// I metadati senza file (o viceversa) non contano come sezione completa.
|
||||
assert.equal(sezioniComplete({ ...completo, certificatoScadenza: null }).certificato, false);
|
||||
assert.equal(
|
||||
sezioniComplete({ ...completo, documentoRetroPath: null }).documento,
|
||||
false,
|
||||
"il documento vale solo con fronte e retro",
|
||||
);
|
||||
assert.equal(sezioniComplete({ ...completo, documentoFrontePath: null }).documento, false);
|
||||
|
||||
// --- aRigaProfilo ------------------------------------------------------------
|
||||
const riga = aRigaProfilo({ ...completo, luogoNascita: " ", telefono: " 333 " });
|
||||
assert.equal(riga.luogo_nascita, null, "i campi solo-spazi tornano NULL, non stringa vuota");
|
||||
assert.equal(riga.telefono, "333", "il resto viene ripulito ai bordi");
|
||||
assert.equal(riga.documento_fronte_path, "g1/documento-fronte.jpg");
|
||||
assert.deepEqual(
|
||||
daRigaProfilo(aRigaProfilo(completo)),
|
||||
completo,
|
||||
"modello -> riga -> modello non perde niente",
|
||||
);
|
||||
|
||||
// --- statoScadenza -----------------------------------------------------------
|
||||
const oggi = "2026-08-30";
|
||||
assert.equal(statoScadenza(null, null, oggi), "mancante");
|
||||
assert.equal(statoScadenza("2027-01-01", null, oggi), "mancante", "senza file non vale");
|
||||
assert.equal(statoScadenza(null, "g1/cert.pdf", oggi), "mancante", "senza data non vale");
|
||||
assert.equal(statoScadenza("2026-08-29", "g1/cert.pdf", oggi), "scaduto");
|
||||
assert.equal(
|
||||
statoScadenza("2026-08-30", "g1/cert.pdf", oggi),
|
||||
"valido",
|
||||
"scade oggi = ancora valido",
|
||||
);
|
||||
assert.equal(statoScadenza("2026-12-31", "g1/cert.pdf", oggi), "valido");
|
||||
|
||||
// --- csvTesseramento ---------------------------------------------------------
|
||||
const squadra: GiocatoreSquadra[] = [
|
||||
{
|
||||
id: "g1",
|
||||
nome: "Ivan",
|
||||
cognome: "Cacciari",
|
||||
numero: 23,
|
||||
ruolo: "Banda",
|
||||
authUserId: null,
|
||||
attivo: true,
|
||||
},
|
||||
{
|
||||
id: "g2",
|
||||
nome: "Anna",
|
||||
cognome: 'De "Rossi"',
|
||||
numero: 7,
|
||||
ruolo: "Libero",
|
||||
authUserId: "u2",
|
||||
attivo: true,
|
||||
},
|
||||
];
|
||||
const csv = csvTesseramento(squadra, { g1: completo });
|
||||
const righe = csv.split("\n");
|
||||
assert.equal(righe.length, 3, "intestazione + un giocatore per riga");
|
||||
assert.equal(righe[0]?.split(";").length, 12, "i 12 campi richiesti dal CSI");
|
||||
assert.match(righe[1] ?? "", /^Ivan;Cacciari;1995-05-01;Bologna/);
|
||||
assert.match(
|
||||
righe[2] ?? "",
|
||||
/^Anna;"De ""Rossi""";;;/,
|
||||
"chi non ha profilo esce con i campi vuoti",
|
||||
);
|
||||
|
||||
// --- anagrafica squadra ------------------------------------------------------
|
||||
assert.deepEqual(dividiNome("Ivan Cacciari"), { nome: "Ivan", cognome: "Cacciari" });
|
||||
assert.deepEqual(
|
||||
dividiNome("Carlo Di Castelnuovo"),
|
||||
{ nome: "Carlo", cognome: "Di Castelnuovo" },
|
||||
"il cognome composto resta intero",
|
||||
);
|
||||
assert.deepEqual(dividiNome("Ivan"), { nome: "Ivan", cognome: "" });
|
||||
|
||||
assert.equal(slotDi(squadra, null), null, "senza sessione nessuno slot");
|
||||
assert.equal(slotDi(squadra, "u2")?.id, "g2");
|
||||
assert.equal(slotDi(squadra, "sconosciuto"), null);
|
||||
assert.deepEqual(
|
||||
slotLiberi(squadra).map((g) => g.id),
|
||||
["g1"],
|
||||
"uno slot già collegato non è più libero",
|
||||
);
|
||||
assert.deepEqual(
|
||||
slotLiberi([...squadra, { ...squadra[0]!, id: "g3", attivo: false }]).map((g) => g.id),
|
||||
["g1"],
|
||||
"i giocatori non attivi restano fuori",
|
||||
);
|
||||
|
||||
// --- dati squadra modificabili dall'admin (DD-017) ---------------------------
|
||||
const datiOk = { nome: "Ivan", cognome: "Cacciari", numero: 23, ruolo: "Banda" };
|
||||
assert.equal(validaDatiSquadra(datiOk), null);
|
||||
assert.match(validaDatiSquadra({ ...datiOk, nome: " " }) ?? "", /nome/i);
|
||||
assert.match(validaDatiSquadra({ ...datiOk, cognome: "" }) ?? "", /cognome/i);
|
||||
assert.match(validaDatiSquadra({ ...datiOk, ruolo: " " }) ?? "", /ruolo/i);
|
||||
assert.match(
|
||||
validaDatiSquadra({ ...datiOk, numero: 0 }) ?? "",
|
||||
/numero/i,
|
||||
"il database rifiuta numero <= 0: meglio dirlo prima",
|
||||
);
|
||||
assert.match(validaDatiSquadra({ ...datiOk, numero: -3 }) ?? "", /numero/i);
|
||||
assert.match(validaDatiSquadra({ ...datiOk, numero: 1.5 }) ?? "", /numero/i);
|
||||
|
||||
assert.equal(numeroGiaUsato(squadra, "g1", 7), true, "il 7 è di g2");
|
||||
assert.equal(numeroGiaUsato(squadra, "g2", 7), false, "il proprio numero non è un conflitto");
|
||||
assert.equal(numeroGiaUsato(squadra, "g1", 99), false);
|
||||
assert.equal(
|
||||
numeroGiaUsato([{ ...squadra[1]!, attivo: false }], "g1", 7),
|
||||
false,
|
||||
"chi non è più in rosa non blocca il numero",
|
||||
);
|
||||
|
||||
const fallback = rosaFallback();
|
||||
assert.ok(fallback.length > 0, "il fallback da crapp-data non è mai vuoto");
|
||||
assert.ok(
|
||||
fallback.every((g) => g.authUserId === null && g.attivo),
|
||||
"il fallback non può collegare account",
|
||||
);
|
||||
|
||||
console.log("profili-core: ok");
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Check dei permessi di amministrazione: `bun test/unit/ruoli.test.ts`. */
|
||||
import assert from "node:assert/strict";
|
||||
import { risolviAdmin } from "@/lib/ruoli";
|
||||
import { adminNomi, giocatori } from "@/lib/crapp-data";
|
||||
|
||||
const referente = giocatori.find((g) => adminNomi.includes(g.nome))!;
|
||||
const chiunque = giocatori.find((g) => !adminNomi.includes(g.nome))!;
|
||||
|
||||
// Con una sessione attiva decide il database, e basta: la lista di nomi non conta più.
|
||||
assert.equal(risolviAdmin(true, chiunque.id), true, "il ruolo nel database concede");
|
||||
assert.equal(
|
||||
risolviAdmin(false, referente.id),
|
||||
false,
|
||||
"il ruolo nel database nega anche a chi è nella lista dei nomi",
|
||||
);
|
||||
assert.equal(risolviAdmin(true, null), true);
|
||||
|
||||
// Senza sessione (null) resta il ponte temporaneo sulla lista di nomi.
|
||||
assert.equal(risolviAdmin(null, referente.id), true);
|
||||
assert.equal(risolviAdmin(null, chiunque.id), false);
|
||||
assert.equal(risolviAdmin(null, null), false, "nessuna identità, nessun permesso");
|
||||
assert.equal(risolviAdmin(null, "gXX"), false, "un id sconosciuto non concede nulla");
|
||||
|
||||
console.log("ruoli: ok");
|
||||
Reference in New Issue
Block a user