From a136c5450147dc7d5d86e1a7a8be95723a31c023 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 23:18:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(plans/wizard):=20cadence=20sur=20calendrie?= =?UTF-8?q?r=20mensuel=20avec=20tonalit=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remplace la liste verticale par une vraie visu calendrier qui ancre chaque étape sur une date concrète, ce qui donne du sens au timing. - Date d'échéance fictive : le 15 du mois prochain (stable, prévisible, laisse de la marge avant/après pour offsets négatifs comme positifs) - Cellule échéance = ◆ rubis plein sur fond rubis-glow + shadow rubis, jour mis en exergue - Cellule étape = couleur de fond pleine selon la tonalité (Doux = rubis-glow, Standard = cream-2, Ferme = ink, Strict = rubis-deep) avec affichage J+X / numéro du jour - Cellule jour normal = numéro muted, today souligné en rubis-glow - Click sur cellule étape → sélection, l'éditeur (offset, ton, supprimer) apparaît directement sous le calendrier - Légende des tonalités juste sous l'en-tête - Affiche tous les mois entre la 1re et la dernière étape (échéance incluse) — typiquement 1 à 2 mois en pratique - Mêmes raccourcis qu'avant : OffsetInput string-controlled qui accepte les états intermédiaires "" et "-" Suppression de CadenceTimeline.tsx (la liste verticale précédente). Co-Authored-By: Claude Opus 4.7 --- .../plans/wizard/CadenceCalendar.tsx | 530 ++++++++++++++++++ .../plans/wizard/CadenceTimeline.tsx | 284 ---------- apps/web/src/routes/_app/plans_.nouveau.tsx | 4 +- 3 files changed, 532 insertions(+), 286 deletions(-) create mode 100644 apps/web/src/components/plans/wizard/CadenceCalendar.tsx delete mode 100644 apps/web/src/components/plans/wizard/CadenceTimeline.tsx diff --git a/apps/web/src/components/plans/wizard/CadenceCalendar.tsx b/apps/web/src/components/plans/wizard/CadenceCalendar.tsx new file mode 100644 index 0000000..3073e0b --- /dev/null +++ b/apps/web/src/components/plans/wizard/CadenceCalendar.tsx @@ -0,0 +1,530 @@ +import { useMemo } from "react"; +import { 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 à + * l'étape 3 du wizard). + */ +export type DraftStepLite = { + offsetDays: number; + tone: RelanceTone; + requiresManualValidation?: boolean; +}; + +/** + * Calendrier mensuel comme visualisation principale de la cadence. + * + * - 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). + */ +export function CadenceCalendar({ + steps, + selectedIndex = -1, + onSelectStep, + onUpdateStep, + onAddStep, + onRemoveStep, +}: { + steps: DraftStepLite[]; + selectedIndex?: number; + onSelectStep?: (idx: number) => void; + onUpdateStep?: (idx: number, patch: Partial) => void; + onAddStep: () => void; + onRemoveStep: (idx: number) => void; +}) { + const dueDate = useMemo(() => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth() + 1, 15); + }, []); + + const stepDates = useMemo( + () => steps.map((s) => addDays(dueDate, s.offsetDays)), + [steps, dueDate], + ); + + const months = useMemo(() => listMonths(dueDate, stepDates), [dueDate, 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 +

+
+ + {/* Légende */} + + + {/* Grilles mensuelles */} +
+ {months.map((m) => ( + + ))} +
+ + {/* Editor inline pour l'étape sélectionnée */} +
+ {selected ? ( + 1} + onUpdate={(patch) => onUpdateStep?.(selectedIndex, patch)} + onRemove={() => onRemoveStep(selectedIndex)} + /> + ) : ( +

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

+ )} +
+ + {/* Ajouter une étape */} + {steps.length < 8 && ( + + )} +
+ ); +} + +// ============================================================================ +// Légende +// ============================================================================ +function Legend() { + return ( +
+ + + + + + + + + + +
+ ); +} + +// ============================================================================ +// Grille d'un mois +// ============================================================================ + +const TONE_CELL_CLASS: Record = { + amical: "bg-rubis-glow border-rubis text-rubis-deep", + courtois: "bg-cream-2 border-ink-3 text-ink", + ferme: "bg-ink border-ink text-cream", + mise_en_demeure: "bg-rubis-deep border-rubis-deep text-cream", +}; + +const WEEKDAY_LABELS = ["L", "M", "M", "J", "V", "S", "D"]; + +function MonthGrid({ + month, + dueDate, + steps, + stepDates, + selectedIndex, + onSelectStep, +}: { + month: Date; + dueDate: Date; + steps: DraftStepLite[]; + stepDates: Date[]; + selectedIndex: number; + onSelectStep?: (idx: number) => void; +}) { + 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 daysInMonth = lastDay.getDate(); + const cells: (Date | null)[] = []; + for (let i = 0; i < firstDayOfWeek; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; 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) => ( + + ))} +
+
+ ); +} + +function DayCell({ + date, + dueDate, + steps, + stepDates, + selectedIndex, + onSelectStep, +}: { + date: Date | null; + dueDate: Date; + steps: DraftStepLite[]; + stepDates: Date[]; + selectedIndex: number; + onSelectStep?: (idx: number) => void; +}) { + if (!date) return -