diff --git a/src/components/crapp/ProfiloAmministrativo.tsx b/src/components/crapp/ProfiloAmministrativo.tsx new file mode 100644 index 0000000..1c8ad91 --- /dev/null +++ b/src/components/crapp/ProfiloAmministrativo.tsx @@ -0,0 +1,370 @@ +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 } from "@/lib/profili-core"; + +const TIPI_DOCUMENTO = ["Carta d'identità", "Patente", "Passaporto"]; + +function Campo({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + {label} + + {children} + + ); +} + +const classiInput = "w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"; + +function CampoFile({ + label, + path, + sezione, + giocatoreId, + onCaricato, +}: { + label: string; + path: string | null; + sezione: SezioneFile; + giocatoreId: string; + onCaricato: (path: string) => Promise; +}) { + const input = useRef(null); + const [inCorso, setInCorso] = useState(false); + + async function scegli(e: React.ChangeEvent) { + 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 ( + + + + {path ? : } + + {label} + + + {path ? ( + void scaricaFile(path)} + className="premi rounded-xl bg-secondary p-2 text-muted-foreground" + aria-label={`Vedi ${label}`} + > + + + ) : null} + 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 ? : path ? "Sostituisci" : "Carica"} + + + + + ); +} + +/** + * 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(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) { + 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 ( + {perc}%} + > + + + + + + + Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro. + + + + + + aggiorna({ dataNascita: e.target.value })} + className={classiInput} + /> + + + aggiorna({ luogoNascita: e.target.value })} + className={classiInput} + /> + + + + aggiorna({ indirizzo: e.target.value })} + className={classiInput} + /> + + + + aggiorna({ telefono: e.target.value })} + className={classiInput} + /> + + + aggiorna({ email: e.target.value })} + className={classiInput} + /> + + + + + + + aggiorna({ documentoTipo: e.target.value })} + className={classiInput} + > + — + {TIPI_DOCUMENTO.map((t) => ( + + {t} + + ))} + + + + aggiorna({ documentoNumero: e.target.value })} + className={classiInput} + /> + + + + aggiorna({ documentoRilasciatoDa: e.target.value })} + className={classiInput} + /> + + + + aggiorna({ documentoEmissione: e.target.value })} + className={classiInput} + /> + + + aggiorna({ documentoScadenza: e.target.value })} + className={classiInput} + /> + + + + + + + + + + aggiorna({ certificatoScadenza: e.target.value })} + className={classiInput} + /> + + + + + + + + {salva.isPending ? : null} + {sporco ? "Salva" : "Salvato"} + + + + ); +} + +/** + * 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 ( + + + + + Completa il tuo profilo + + {perc}% + + + + + + Documento, certificato medico e foto tessera servono per il tesseramento CSI. + + + + ); +} + +function Intestazione({ titolo, completa }: { titolo: string; completa: boolean }) { + return ( + + {titolo} + {completa ? : null} + + ); +} diff --git a/src/components/crapp/RosaPresenze.tsx b/src/components/crapp/RosaPresenze.tsx index 8836039..e0df50a 100644 --- a/src/components/crapp/RosaPresenze.tsx +++ b/src/components/crapp/RosaPresenze.tsx @@ -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 }) { ) : null} - {io && isAdmin(io.id) ? ( + {admin ? ( (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 { + const { error } = await supabase.auth.signInWithOAuth({ + provider: "google", + options: { redirectTo: window.location.origin }, + }); + if (error) throw error; +} + +export async function esci(): Promise { + const { error } = await supabase.auth.signOut(); + if (error) throw error; +} diff --git a/src/lib/giocatori-squadra.ts b/src/lib/giocatori-squadra.ts new file mode 100644 index 0000000..ca16cc3 --- /dev/null +++ b/src/lib/giocatori-squadra.ts @@ -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 { + 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(SQUADRA_KEY, (prec) => + (prec ?? []).map((g) => + g.id === input.giocatoreId ? { ...g, authUserId: input.utenteId } : g, + ), + ); + }, + }); +} diff --git a/src/lib/profili-core.ts b/src/lib/profili-core.ts new file mode 100644 index 0000000..ae40c07 --- /dev/null +++ b/src/lib/profili-core.ts @@ -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 { + 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 { + 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)}`; +} diff --git a/src/lib/profili.ts b/src/lib/profili.ts new file mode 100644 index 0000000..e73d7ef --- /dev/null +++ b/src/lib/profili.ts @@ -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> { + const { data, error } = await supabaseNuoveTabelle + .from("profili_giocatore") + .select(COLONNE_PROFILO); + if (error) throw error; + const mappa: Record = {}; + 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 { + 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 { + 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>(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 { + 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 { + const { error } = await supabase.storage.from(BUCKET).remove([path]); + if (error) throw error; +} diff --git a/src/lib/ruoli.ts b/src/lib/ruoli.ts new file mode 100644 index 0000000..e37b816 --- /dev/null +++ b/src/lib/ruoli.ts @@ -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 { + 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); +} diff --git a/src/lib/scout-export.ts b/src/lib/scout-export.ts index 843d9ef..ae46931 100644 --- a/src/lib/scout-export.ts +++ b/src/lib/scout-export.ts @@ -1,7 +1,8 @@ import { giocatori } from "./crapp-data"; import { azioniMeta, totaliPerGiocatore, type ScoutMatch } from "./scout-store"; -function riga(campi: Array) { +/** Riga CSV con separatore ";" (Excel IT), riusata anche dall'export tesseramento. */ +export function rigaCsv(campi: Array) { return campi .map((c) => { const testo = String(c); @@ -13,24 +14,24 @@ function riga(campi: Array) { /** 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"); } diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index a7a0bda..3378673 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx new file mode 100644 index 0000000..7511cdc --- /dev/null +++ b/src/routes/admin.tsx @@ -0,0 +1,280 @@ +import { useState } from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { ChevronDown, Download, FileText, IdCard, Image, Loader2, Lock } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits"; +import { useGiocatoriSquadra, nomeCompleto } from "@/lib/giocatori-squadra"; +import { useProfili, scaricaFile } from "@/lib/profili"; +import { + completamento, + csvTesseramento, + 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 = { + 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", +}; + +function Riga({ etichetta, valore }: { etichetta: string; valore: string | null }) { + return ( + + {etichetta} + {valore || "—"} + + ); +} + +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 ( + + {inCorso ? : icona} + {label} + {path ? : null} + + ); +} + +function SchedaGiocatore({ + nome, + ruolo, + numero, + profilo, + oggi, + indice, +}: { + nome: string; + ruolo: string; + numero: number; + profilo: Profilo | undefined; + oggi: string; + indice: number; +}) { + const [aperta, setAperta] = useState(false); + 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 ( + + setAperta((v) => !v)} + className="flex w-full items-center gap-3 text-left" + > + + {numero} + + + {nome} + + {ruolo} · profilo {perc}% + + + + + + + + + + + } + label="Doc fronte" + stato={fronte} + path={profilo?.documentoFrontePath ?? null} + /> + } + label="Doc retro" + stato={retro} + path={profilo?.documentoRetroPath ?? null} + /> + } + label="Certificato" + stato={certificato} + path={profilo?.certificatoPath ?? null} + /> + } + label="Foto" + stato={sezioni.foto ? "presente" : "assente"} + path={profilo?.fotoPath ?? null} + /> + + + {aperta ? ( + + + + + + + + + + + + ) : null} + + ); +} + +function Dashboard() { + const admin = useIsAdmin(); + const { righe: squadra } = useGiocatoriSquadra(); + const { profili, isPending } = useProfili(); + const oggi = oggiISO(); + + if (!admin) { + return ( + <> + + + + + Riservata agli amministratori della squadra. + + + > + ); + } + + 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 ( + <> + + + + + + + + + + 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" + > + Esporta CSV tesseramento + + + + + {isPending ? ( + + Caricamento… + + ) : ( + + {attivi.map((g, i) => ( + + ))} + + )} + + > + ); +} diff --git a/src/routes/benvenuto.tsx b/src/routes/benvenuto.tsx index 865e66f..d4ab6cd 100644 --- a/src/routes/benvenuto.tsx +++ b/src/routes/benvenuto.tsx @@ -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 ( + + + {iniziali} + + + {titolo} + {sottotitolo} + + + ); +} + 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 ( @@ -38,29 +111,63 @@ function Benvenuto() { Benvenuto in CrAPP DEVELOP - - Seleziona chi sei per personalizzare l'app. - - - {giocatori.map((g) => ( + + {!pronta ? null : !utenteId ? ( + <> + + Accedi con il tuo account Google per collegare il profilo giocatore. + 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" > - - {g.iniziali} - - - {g.nome} - - #{g.numero} · {g.ruolo} - - + Accedi con Google - ))} - + + {authObbligatoria() ? null : ( + + + Oppure entra scegliendo il tuo nome, come prima. + + + {giocatori.map((g) => ( + impostaGiocatore(g.id)} + /> + ))} + + + )} + > + ) : ( + <> + + Sei entrato. Scegli il tuo nome: resterà collegato a questo account. + + + {liberi.map((g) => ( + void reclama(g)} + /> + ))} + {liberi.length === 0 ? ( + + Nessun profilo libero: chiedi a un amministratore di collegarti. + + ) : null} + + > + )} ); } diff --git a/src/routes/calendario.tsx b/src/routes/calendario.tsx index e25c52f..3a42a84 100644 --- a/src/routes/calendario.tsx +++ b/src/routes/calendario.tsx @@ -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(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() { ) : null} - {io && isAdmin(io.id) ? ( + {admin ? ( ({ @@ -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(null); - if (!io || !isAdmin(io.id)) { + if (!io || !admin) { return ( <> diff --git a/src/routes/index.tsx b/src/routes/index.tsx index ec8a27c..544b6cd 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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() { + + {prossimi.slice(1).map((e) => { @@ -155,25 +159,29 @@ function Index() { titolo="Obiettivo di squadra" indice={4} azione={ - + Tutti } > {obiettivo ? ( - - - {obiettivo.emoji} {obiettivo.titolo} + + + {obiettivo.emoji} {obiettivo.titolo} + + + + Siamo al —{" "} + {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}. + + + {microcopyObiettivo(obiettivo)} + + {obiettivo.impatto} - - - Siamo al —{" "} - {obiettivo.valore}/{obiettivo.target}{" "} - {obiettivo.unita}. - - {microcopyObiettivo(obiettivo)} - {obiettivo.impatto} - ) : null} diff --git a/src/routes/partita.$id.tsx b/src/routes/partita.$id.tsx index 57b258d..2dc6574 100644 --- a/src/routes/partita.$id.tsx +++ b/src/routes/partita.$id.tsx @@ -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() { ); })} - {io && isAdmin(io.id) ? ( + {admin ? ( scaricaCsv(`scout-${scout.data}-${scout.avversario}.csv`, csvScoutMatch(scout))} diff --git a/src/routes/profilo.tsx b/src/routes/profilo.tsx index 29a05e5..91fafa2 100644 --- a/src/routes/profilo.tsx +++ b/src/routes/profilo.tsx @@ -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(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) { @@ -168,6 +182,8 @@ function Profilo() { + + ), )} + {admin ? ( + + Dashboard amministratore + + + ) : null} resetGiocatore()} @@ -211,6 +236,16 @@ function Profilo() { Cambia giocatore + {sessione ? ( + + Esci + + + ) : null} > diff --git a/src/routes/scout.tsx b/src/routes/scout.tsx index 6aaeda0..f34ac4e 100644 --- a/src/routes/scout.tsx +++ b/src/routes/scout.tsx @@ -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 ( } diff --git a/supabase/config.toml b/supabase/config.toml index e0800f3..30be195 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -1 +1,22 @@ -project_id = "kfkcldwncxqaixetsjes" \ No newline at end of file +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)" diff --git a/supabase/migrations/20260830120000_m2_profili_giocatore.sql b/supabase/migrations/20260830120000_m2_profili_giocatore.sql new file mode 100644 index 0000000..97061bb --- /dev/null +++ b/supabase/migrations/20260830120000_m2_profili_giocatore.sql @@ -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; diff --git a/supabase/migrations/20260830120100_m3_bucket_profili.sql b/supabase/migrations/20260830120100_m3_bucket_profili.sql new file mode 100644 index 0000000..4a26afd --- /dev/null +++ b/supabase/migrations/20260830120100_m3_bucket_profili.sql @@ -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: `/.` (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) + ); diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..0048ecd --- /dev/null +++ b/supabase/seed.sql @@ -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 = '';
+ Servono agli amministratori per il tesseramento CSI. Li vedi solo tu e loro. +
+ Documento, certificato medico e foto tessera servono per il tesseramento CSI. +
{nome}
+ {ruolo} · profilo {perc}% +
+ + Riservata agli amministratori della squadra. +
+ Caricamento… +
{titolo}
{sottotitolo}
- Seleziona chi sei per personalizzare l'app. -
+ Accedi con il tuo account Google per collegare il profilo giocatore. +
{g.nome}
- #{g.numero} · {g.ruolo} -
+ Oppure entra scegliendo il tuo nome, come prima. +
+ Sei entrato. Scegli il tuo nome: resterà collegato a questo account. +
+ Nessun profilo libero: chiedi a un amministratore di collegarti. +
+ Siamo al —{" "} + {obiettivo.valore}/{obiettivo.target} {obiettivo.unita}. +
+ {microcopyObiettivo(obiettivo)} +
{obiettivo.impatto}
- Siamo al —{" "} - {obiettivo.valore}/{obiettivo.target}{" "} - {obiettivo.unita}. -
{microcopyObiettivo(obiettivo)}