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
364 lines
11 KiB
TypeScript
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);
|
|
}
|