espace-paie-odentas/lib/dateFormatter.ts
odentas c2a54ecd89 fix: Utiliser l'année réelle sélectionnée dans le calendrier au lieu de supposer
Problème: Le système supposait l'année basée sur le contexte, ce qui causait des erreurs.

Solution:
- Ajout de formatDateFrWithYear() pour retourner DD/MM/YYYY
- Le calendrier DatePickerCalendar retourne maintenant des dates avec l'année complète
- parseFrenchedDate() supporte maintenant DD/MM/YYYY et DD/MM
- Si l'année est dans la date (DD/MM/YYYY), elle est utilisée directement
- Plus besoin de supposer l'année basée sur oct/nov/déc -> jan/fev/mar
- Les dates de début et fin se calculent correctement à partir des vraies dates ISO

Cela garantit que:
1. Les dates en janvier 2026 restent bien en 2026
2. Les champs date début/date fin se remplissent avec les bonnes années
3. Aucune supposition erronée n'est faite
2025-12-19 17:58:33 +01:00

364 lines
11 KiB
TypeScript

/**
* dateFormatter.ts
*
* Utilities pour parser, formatter et grouper les dates de travail
* Format input/storage: "12/10, 13/10, 14/10, 24/10"
* Format affichage: "le 12/10 ; du 14/10 au 17/10."
*/
// Types
export interface DateGroup {
type: "single" | "range";
displayFr: string; // "le 12/10" ou "du 14/10 au 17/10"
startIso: string; // "2025-10-14"
endIso?: string; // "2025-10-17"
}
export interface ParseResult {
groups: DateGroup[];
hasMultiMonth: boolean;
pdfFormatted: string;
allDatesFr: string[]; // Pour affichage inline: ["12/10", "13/10", ...]
}
/**
* Formate une date ISO (YYYY-MM-DD) en format français (DD/MM)
*/
export function formatDateFr(isoStr: string): string {
if (!isoStr || isoStr.length < 10) return "";
const [year, month, day] = isoStr.split("-");
return `${day}/${month}`;
}
/**
* Formate une date ISO (YYYY-MM-DD) en format français avec année (DD/MM/YYYY)
*/
export function formatDateFrWithYear(isoStr: string): string {
if (!isoStr || isoStr.length < 10) return "";
const [year, month, day] = isoStr.split("-");
return `${day}/${month}/${year}`;
}
/**
* Convertit une date française (DD/MM ou DD/MM/YYYY) en ISO (YYYY-MM-DD)
* Si l'année est fournie dans frStr, elle est utilisée
* Sinon, nécessite l'année de contexte
*/
export function parseFrenchedDate(frStr: string, yearContext: string): string {
const cleaned = frStr.trim();
if (!cleaned || !cleaned.includes("/")) return "";
const parts = cleaned.split("/");
// Format DD/MM/YYYY
if (parts.length === 3) {
const [day, month, year] = parts;
if (!day || !month || !year) return "";
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
}
// Format DD/MM - utiliser le contexte
const [day, month] = parts;
if (!day || !month) return "";
const year = yearContext.split("-")[0];
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
}
/**
* Vérifie si deux dates ISO consécutives sont à exactement 1 jour d'intervalle
*/
function isConsecutiveDay(isoStr1: string, isoStr2: string): boolean {
const date1 = new Date(isoStr1 + "T00:00:00Z");
const date2 = new Date(isoStr2 + "T00:00:00Z");
const diffMs = date2.getTime() - date1.getTime();
const diffDays = diffMs / (1000 * 60 * 60 * 24);
return Math.abs(diffDays - 1) < 0.01; // tolérance pour fuseaux horaires
}
/**
* Génère toutes les dates ISO entre startIso et endIso (inclus)
*/
function generateDateRange(startIso: string, endIso: string): string[] {
const dates: string[] = [];
const current = new Date(startIso + "T00:00:00Z");
const end = new Date(endIso + "T00:00:00Z");
while (current <= end) {
dates.push(current.toISOString().slice(0, 10));
current.setUTCDate(current.getUTCDate() + 1);
}
return dates;
}
// Exporter generateDateRange pour usage externe
export { generateDateRange };
/**
* Format des dates quantifiées.
* Prend une liste de dates ISO (YYYY-MM-DD) et une map { iso: quantity }
* Retourne une chaîne formatée pour PDFMonkey en regroupant les dates
* consécutives qui ont la même quantité. Exemple:
* "1 représentation le 12/10 ; 1 représentation par jour du 13/10 au 16/10 ; 3 heures le 13/10."
*/
export function formatQuantifiedDates(
isos: string[],
quantities: Record<string, number | string>,
dateType: "representations" | "repetitions" | "jours_travail" | "heures_repetitions",
repetitionDuration?: "3" | "4"
): string {
if (!isos || isos.length === 0) return "";
// Trier les dates
const sorted = Array.from(new Set(isos)).sort();
// Construire tableau { iso, qty }
const items = sorted.map((iso) => ({ iso, qty: quantities[iso] ?? quantities[formatDateFr(iso)] ?? 0 }));
// Grouper consécutifs avec même qty
const groups: Array<{ startIso: string; endIso?: string; qty: number | string } > = [];
for (let i = 0; i < items.length; i++) {
const cur = items[i];
const prev = groups[groups.length - 1];
if (!prev) {
groups.push({ startIso: cur.iso, endIso: cur.iso, qty: cur.qty });
continue;
}
const isConsec = isConsecutiveDay(prev.endIso!, cur.iso);
const sameQty = String(prev.qty) === String(cur.qty);
if (isConsec && sameQty) {
prev.endIso = cur.iso;
} else {
groups.push({ startIso: cur.iso, endIso: cur.iso, qty: cur.qty });
}
}
// Construire le texte
const parts: string[] = groups.map((g) => {
const qty = g.qty;
const qtyNum = typeof qty === 'number' ? qty : (parseInt(String(qty)) || 0);
// déterminer le libellé de base
let base = '';
if (dateType === 'representations') {
base = qtyNum > 1 ? 'représentations' : 'représentation';
} else if (dateType === 'repetitions') {
// Pour les répétitions, ajouter la durée si disponible
if (repetitionDuration) {
if (qtyNum > 1) {
base = `services de répétition de ${repetitionDuration} heures chacun`;
} else {
base = `service de répétition de ${repetitionDuration} heures`;
}
} else {
base = qtyNum > 1 ? 'services de répétition' : 'service de répétition';
}
} else if (dateType === 'jours_travail' || dateType === 'heures_repetitions') {
base = qtyNum > 1 ? 'heures' : 'heure';
}
if (g.startIso === g.endIso) {
// date isolée
return `${qty} ${base} le ${formatDateFr(g.startIso)}`;
}
// plage: utiliser "par jour" pour indiquer par-journée
if (dateType === 'jours_travail' || dateType === 'heures_repetitions') {
return `${qty} ${base} par jour du ${formatDateFr(g.startIso)} au ${formatDateFr(g.endIso!)}`;
}
// representations / repetitions -> "X base par jour du A au B"
return `${qty} ${base} par jour du ${formatDateFr(g.startIso)} au ${formatDateFr(g.endIso!)}`;
});
return parts.join(' ; ') + '.';
}
/**
* Parse une chaîne de dates en français (DD/MM) séparées par des virgules
* Retourne des groupes (dates isolées ou plages consécutives)
*
* @param dateStr ex: "12/10, 13/10, 14/10, 24/10"
* @param yearContext ex: "2025-10-12" (pour extraire l'année)
* @returns ParseResult avec groupes, détection multi-mois et formatages
*/
export function parseDateString(
dateStr: string,
yearContext: string
): ParseResult {
if (!dateStr || !yearContext) {
return {
groups: [],
hasMultiMonth: false,
pdfFormatted: "",
allDatesFr: [],
};
}
// 1. Parser chaque date en ISO
const isoArray = dateStr
.split(",")
.map(d => d.trim())
.filter(Boolean)
.map(d => parseFrenchedDate(d, yearContext))
.filter(iso => iso.length === 10) // Valider format YYYY-MM-DD
.sort(); // Trier chronologiquement
// Dédupliquer
const uniqueIsos = [...new Set(isoArray)];
if (uniqueIsos.length === 0) {
return {
groups: [],
hasMultiMonth: false,
pdfFormatted: "",
allDatesFr: [],
};
}
// 2. Grouper les dates consécutives
const groups: DateGroup[] = [];
let i = 0;
while (i < uniqueIsos.length) {
const startIso = uniqueIsos[i];
let endIso = startIso;
// Trouver la fin de la plage consécutive
while (i + 1 < uniqueIsos.length && isConsecutiveDay(uniqueIsos[i], uniqueIsos[i + 1])) {
endIso = uniqueIsos[i + 1];
i++;
}
// Créer le groupe
if (startIso === endIso) {
groups.push({
type: "single",
displayFr: `le ${formatDateFr(startIso)}`,
startIso,
});
} else {
groups.push({
type: "range",
displayFr: `du ${formatDateFr(startIso)} au ${formatDateFr(endIso)}`,
startIso,
endIso,
});
}
i++;
}
// 3. Formater pour PDFMonkey
const pdfFormatted = groups.map(g => g.displayFr).join(" ; ") + ".";
// 4. Déterminer multi-mois
const months = new Set(uniqueIsos.map(iso => iso.slice(0, 7))); // ["2025-10", "2025-11", ...]
const hasMultiMonth = months.size > 1;
// 5. Générer array de toutes les dates en français
const allDatesFr = uniqueIsos.map(iso => formatDateFr(iso));
return {
groups,
hasMultiMonth,
pdfFormatted,
allDatesFr,
};
}
/**
* Convertit les groupes en format input/storage (pour Supabase)
* Ex: ["12/10", "13/10", "14/10", "24/10"]
*/
export function formatGroupsToInput(groups: DateGroup[]): string {
const allDates: string[] = [];
for (const group of groups) {
if (group.type === "single") {
allDates.push(formatDateFr(group.startIso));
} else if (group.type === "range" && group.endIso) {
// Générer toutes les dates dans la plage
const rangeDates = generateDateRange(group.startIso, group.endIso);
allDates.push(...rangeDates.map(iso => formatDateFr(iso)));
}
}
return allDates.join(", ");
}
/**
* Convertit les groupes en format affichage lisible pour PDFMonkey
* Ex: "le 12/10 ; du 13/10 au 14/10 ; le 24/10."
*/
export function formatGroupsToPDFMonkey(groups: DateGroup[]): string {
if (groups.length === 0) return "";
return groups.map(g => g.displayFr).join(" ; ") + ".";
}
/**
* Convertit un array de dates ISO en DateGroup[]
* Utile pour le calendrier qui sélectionne les dates en ISO
*/
export function convertIsoDatesToGroups(isoDates: string[]): DateGroup[] {
if (!isoDates || isoDates.length === 0) return [];
const sorted = [...isoDates].sort();
const groups: DateGroup[] = [];
let i = 0;
while (i < sorted.length) {
const startIso = sorted[i];
let endIso = startIso;
// Trouver la fin de la plage consécutive
while (i + 1 < sorted.length && isConsecutiveDay(sorted[i], sorted[i + 1])) {
endIso = sorted[i + 1];
i++;
}
// Créer le groupe
if (startIso === endIso) {
groups.push({
type: "single",
displayFr: `le ${formatDateFr(startIso)}`,
startIso,
});
} else {
groups.push({
type: "range",
displayFr: `du ${formatDateFr(startIso)} au ${formatDateFr(endIso)}`,
startIso,
endIso,
});
}
i++;
}
return groups;
}
/**
* Détecte si les dates couvrent plusieurs mois
*/
export function hasMultipleMoons(isoDates: string[]): boolean {
if (!isoDates || isoDates.length === 0) return false;
const months = new Set(isoDates.map(iso => iso.slice(0, 7)));
return months.size > 1;
}
/**
* Récupère le mois/année d'une date ISO
* Ex: "2025-10-12" → "2025-10"
*/
export function getMonthYear(isoStr: string): string {
return isoStr.slice(0, 7);
}
/**
* Récupère toutes les dates d'une plage consécutive (pour le calendrier)
*/
export function expandDateRange(startIso: string, endIso: string): string[] {
return generateDateRange(startIso, endIso);
}