Dashboard amministratore e profilo per il tesseramento

Implementa la voce "Dashboard amministratore" della roadmap v1.1, come descritta
in docs/modules/profilo-giocatore.md, insieme alla parte di profilo che la
alimenta.

- /admin: stato dei profili della squadra, download di documento, certificato e
  foto tessera, export CSV con i 12 campi del tesseramento CSI. Un certificato
  scaduto non conta come valido.
- Profilo giocatore: dati personali, documento (fronte e retro), certificato e
  foto tessera, con widget di completamento in Home che sparisce al 100%.
- I permessi di amministrazione arrivano da user_roles (DD-011) e non più dalla
  lista di nomi in crapp-data.ts, che resta come ponte finché
  VITE_AUTH_OBBLIGATORIA non viene acceso in produzione.
- Login Google via Supabase Auth: al primo accesso l'account si collega a uno
  slot libero di giocatori_squadra, e il vincolo lo fa rispettare il trigger di
  M1 (DD-016 regola 2).

Migration additive: M2 crea profili_giocatore, M3 il bucket privato
profili-giocatore. Nessuna tabella v1.0 viene toccata, quindi si possono
applicare senza cambiare il comportamento attuale dell'app.

supabase/config.toml e seed.sql configurano lo stack locale: serve perché il
progetto Supabase è uno solo, condiviso tra sviluppo e produzione.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 17:57:59 +02:00
co-authored by Claude Opus 5
parent ea29f053e3
commit da51517ffc
23 changed files with 1635 additions and 63 deletions
+61
View File
@@ -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;
}
+118
View File
@@ -0,0 +1,118 @@
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 };
}
/**
* 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,
),
);
},
});
}
+205
View File
@@ -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)}`;
}
+117
View File
@@ -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;
}
+44
View File
@@ -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
View File
@@ -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");
}