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 -