diff --git a/apps/web/src/components/plans/wizard/CadenceCalendar.tsx b/apps/web/src/components/plans/wizard/CadenceCalendar.tsx index 3073e0b..0058c48 100644 --- a/apps/web/src/components/plans/wizard/CadenceCalendar.tsx +++ b/apps/web/src/components/plans/wizard/CadenceCalendar.tsx @@ -1,12 +1,10 @@ -import { useMemo } from "react"; -import { Plus, Trash2, AlertTriangle } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight, Plus, Trash2, AlertTriangle } from "lucide-react"; import type { RelanceTone } from "@rubis/shared"; import { cn } from "@/lib/utils"; import { TONE_LABELS } from "@/lib/plans"; -import { Field } from "@/components/ui/Field"; import { Input } from "@/components/ui/Input"; -import { Button } from "@/components/ui/Button"; /** * Étape minimale pour la visu calendrier (subject/body arrivent à @@ -19,20 +17,14 @@ export type DraftStepLite = { }; /** - * Calendrier mensuel comme visualisation principale de la cadence. + * Mini-calendrier mensuel comme outil de **navigation** dans la cadence. + * Compact : un mois à la fois, prev/next, éditeur compact en dessous. * - * - L'échéance fictive sert de repère héros (◆ rubis plein) pour que - * l'utilisateur se projette dans le timing réel. - * - Chaque étape s'affiche sur la case du jour J+offset, colorée par - * tonalité. - * - Cliquer une case = sélectionner l'étape, l'éditeur (offset + ton + - * supprimer) apparaît directement en dessous. - * - On rend tous les mois entre la première et la dernière étape, plus - * le mois de l'échéance : on évite de scroller un calendrier vide. - * - * Date de référence : le 15 du mois prochain. Stable, prévisible, - * laisse de la marge pour les offsets négatifs (rappel avant échéance) - * comme positifs (mises en demeure J+30, J+60). + * - Date d'échéance fictive : le 15 du mois prochain. + * - Auto-jump au mois de l'étape sélectionnée → la cellule reste visible. + * - Cellule échéance = ◆ rubis (info-only). + * - Cellule étape = couleur de fond selon la tonalité, cliquable pour + * sélectionner et éditer juste en dessous. */ export function CadenceCalendar({ steps, @@ -59,53 +51,76 @@ export function CadenceCalendar({ [steps, dueDate], ); - const months = useMemo(() => listMonths(dueDate, stepDates), [dueDate, stepDates]); + // Mois affiché : par défaut = mois de l'échéance ; auto-jump au mois de + // l'étape sélectionnée si on en a une. + const [viewMonth, setViewMonth] = useState( + () => new Date(dueDate.getFullYear(), dueDate.getMonth(), 1), + ); + + useEffect(() => { + if (selectedIndex < 0) return; + const target = stepDates[selectedIndex]; + if (!target) return; + setViewMonth((cur) => { + if (cur.getFullYear() === target.getFullYear() && cur.getMonth() === target.getMonth()) { + return cur; + } + return new Date(target.getFullYear(), target.getMonth(), 1); + }); + }, [selectedIndex, stepDates]); const selected = selectedIndex >= 0 ? steps[selectedIndex] : null; return ( -
- {/* Header */} -
-
-

- Calendrier -

-

- Pour une facture échue le{" "} - - {formatLongDate(dueDate)} - - , voici ce que Rubis enverra. -

-
-

- {steps.length}/8 +

+ {/* Header — month nav + compteur */} +
+ +

+ {formatMonth(viewMonth)}

+
- {/* Légende */} - + {/* Grille du mois */} + - {/* Grilles mensuelles */} -
- {months.map((m) => ( - - ))} -
+ {/* Légende compacte */} +

+ ◆ Échéance fictive le{" "} + {formatLongDate(dueDate)} · couleur = + tonalité +

- {/* Editor inline pour l'étape sélectionnée */} -
+ {/* Éditeur inline compact */} +
{selected ? ( - onRemoveStep(selectedIndex)} /> ) : ( -

- Touchez une case dans le calendrier pour modifier une relance. +

+ Touchez une case colorée pour modifier la relance.

)}
- {/* Ajouter une étape */} - {steps.length < 8 && ( - - )} + {/* Footer — ajouter + compteur */} +
+ {steps.length < 8 && ( + + )} +

+ {steps.length}/8 +

+
); } // ============================================================================ -// Légende -// ============================================================================ -function Legend() { - return ( -
- - - - - - - - - - -
- ); -} - -// ============================================================================ -// Grille d'un mois +// Grille d'un mois — cellules compactes, h-fixed pour stabilité visuelle // ============================================================================ const TONE_CELL_CLASS: Record = { @@ -201,42 +190,36 @@ function MonthGrid({ }) { const firstDay = new Date(month.getFullYear(), month.getMonth(), 1); const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0); - const firstDayOfWeek = (firstDay.getDay() + 6) % 7; // L=0...D=6 + const firstDayOfWeek = (firstDay.getDay() + 6) % 7; - const daysInMonth = lastDay.getDate(); const cells: (Date | null)[] = []; for (let i = 0; i < firstDayOfWeek; i++) cells.push(null); - for (let d = 1; d <= daysInMonth; d++) { + for (let d = 1; d <= lastDay.getDate(); d++) { cells.push(new Date(month.getFullYear(), month.getMonth(), d)); } while (cells.length % 7 !== 0) cells.push(null); return ( -
-

- {formatMonth(month)} -

-
- {WEEKDAY_LABELS.map((label, i) => ( -
- {label} -
- ))} - {cells.map((date, i) => ( - - ))} -
+
+ {WEEKDAY_LABELS.map((label, i) => ( +
+ {label} +
+ ))} + {cells.map((date, i) => ( + + ))}
); } @@ -256,36 +239,36 @@ function DayCell({ selectedIndex: number; onSelectStep?: (idx: number) => void; }) { - if (!date) return @@ -436,10 +410,6 @@ function sameDay(a: Date, b: Date): boolean { ); } -function isSameDay(a: Date, b: Date): boolean { - return sameDay(a, b); -} - function lastIndexOf(arr: T[], pred: (x: T) => boolean): number { for (let i = arr.length - 1; i >= 0; i--) { if (pred(arr[i]!)) return i; @@ -447,20 +417,6 @@ function lastIndexOf(arr: T[], pred: (x: T) => boolean): number { return -1; } -function listMonths(dueDate: Date, stepDates: Date[]): Date[] { - const all = [dueDate, ...stepDates]; - const earliest = new Date(Math.min(...all.map((d) => d.getTime()))); - const latest = new Date(Math.max(...all.map((d) => d.getTime()))); - const months: Date[] = []; - let cur = new Date(earliest.getFullYear(), earliest.getMonth(), 1); - const end = new Date(latest.getFullYear(), latest.getMonth(), 1); - while (cur <= end) { - months.push(new Date(cur)); - cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1); - } - return months; -} - const FR_MONTH = new Intl.DateTimeFormat("fr-FR", { month: "long", year: "numeric", @@ -472,6 +428,11 @@ const FR_LONG = new Intl.DateTimeFormat("fr-FR", { month: "long", }); +const FR_SHORT = new Intl.DateTimeFormat("fr-FR", { + day: "numeric", + month: "short", +}); + function formatMonth(d: Date): string { return FR_MONTH.format(d); } @@ -480,20 +441,23 @@ function formatLongDate(d: Date): string { return FR_LONG.format(d); } +function formatShortDate(d: Date): string { + return FR_SHORT.format(d); +} + // ============================================================================ // OffsetInput (string-controlled, accepte vide/`-` intermédiaires) // ============================================================================ -import { useEffect, useState } from "react"; - function OffsetInput({ id, value, onCommit, + ...rest }: { id: string; value: number; onCommit: (n: number) => void; -}) { +} & Omit, "value" | "onChange">) { const [local, setLocal] = useState(String(value)); useEffect(() => { @@ -506,6 +470,7 @@ function OffsetInput({ type="text" inputMode="numeric" pattern="-?[0-9]*" + className="h-10 text-center tabular-nums" value={local} onChange={(e) => { const next = e.target.value; @@ -525,6 +490,7 @@ function OffsetInput({ onCommit(0); } }} + {...rest} /> ); }