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