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
361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useRef } from "react";
|
|
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
|
import {
|
|
parseDateString,
|
|
formatGroupsToInput,
|
|
formatGroupsToPDFMonkey,
|
|
hasMultipleMoons,
|
|
convertIsoDatesToGroups,
|
|
formatDateFr,
|
|
formatDateFrWithYear,
|
|
parseFrenchedDate,
|
|
} from "@/lib/dateFormatter";
|
|
|
|
interface DatePickerCalendarProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onApply: (result: {
|
|
selectedDates: string[]; // format input "12/10, 13/10, ..."
|
|
hasMultiMonth: boolean;
|
|
pdfFormatted: string; // "le 12/10 ; du 14/10 au 17/10."
|
|
}) => void;
|
|
initialDates?: string[]; // format input "12/10, 13/10, ..."
|
|
title: string;
|
|
minDate?: string; // "2025-10-01"
|
|
maxDate?: string; // "2025-10-31"
|
|
}
|
|
|
|
export default function DatePickerCalendar({
|
|
isOpen,
|
|
onClose,
|
|
onApply,
|
|
initialDates = [],
|
|
title,
|
|
minDate,
|
|
maxDate,
|
|
}: DatePickerCalendarProps) {
|
|
const [selectedIsos, setSelectedIsos] = useState<string[]>([]);
|
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
const prevIsOpenRef = useRef(isOpen);
|
|
|
|
// Initialiser avec les dates initiales
|
|
useEffect(() => {
|
|
// Vérifier si la modale vient de s'ouvrir (transition de false à true)
|
|
const justOpened = isOpen && !prevIsOpenRef.current;
|
|
prevIsOpenRef.current = isOpen;
|
|
|
|
if (justOpened) {
|
|
if (initialDates.length > 0) {
|
|
// Convertir format input "12/10" en ISO
|
|
// On utilise le minDate ou maxDate comme contexte d'année
|
|
const yearContext = minDate || maxDate || new Date().toISOString().slice(0, 10);
|
|
const isos = initialDates
|
|
.map(d => parseFrenchedDate(d.trim(), yearContext))
|
|
.filter(iso => iso.length === 10);
|
|
setSelectedIsos(isos);
|
|
|
|
// Mettre le calendrier sur le premier mois
|
|
if (isos.length > 0) {
|
|
const firstDate = new Date(isos[0] + "T00:00:00Z");
|
|
setCurrentMonth(firstDate);
|
|
}
|
|
} else {
|
|
// Réinitialiser les sélections si pas de dates initiales
|
|
setSelectedIsos([]);
|
|
if (minDate) {
|
|
// Si pas de dates initiales, utiliser la date de début du contrat
|
|
const startDate = new Date(minDate + "T00:00:00Z");
|
|
setCurrentMonth(startDate);
|
|
}
|
|
}
|
|
}
|
|
}, [isOpen, initialDates, minDate, maxDate]);
|
|
|
|
// Initialiser la position au centre
|
|
useEffect(() => {
|
|
if (isOpen && position === null) {
|
|
const centerX = (typeof window !== "undefined" ? window.innerWidth : 1024) - 450;
|
|
const centerY = Math.max(20, ((typeof window !== "undefined" ? window.innerHeight : 768) - 500) * 0.4);
|
|
setPosition({ x: Math.max(20, centerX), y: centerY });
|
|
}
|
|
}, [isOpen, position]);
|
|
|
|
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!position || !modalRef.current) return;
|
|
setIsDragging(true);
|
|
setDragOffset({
|
|
x: e.clientX - position.x,
|
|
y: e.clientY - position.y,
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!isDragging || !position) return;
|
|
setPosition({
|
|
x: e.clientX - dragOffset.x,
|
|
y: e.clientY - dragOffset.y,
|
|
});
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
};
|
|
|
|
if (isDragging) {
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
}, [isDragging, dragOffset, position]);
|
|
|
|
const handleDateClick = (day: number) => {
|
|
// Créer la date en UTC pour éviter les décalages de fuseau horaire
|
|
const isoStr = new Date(Date.UTC(currentMonth.getFullYear(), currentMonth.getMonth(), day))
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
|
|
if (isDateOutOfRange(isoStr)) return;
|
|
|
|
setSelectedIsos(prev => {
|
|
if (prev.includes(isoStr)) {
|
|
return prev.filter(d => d !== isoStr);
|
|
} else {
|
|
return [...prev, isoStr].sort();
|
|
}
|
|
});
|
|
};
|
|
|
|
const isDateOutOfRange = (isoStr: string): boolean => {
|
|
if (minDate && isoStr < minDate) return true;
|
|
if (maxDate && isoStr > maxDate) return true;
|
|
return false;
|
|
};
|
|
|
|
const isDateSelected = (day: number): boolean => {
|
|
// Créer la date en UTC
|
|
const isoStr = new Date(Date.UTC(currentMonth.getFullYear(), currentMonth.getMonth(), day))
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
return selectedIsos.includes(isoStr);
|
|
};
|
|
|
|
const isDateDisabled = (day: number): boolean => {
|
|
// Créer la date en UTC
|
|
const isoStr = new Date(Date.UTC(currentMonth.getFullYear(), currentMonth.getMonth(), day))
|
|
.toISOString()
|
|
.slice(0, 10);
|
|
return isDateOutOfRange(isoStr);
|
|
};
|
|
|
|
const getDaysInMonth = (date: Date) => {
|
|
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
|
};
|
|
|
|
const getFirstDayOfMonth = (date: Date) => {
|
|
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
|
};
|
|
|
|
const renderCalendar = () => {
|
|
const daysInMonth = getDaysInMonth(currentMonth);
|
|
const firstDay = getFirstDayOfMonth(currentMonth);
|
|
const days: (number | null)[] = Array(firstDay === 0 ? 6 : firstDay - 1).fill(null);
|
|
|
|
for (let i = 1; i <= daysInMonth; i++) {
|
|
days.push(i);
|
|
}
|
|
|
|
return days;
|
|
};
|
|
|
|
const handleApply = () => {
|
|
if (selectedIsos.length === 0) {
|
|
onApply({
|
|
selectedDates: [],
|
|
hasMultiMonth: false,
|
|
pdfFormatted: "",
|
|
});
|
|
onClose();
|
|
return;
|
|
}
|
|
|
|
// Convertir en format input avec année (DD/MM/YYYY)
|
|
const inputFormat = selectedIsos.map(iso => formatDateFrWithYear(iso)).join(", ");
|
|
|
|
// Détecter multi-mois
|
|
const isMultiMonth = hasMultipleMoons(selectedIsos);
|
|
|
|
// Générer les groupes et formatter pour PDFMonkey
|
|
const groups = convertIsoDatesToGroups(selectedIsos);
|
|
const pdfFormatted = groups.map(g => g.displayFr).join(" ; ") + ".";
|
|
|
|
onApply({
|
|
selectedDates: inputFormat.split(", "),
|
|
hasMultiMonth: isMultiMonth,
|
|
pdfFormatted,
|
|
});
|
|
|
|
onClose();
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setSelectedIsos([]);
|
|
};
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const monthName = currentMonth.toLocaleDateString("fr-FR", { month: "long", year: "numeric" });
|
|
const daysArray = renderCalendar();
|
|
const dayNames = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
|
|
|
|
return (
|
|
<>
|
|
{/* Overlay semi-transparent */}
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-[100]"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}}
|
|
/>
|
|
|
|
{/* Modale draggable */}
|
|
<div
|
|
ref={modalRef}
|
|
className="fixed z-[101] w-[420px] bg-white rounded-2xl shadow-2xl flex flex-col overflow-hidden"
|
|
style={{
|
|
left: position?.x || 0,
|
|
top: position?.y || 0,
|
|
cursor: isDragging ? "grabbing" : "grab",
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header draggable */}
|
|
<div
|
|
onMouseDown={handleMouseDown}
|
|
className="flex items-center justify-between p-4 bg-gradient-to-r from-indigo-50 to-purple-50 border-b cursor-grab active:cursor-grabbing"
|
|
>
|
|
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1 hover:bg-white/50 rounded-lg transition"
|
|
>
|
|
<X className="w-5 h-5 text-slate-500" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Contenu */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
{/* Navigation mois */}
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}
|
|
className="p-2 hover:bg-slate-100 rounded-lg transition"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
<span className="text-sm font-semibold text-slate-700 capitalize">{monthName}</span>
|
|
<button
|
|
onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}
|
|
className="p-2 hover:bg-slate-100 rounded-lg transition"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Calendrier */}
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{/* Headers des jours */}
|
|
{dayNames.map(day => (
|
|
<div key={day} className="text-center text-xs font-semibold text-slate-500 py-2">
|
|
{day}
|
|
</div>
|
|
))}
|
|
|
|
{/* Jours */}
|
|
{daysArray.map((day, idx) => (
|
|
<div key={idx}>
|
|
{day === null ? (
|
|
<div />
|
|
) : (
|
|
<button
|
|
onClick={() => handleDateClick(day)}
|
|
disabled={isDateDisabled(day)}
|
|
className={`
|
|
w-full aspect-square rounded-lg text-sm font-medium transition
|
|
${isDateDisabled(day)
|
|
? "bg-slate-100 text-slate-300 cursor-not-allowed"
|
|
: isDateSelected(day)
|
|
? "bg-indigo-600 text-white hover:bg-indigo-700"
|
|
: "bg-white text-slate-700 hover:bg-indigo-50 border border-slate-200"
|
|
}
|
|
`}
|
|
>
|
|
{day}
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Sélection actuelle */}
|
|
{selectedIsos.length > 0 && (
|
|
<div className="bg-slate-50 rounded-lg p-3 border border-slate-200">
|
|
<p className="text-xs font-semibold text-slate-600 mb-2">Dates sélectionnées :</p>
|
|
<div className="text-sm text-slate-700 leading-relaxed">
|
|
{selectedIsos.map(iso => formatDateFr(iso)).join(", ")}
|
|
</div>
|
|
{hasMultipleMoons(selectedIsos) && (
|
|
<p className="text-xs text-amber-700 mt-2 bg-amber-50 p-2 rounded border border-amber-200">
|
|
Attention : multi-mois détecté (le champ sera auto-set à "Oui")
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
|
|
{/* Footer avec boutons */}
|
|
<div className="border-t p-4 bg-slate-50 flex items-center gap-2">
|
|
{selectedIsos.length > 0 && (
|
|
<button
|
|
onClick={handleReset}
|
|
className="px-3 py-2 text-sm rounded-lg border border-slate-300 text-slate-700 hover:bg-slate-100 transition"
|
|
>
|
|
Réinitialiser
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
className="px-3 py-2 text-sm rounded-lg border border-slate-300 text-slate-700 hover:bg-slate-100 transition ml-auto"
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
onClick={handleApply}
|
|
disabled={selectedIsos.length === 0}
|
|
className={`
|
|
px-4 py-2 text-sm font-semibold rounded-lg transition
|
|
${selectedIsos.length === 0
|
|
? "bg-slate-200 text-slate-400 cursor-not-allowed"
|
|
: "bg-indigo-600 text-white hover:bg-indigo-700"
|
|
}
|
|
`}
|
|
>
|
|
Appliquer ({selectedIsos.length})
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|