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:
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user