2026-08-30 17:57:59 +02:00
|
|
|
import { rigaCsv } from "./scout-export";
|
2026-09-27 14:06:18 +02:00
|
|
|
import { inRosa, nomeCompleto, type GiocatoreSquadra } from "./giocatori-squadra";
|
2026-08-30 17:57:59 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 14:54:35 +02:00
|
|
|
/**
|
|
|
|
|
* L'allenatore compila solo i dati personali ridotti (DD-034): niente indirizzo, documento,
|
|
|
|
|
* certificato né foto tessera, che servono al tesseramento dei giocatori.
|
|
|
|
|
*/
|
|
|
|
|
export const CAMPI_ALLENATORE = ["dataNascita", "luogoNascita", "telefono", "email"] as const;
|
|
|
|
|
|
|
|
|
|
/** Percentuale dei campi dell'allenatore compilati: 100 quando ci sono tutti. */
|
|
|
|
|
export function completamentoAllenatore(p: Profilo | null | undefined): number {
|
|
|
|
|
const pieni = CAMPI_ALLENATORE.filter((c) => !!p?.[c]?.trim()).length;
|
|
|
|
|
return Math.round((pieni / CAMPI_ALLENATORE.length) * 100);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-30 17:57:59 +02:00
|
|
|
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)}`;
|
|
|
|
|
}
|
2026-09-27 14:06:18 +02:00
|
|
|
|
2026-10-01 14:21:25 +02:00
|
|
|
/** Soglia dell'avviso certificati dello staff in Home (DD-035). */
|
2026-09-27 14:06:18 +02:00
|
|
|
export const GIORNI_AVVISO_CERTIFICATO = 7;
|
|
|
|
|
|
2026-10-01 14:21:25 +02:00
|
|
|
/** Soglia dell'avviso personale: il giocatore è avvisato un mese prima dello staff (DD-041). */
|
|
|
|
|
export const GIORNI_AVVISO_CERTIFICATO_GIOCATORE = 30;
|
|
|
|
|
|
2026-09-27 14:06:18 +02:00
|
|
|
/** Giorni di calendario tra due date `AAAA-MM-GG` (ora locale): negativo se `scadenza` è passata. */
|
|
|
|
|
export function giorniAllaScadenza(scadenza: string, oggi: string): number {
|
|
|
|
|
const a = new Date(`${scadenza}T00:00:00`);
|
|
|
|
|
const b = new Date(`${oggi}T00:00:00`);
|
|
|
|
|
return Math.round((a.getTime() - b.getTime()) / 86_400_000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Data in formato `GG/MM/AAAA`, per i testi dell'avviso certificati. */
|
|
|
|
|
export function formatDataBreve(iso: string): string {
|
|
|
|
|
return new Date(`${iso}T00:00:00`).toLocaleDateString("it-IT", {
|
|
|
|
|
day: "2-digit",
|
|
|
|
|
month: "2-digit",
|
|
|
|
|
year: "numeric",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Riga dell'avviso certificati per un giocatore (DD-035). */
|
|
|
|
|
export function testoScadenza(giorni: number): string {
|
|
|
|
|
if (giorni === 0) return "scade oggi";
|
|
|
|
|
if (giorni === 1) return "scade domani";
|
|
|
|
|
return `scade tra ${giorni} giorni`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type AvvisoCertificato = {
|
|
|
|
|
giocatoreId: string;
|
|
|
|
|
nome: string;
|
|
|
|
|
cognome: string;
|
|
|
|
|
scadenza: string;
|
|
|
|
|
giorni: number;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function confrontaAvvisi(a: AvvisoCertificato, b: AvvisoCertificato): number {
|
|
|
|
|
return a.giorni - b.giorni || a.cognome.localeCompare(b.cognome) || a.nome.localeCompare(b.nome);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Certificati in scadenza o scaduti nella rosa (DD-035): calcolato al volo da
|
|
|
|
|
* `certificato_scadenza`, niente stato salvato. Esclude chi non è in rosa e chi non ha
|
|
|
|
|
* ancora un certificato caricato (è un problema diverso, non un avviso di scadenza).
|
2026-10-01 14:21:25 +02:00
|
|
|
* `soglia` sono i giorni di preavviso: 7 per lo staff, 30 per il giocatore (DD-041).
|
2026-09-27 14:06:18 +02:00
|
|
|
*/
|
|
|
|
|
export function avvisiCertificati(
|
|
|
|
|
rosa: GiocatoreSquadra[],
|
|
|
|
|
profili: Record<string, Profilo>,
|
|
|
|
|
oggi: string,
|
2026-10-01 14:21:25 +02:00
|
|
|
soglia: number = GIORNI_AVVISO_CERTIFICATO,
|
2026-09-27 14:06:18 +02:00
|
|
|
): { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } {
|
|
|
|
|
const scaduti: AvvisoCertificato[] = [];
|
|
|
|
|
const inScadenza: AvvisoCertificato[] = [];
|
|
|
|
|
for (const g of rosa) {
|
|
|
|
|
if (!inRosa(g)) continue;
|
|
|
|
|
const p = profili[g.id];
|
|
|
|
|
if (!p?.certificatoScadenza || !p.certificatoPath) continue;
|
|
|
|
|
const giorni = giorniAllaScadenza(p.certificatoScadenza, oggi);
|
2026-10-01 14:21:25 +02:00
|
|
|
if (giorni > soglia) continue;
|
2026-09-27 14:06:18 +02:00
|
|
|
const avviso: AvvisoCertificato = {
|
|
|
|
|
giocatoreId: g.id,
|
|
|
|
|
nome: g.nome,
|
|
|
|
|
cognome: g.cognome,
|
|
|
|
|
scadenza: p.certificatoScadenza,
|
|
|
|
|
giorni,
|
|
|
|
|
};
|
|
|
|
|
(giorni < 0 ? scaduti : inScadenza).push(avviso);
|
|
|
|
|
}
|
|
|
|
|
scaduti.sort(confrontaAvvisi);
|
|
|
|
|
inScadenza.sort(confrontaAvvisi);
|
|
|
|
|
return { scaduti, inScadenza };
|
|
|
|
|
}
|
2026-10-01 14:21:25 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Avvisi certificati per chi guarda la Home (DD-035, DD-041): `personale` è il proprio
|
|
|
|
|
* certificato (soglia 30 giorni), per chiunque sia un giocatore in rosa; `staff` sono gli
|
|
|
|
|
* altri della rosa (soglia 7 giorni), solo per l'admin, senza il suo nome perché già nel
|
|
|
|
|
* personale.
|
|
|
|
|
*/
|
|
|
|
|
export function avvisiCertificatiUtente(
|
|
|
|
|
rosa: GiocatoreSquadra[],
|
|
|
|
|
profili: Record<string, Profilo>,
|
|
|
|
|
oggi: string,
|
|
|
|
|
utente: { admin: boolean; base: GiocatoreSquadra | null },
|
|
|
|
|
): {
|
|
|
|
|
personale: AvvisoCertificato | undefined;
|
|
|
|
|
staff: { scaduti: AvvisoCertificato[]; inScadenza: AvvisoCertificato[] } | null;
|
|
|
|
|
} {
|
|
|
|
|
const mio = utente.base
|
|
|
|
|
? avvisiCertificati([utente.base], profili, oggi, GIORNI_AVVISO_CERTIFICATO_GIOCATORE)
|
|
|
|
|
: null;
|
|
|
|
|
const personale = mio ? (mio.scaduti[0] ?? mio.inScadenza[0]) : undefined;
|
|
|
|
|
const staff = utente.admin
|
|
|
|
|
? avvisiCertificati(
|
|
|
|
|
rosa.filter((g) => g.id !== utente.base?.id),
|
|
|
|
|
profili,
|
|
|
|
|
oggi,
|
|
|
|
|
)
|
|
|
|
|
: null;
|
|
|
|
|
return { personale, staff };
|
|
|
|
|
}
|