diff --git a/apps/web/src/components/factures/ManualInvoiceDialog.tsx b/apps/web/src/components/factures/ManualInvoiceDialog.tsx index 2a39691..f8e7caf 100644 --- a/apps/web/src/components/factures/ManualInvoiceDialog.tsx +++ b/apps/web/src/components/factures/ManualInvoiceDialog.tsx @@ -18,6 +18,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/Dialog"; +import { DatePicker } from "@/components/ui/DatePicker"; import { Field } from "@/components/ui/Field"; import { Input } from "@/components/ui/Input"; import { @@ -238,14 +239,10 @@ export function ManualInvoiceDialog({ open, onOpenChange }: ManualInvoiceDialogP {(field) => ( - { - const d = new Date(e.target.value); - field.handleChange(d.toISOString()); - }} + value={field.state.value} + onChange={(d) => field.handleChange(d.toISOString())} /> )} diff --git a/apps/web/src/components/ui/DatePicker.tsx b/apps/web/src/components/ui/DatePicker.tsx new file mode 100644 index 0000000..d1f5b3c --- /dev/null +++ b/apps/web/src/components/ui/DatePicker.tsx @@ -0,0 +1,249 @@ +import * as React from "react"; +import * as Popover from "@radix-ui/react-popover"; +import { + Calendar as CalendarIcon, + ChevronLeft, + ChevronRight, +} from "lucide-react"; +import { + addMonths, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + isSameDay, + isSameMonth, + isToday, + parseISO, + startOfMonth, + startOfWeek, + subMonths, +} from "date-fns"; +import { fr } from "date-fns/locale"; + +import { cn } from "@/lib/utils"; + +/** + * DatePicker brandé Rubis — alternative au natif `` qui + * affiche le calendrier moche du navigateur. Utilise Radix Popover pour le + * positionnement et l'accessibilité (focus trap, Escape, click outside). + * + * Palette : trigger en `Input`-style (border line, focus rubis), grille avec + * jour sélectionné `bg-rubis text-white` + ombre rubis, today `ring-rubis-glow`. + * + * I/O : + * - `value` accepte string ISO (cf. usage existant Tanstack Form) ou `Date`. + * - `onChange(date: Date)` rend toujours un Date que l'appelant convertit + * via `.toISOString()` ou autre format selon son champ. + */ +export type DatePickerProps = { + value?: string | Date | null; + onChange: (date: Date) => void; + placeholder?: string; + id?: string; + className?: string; + disabled?: boolean; + "aria-invalid"?: boolean; +}; + +function toDate(value: DatePickerProps["value"]): Date | null { + if (!value) return null; + if (value instanceof Date) return value; + try { + const d = parseISO(value); + return Number.isNaN(d.getTime()) ? null : d; + } catch { + return null; + } +} + +export function DatePicker({ + value, + onChange, + placeholder = "Choisir une date", + id, + className, + disabled, + "aria-invalid": ariaInvalid, +}: DatePickerProps) { + const date = toDate(value); + const [open, setOpen] = React.useState(false); + const [viewMonth, setViewMonth] = React.useState(date ?? new Date()); + + // Re-sync viewMonth quand la valeur externe change (reset de form, etc.) + React.useEffect(() => { + if (date) setViewMonth(date); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [date?.getTime()]); + + const handleSelect = (d: Date) => { + onChange(d); + setOpen(false); + }; + + return ( + + + + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// Grille interne — header (chevrons + mois courant) + 7×6 jours + raccourci +// "Aujourd'hui". Semaine commence lundi (FR). +// --------------------------------------------------------------------------- + +type CalendarGridProps = { + viewMonth: Date; + selectedDate: Date | null; + onSelect: (date: Date) => void; + onMonthChange: (date: Date) => void; +}; + +function CalendarGrid({ + viewMonth, + selectedDate, + onSelect, + onMonthChange, +}: CalendarGridProps) { + const monthStart = startOfMonth(viewMonth); + const monthEnd = endOfMonth(viewMonth); + const gridStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Lundi + const gridEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); + + const days = eachDayOfInterval({ start: gridStart, end: gridEnd }); + const weekDays = ["L", "M", "M", "J", "V", "S", "D"]; + + return ( +
+ {/* Header mois — chevrons + label */} +
+ +
+ {format(viewMonth, "MMMM yyyy", { locale: fr })} +
+ +
+ + {/* Labels jours (L M M J V S D) */} +
+ {weekDays.map((d, i) => ( +
+ {d} +
+ ))} +
+ + {/* Grille 7×6 jours */} +
+ {days.map((day) => { + const isOutside = !isSameMonth(day, viewMonth); + const isSelected = selectedDate ? isSameDay(day, selectedDate) : false; + const isCurrent = isToday(day); + return ( + + ); + })} +
+ + {/* Raccourci "Aujourd'hui" */} +
+ +
+
+ ); +} diff --git a/apps/web/src/routes/_app/factures_.import_.$batchId.tsx b/apps/web/src/routes/_app/factures_.import_.$batchId.tsx index 4b3b285..c1a2c3a 100644 --- a/apps/web/src/routes/_app/factures_.import_.$batchId.tsx +++ b/apps/web/src/routes/_app/factures_.import_.$batchId.tsx @@ -26,6 +26,7 @@ import { formatEuros } from "@/lib/format"; import { Button } from "@/components/ui/Button"; import { Card } from "@/components/ui/Card"; +import { DatePicker } from "@/components/ui/DatePicker"; import { Eyebrow } from "@/components/ui/Eyebrow"; import { Field } from "@/components/ui/Field"; import { Input } from "@/components/ui/Input"; @@ -358,13 +359,12 @@ function ImportReviewPage() { - + value={draft.dueDate} + onChange={(d) => update({ - dueDate: new Date(e.target.value).toISOString(), + dueDate: d.toISOString(), }) } className={cn(isLowConfidence("dueDate") && "border-rubis")}