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 (
+
+
+
+ Échéance
+
+
+
+ Doux
+
+
+
+ Standard
+
+
+
+ Ferme
+
+
+
+ Strict
+
+
+ );
+}
+
+// ============================================================================
+// 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 ;
+
+ const isDue = sameDay(date, dueDate);
+ // En cas de collision (deux étapes le même jour), on prend la dernière —
+ // c'est la plus récente dans le plan, dominante visuellement.
+ const stepIdx = lastIndexOf(stepDates, (d) => sameDay(d, date));
+ const step = stepIdx >= 0 ? steps[stepIdx] : null;
+ const isSelected = stepIdx === selectedIndex;
+ const isInteractive = isDue || step !== null;
+
+ // Cellule "due date" : ◆ rubis sur fond glow, pas cliquable (info-only)
+ if (isDue) {
+ return (
+
+
+
+ {date.getDate()}
+
+
+ );
+ }
+
+ // Cellule "étape" : couleur par ton, cliquable
+ if (step) {
+ return (
+
+ );
+ }
+
+ // Cellule normale (jour sans event) — affichage muet
+ return (
+
+ {date.getDate()}
+
+ );
+}
+
+// ============================================================================
+// Editor inline
+// ============================================================================
+function StepEditor({
+ step,
+ index,
+ stepDate,
+ canRemove,
+ onUpdate,
+ onRemove,
+}: {
+ step: DraftStepLite;
+ index: number;
+ stepDate: Date;
+ canRemove: boolean;
+ onUpdate: (patch: Partial) => void;
+ onRemove: () => void;
+}) {
+ return (
+
+
+
+
+
+ Étape {index + 1} · {formatLongDate(stepDate)}
+
+
+ {canRemove && (
+
+ )}
+
+
+
+
+ onUpdate({ offsetDays: n })}
+ />
+
+
+
+
+
+
+ {step.tone === "mise_en_demeure" && (
+
+ Validation manuelle obligatoire avant
+ envoi (sécurité juridique).
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// Helpers
+// ============================================================================
+function addDays(d: Date, n: number): Date {
+ const r = new Date(d);
+ r.setDate(r.getDate() + n);
+ return r;
+}
+
+function sameDay(a: Date, b: Date): boolean {
+ return (
+ a.getFullYear() === b.getFullYear() &&
+ a.getMonth() === b.getMonth() &&
+ a.getDate() === b.getDate()
+ );
+}
+
+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;
+ }
+ 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",
+});
+
+const FR_LONG = new Intl.DateTimeFormat("fr-FR", {
+ weekday: "long",
+ day: "numeric",
+ month: "long",
+});
+
+function formatMonth(d: Date): string {
+ return FR_MONTH.format(d);
+}
+
+function formatLongDate(d: Date): string {
+ return FR_LONG.format(d);
+}
+
+// ============================================================================
+// OffsetInput (string-controlled, accepte vide/`-` intermédiaires)
+// ============================================================================
+import { useEffect, useState } from "react";
+
+function OffsetInput({
+ id,
+ value,
+ onCommit,
+}: {
+ id: string;
+ value: number;
+ onCommit: (n: number) => void;
+}) {
+ const [local, setLocal] = useState(String(value));
+
+ useEffect(() => {
+ setLocal(String(value));
+ }, [value]);
+
+ return (
+ {
+ const next = e.target.value;
+ if (next === "" || next === "-" || /^-?\d{0,3}$/.test(next)) {
+ setLocal(next);
+ if (next !== "" && next !== "-") {
+ const parsed = parseInt(next, 10);
+ if (!Number.isNaN(parsed)) {
+ onCommit(Math.max(-30, Math.min(180, parsed)));
+ }
+ }
+ }
+ }}
+ onBlur={() => {
+ if (local === "" || local === "-") {
+ setLocal("0");
+ onCommit(0);
+ }
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/components/plans/wizard/CadenceTimeline.tsx b/apps/web/src/components/plans/wizard/CadenceTimeline.tsx
deleted file mode 100644
index 8843ee5..0000000
--- a/apps/web/src/components/plans/wizard/CadenceTimeline.tsx
+++ /dev/null
@@ -1,284 +0,0 @@
-import { useEffect, useState } from "react";
-import { Plus, X, 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";
-
-/**
- * Étape minimale pour la liste (subject/body arrivent à l'étape 3 du wizard).
- */
-export type DraftStepLite = {
- offsetDays: number;
- tone: RelanceTone;
- requiresManualValidation?: boolean;
-};
-
-const TONE_DOT_CLASS: Record = {
- amical: "bg-rubis-glow border-rubis",
- courtois: "bg-cream-2 border-line",
- ferme: "bg-ink border-ink",
- mise_en_demeure: "bg-rubis-deep border-rubis-deep",
-};
-
-/**
- * Liste verticale des étapes d'un plan. Chaque étape = une card cliquable
- * avec un petit ◆ accent (coloré par tonalité), l'offset (J+X), le label
- * de ton, et un bouton retirer aligné dans le flux.
- *
- * La card sélectionnée s'étend pour révéler l'éditeur inline (décalage
- * en jours + tonalité) directement sous l'en-tête. Pas de panneau séparé,
- * pas de saut de focus — l'utilisateur édite ce qu'il vient de tapper.
- *
- * Décision de design : on n'utilise PAS le ◆ rotaté comme structure
- * (trop fragile en vertical, collisions des coins avec le texte). Le ◆
- * reste un accent de marque, présent mais petit.
- */
-export function CadenceTimeline({
- 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;
-}) {
- return (
-
-
-
- Cadence
-
-
- {steps.length} étape{steps.length > 1 ? "s" : ""} · max 8
-
-
-
-
- {steps.map((step, idx) => {
- const isSelected = idx === selectedIndex;
- return (
- -
- 1}
- onSelect={() => onSelectStep?.(idx)}
- onUpdate={(patch) => onUpdateStep?.(idx, patch)}
- onRemove={() => onRemoveStep(idx)}
- />
-
- );
- })}
-
-
- {steps.length < 8 && (
-
- )}
-
- );
-}
-
-function StepCard({
- step,
- index,
- selected,
- canRemove,
- onSelect,
- onUpdate,
- onRemove,
-}: {
- step: DraftStepLite;
- index: number;
- selected: boolean;
- canRemove: boolean;
- onSelect: () => void;
- onUpdate: (patch: Partial) => void;
- onRemove: () => void;
-}) {
- return (
-
- {/* Header — toujours visible, cliquable pour sélectionner */}
-
-
-
- {canRemove && (
-
- )}
-
-
- {/* Éditeur inline — apparaît dans la card sélectionnée */}
- {selected && (
-
-
-
- onUpdate({ offsetDays: n })}
- />
-
-
-
-
-
- {step.tone === "mise_en_demeure" && (
-
- Validation manuelle obligatoire
- avant envoi (sécurité juridique).
-
- )}
-
- )}
-
- );
-}
-
-/**
- * Input numérique signé piloté en string local. Évite le 0 fantôme quand on
- * efface pour ressaisir, et accepte les états intermédiaires "" et "-" le
- * temps que l'utilisateur termine de taper. Clamp [-30, 180] sur commit ;
- * fallback à 0 au blur si vide.
- */
-function OffsetInput({
- id,
- value,
- onCommit,
-}: {
- id: string;
- value: number;
- onCommit: (n: number) => void;
-}) {
- const [local, setLocal] = useState(String(value));
-
- useEffect(() => {
- setLocal(String(value));
- }, [value]);
-
- return (
- {
- const next = e.target.value;
- if (next === "" || next === "-" || /^-?\d{0,3}$/.test(next)) {
- setLocal(next);
- if (next !== "" && next !== "-") {
- const parsed = parseInt(next, 10);
- if (!Number.isNaN(parsed)) {
- onCommit(Math.max(-30, Math.min(180, parsed)));
- }
- }
- }
- }}
- onBlur={() => {
- if (local === "" || local === "-") {
- setLocal("0");
- onCommit(0);
- }
- }}
- />
- );
-}
diff --git a/apps/web/src/routes/_app/plans_.nouveau.tsx b/apps/web/src/routes/_app/plans_.nouveau.tsx
index 37d1552..aa34501 100644
--- a/apps/web/src/routes/_app/plans_.nouveau.tsx
+++ b/apps/web/src/routes/_app/plans_.nouveau.tsx
@@ -25,7 +25,7 @@ import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input";
import { Textarea } from "@/components/ui/Textarea";
import { Eyebrow } from "@/components/ui/Eyebrow";
-import { CadenceTimeline } from "@/components/plans/wizard/CadenceTimeline";
+import { CadenceCalendar } from "@/components/plans/wizard/CadenceCalendar";
import { EmailPreview } from "@/components/plans/wizard/EmailPreview";
import { AiGenerateModal } from "@/components/plans/wizard/AiGenerateModal";
@@ -454,7 +454,7 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
-