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>
206 lines
6.2 KiB
TypeScript
206 lines
6.2 KiB
TypeScript
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)}`;
|
|
}
|