From 06dcf38feedc7153b4a4248289985032f9fee906 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Thu, 7 May 2026 23:01:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20DatePicker=20brand=C3=A9=20Rubis=20?= =?UTF-8?q?(remplace=20input=20type=3Ddate)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le natif affiche le calendrier moche du navigateur (différent par client OS, pas d'alignement palette). On le remplace par un composant Radix Popover + grille mensuelle Tailwind aux couleurs Rubis. Composant : apps/web/src/components/ui/DatePicker.tsx - Trigger : bouton style Input (border line, focus rubis-glow, data-[state=open]:border-rubis pour hint visuel) - Popover Radix : focus trap, Escape, click outside, animation - Grille 7×6 (semaine commence lundi, locale FR via date-fns) - Sélection : bg-rubis text-white + ombre rubis - Today : ring-inset rubis-glow - Hover : bg-cream - Raccourci "Aujourd'hui" en footer API alignée avec l'usage existant : - value: string ISO | Date | null - onChange(date: Date) — l'appelant fait .toISOString() comme avant Usages migrés : - ManualInvoiceDialog.tsx : Date d'émission - factures_.import_.$batchId.tsx : Date d'échéance (avec préservation du className aria-invalid pour les low-confidence OCR) Co-Authored-By: Claude Opus 4.7 --- .../factures/ManualInvoiceDialog.tsx | 11 +- apps/web/src/components/ui/DatePicker.tsx | 249 ++++++++++++++++++ .../_app/factures_.import_.$batchId.tsx | 10 +- 3 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/ui/DatePicker.tsx 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")}