fix(plans/wizard): calendrier vraiment pratique (3 problèmes UX)

Issues remontées :
- Cellules étirées en horizontal sur desktop (rectangles plats)
- Échéance et "Doux" indiscernables (tous deux en rubis-glow)
- Pas de feedback au clic, "Étape 1" déjà affichée par défaut sans
  qu'on l'ait sélectionnée
- "Étape 1 · 20 juin · J+5" pas parlant
- Cliquer sur une case vide ne faisait rien

Refonte :
- Calendrier max-w-md mx-auto + cells aspect-square → carrés équilibrés
- Échéance = bg-rubis solide (pas glow) + ◆ blanc + ombre rubis →
  visuellement distincte de toutes les tonalités
- Cellule étape = couleur tonalité + badge "J+X" en coin haut-droit
- Sélection forte : ring-4 + scale-1.08 + shadow-rubis-hover sur la
  case sélectionnée → impossible de la rater
- Default selectedIdx = -1 (pas de présélection) → hint clair :
  "Touchez une case colorée pour modifier, ou un jour vide pour ajouter"
- **Click sur case vide → crée une étape à cet offset**, triée par
  ordre temporel (insertion smart, pas en bout). Plus l'usage le plus
  naturel de l'outil : "je veux relancer le 5 juin" → clic.
- Click sur échéance → toast explicatif (pas une no-op silencieuse)
- Header de l'éditeur : "Relance du **5 juin** · J-10" (pas "Étape 1")
- Hover sur jour vide : "+" rubis apparaît → affordance d'ajout claire
- Hors plage [-30, +180] ou >= 8 étapes : cellule disabled, toast info

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 23:29:39 +02:00
parent 149f60dbb0
commit 07712da774
2 changed files with 247 additions and 115 deletions

View File

@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ChevronLeft, ChevronRight, Plus, Trash2, AlertTriangle } from "lucide-react"; import { ChevronLeft, ChevronRight, Plus, Trash2, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import type { RelanceTone } from "@rubis/shared"; import type { RelanceTone } from "@rubis/shared";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -17,14 +18,17 @@ export type DraftStepLite = {
}; };
/** /**
* Mini-calendrier mensuel comme outil de **navigation** dans la cadence. * Mini-calendrier mensuel pratique : on **navigue** dans la cadence,
* Compact : un mois à la fois, prev/next, éditeur compact en dessous. * on **ajoute** une relance en touchant une case vide, on **modifie**
* en touchant une case colorée. Édition compacte juste en dessous.
* *
* - Date d'échéance fictive : le 15 du mois prochain. * Distinctions visuelles importantes :
* - Auto-jump au mois de l'étape sélectionnée la cellule reste visible. * - Échéance : rubis solide (le repère héros, pas une "tonalité")
* - Cellule échéance = rubis (info-only). * - Étape : couleur de fond selon la tonalité (4 niveaux escalade)
* - Cellule étape = couleur de fond selon la tonalité, cliquable pour * - Sélectionnée : ring rubis + scale
* sélectionner et éditer juste en dessous. * - Vide : numéro discret, hover montre que c'est cliquable
*
* Date de référence : le 15 du mois prochain.
*/ */
export function CadenceCalendar({ export function CadenceCalendar({
steps, steps,
@ -32,14 +36,20 @@ export function CadenceCalendar({
onSelectStep, onSelectStep,
onUpdateStep, onUpdateStep,
onAddStep, onAddStep,
onAddStepAtOffset,
onRemoveStep, onRemoveStep,
globalTone,
}: { }: {
steps: DraftStepLite[]; steps: DraftStepLite[];
selectedIndex?: number; selectedIndex?: number;
onSelectStep?: (idx: number) => void; onSelectStep?: (idx: number) => void;
onUpdateStep?: (idx: number, patch: Partial<DraftStepLite>) => void; onUpdateStep?: (idx: number, patch: Partial<DraftStepLite>) => void;
onAddStep: () => void; onAddStep: () => void;
/** Création directe en cliquant une case vide. Reçoit l'offset depuis l'échéance. */
onAddStepAtOffset?: (offsetDays: number) => void;
onRemoveStep: (idx: number) => void; onRemoveStep: (idx: number) => void;
/** Tonalité par défaut pour une étape créée depuis le calendrier. */
globalTone?: RelanceTone;
}) { }) {
const dueDate = useMemo(() => { const dueDate = useMemo(() => {
const now = new Date(); const now = new Date();
@ -51,12 +61,11 @@ export function CadenceCalendar({
[steps, dueDate], [steps, dueDate],
); );
// 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<Date>( const [viewMonth, setViewMonth] = useState<Date>(
() => new Date(dueDate.getFullYear(), dueDate.getMonth(), 1), () => new Date(dueDate.getFullYear(), dueDate.getMonth(), 1),
); );
// Auto-jump au mois de l'étape sélectionnée pour ne pas perdre la cellule.
useEffect(() => { useEffect(() => {
if (selectedIndex < 0) return; if (selectedIndex < 0) return;
const target = stepDates[selectedIndex]; const target = stepDates[selectedIndex];
@ -71,10 +80,23 @@ export function CadenceCalendar({
const selected = selectedIndex >= 0 ? steps[selectedIndex] : null; const selected = selectedIndex >= 0 ? steps[selectedIndex] : null;
const handleEmptyDayClick = (date: Date) => {
if (steps.length >= 8) {
toast.info("8 étapes max par plan.");
return;
}
const offset = daysBetween(dueDate, date);
if (offset < -30 || offset > 180) {
toast.info("Hors plage : on accepte de J-30 à J+180.");
return;
}
onAddStepAtOffset?.(offset);
};
return ( return (
<div className="rounded-card border border-line bg-cream/40 p-3 sm:p-4"> <div className="rounded-card border border-line bg-cream/40 p-3 sm:p-4 mx-auto max-w-md">
{/* Header — month nav + compteur */} {/* Header — month nav */}
<div className="flex items-center justify-between gap-2 mb-3"> <div className="flex items-center justify-between gap-2 mb-2">
<button <button
type="button" type="button"
onClick={() => onClick={() =>
@ -100,7 +122,6 @@ export function CadenceCalendar({
</button> </button>
</div> </div>
{/* Grille du mois */}
<MonthGrid <MonthGrid
month={viewMonth} month={viewMonth}
dueDate={dueDate} dueDate={dueDate}
@ -108,60 +129,93 @@ export function CadenceCalendar({
stepDates={stepDates} stepDates={stepDates}
selectedIndex={selectedIndex} selectedIndex={selectedIndex}
onSelectStep={onSelectStep} onSelectStep={onSelectStep}
onClickDueDate={() =>
toast.info(
`Le 15 du mois est l'échéance fictive de votre facture. Elle ne se modifie pas — c'est juste un repère pour visualiser le timing.`,
)
}
onClickEmptyDay={handleEmptyDayClick}
canAddMore={steps.length < 8}
/> />
{/* Légende compacte */} {/* Légende compacte avec les 4 tonalités + échéance */}
<p className="mt-2 text-[10.5px] text-ink-3 leading-snug"> <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10.5px] text-ink-3">
Échéance fictive le{" "} <LegendDot
<strong className="text-ink-2">{formatLongDate(dueDate)}</strong> · couleur = label="Échéance"
tonalité className="bg-rubis border-rubis"
</p> isDiamond
/>
<LegendDot label="Doux" className="bg-rubis-glow border-rubis" />
<LegendDot label="Standard" className="bg-cream-2 border-ink-3" />
<LegendDot label="Ferme" className="bg-ink border-ink" />
<LegendDot label="Strict" className="bg-rubis-deep border-rubis-deep" />
</div>
{/* Éditeur inline compact */} {/* Éditeur ou hint */}
<div className="mt-3 pt-3 border-t border-line"> <div className="mt-3 pt-3 border-t border-line">
{selected ? ( {selected ? (
<CompactEditor <CompactEditor
step={selected} step={selected}
index={selectedIndex}
stepDate={stepDates[selectedIndex]!} stepDate={stepDates[selectedIndex]!}
canRemove={steps.length > 1} canRemove={steps.length > 1}
onUpdate={(patch) => onUpdateStep?.(selectedIndex, patch)} onUpdate={(patch) => onUpdateStep?.(selectedIndex, patch)}
onRemove={() => onRemoveStep(selectedIndex)} onRemove={() => onRemoveStep(selectedIndex)}
/> />
) : ( ) : (
<p className="text-[12.5px] text-ink-3 italic text-center py-1.5"> <p className="text-[12.5px] text-ink-3 italic text-center py-2 leading-snug">
Touchez une case colorée pour modifier la relance. Touchez une <strong className="text-ink-2">case colorée</strong> pour
modifier une relance, ou un <strong className="text-ink-2">jour vide</strong>{" "}
pour en ajouter une.
</p> </p>
)} )}
</div> </div>
{/* Footer — ajouter + compteur */} {/* Footer — bouton Ajouter de secours */}
<div className="mt-3 flex items-center gap-2"> {steps.length < 8 && (
{steps.length < 8 && ( <button
<button type="button"
type="button" onClick={onAddStep}
onClick={onAddStep} className={cn(
className={cn( "mt-3 w-full inline-flex items-center justify-center gap-1.5",
"flex-1 inline-flex items-center justify-center gap-1.5", "rounded-default border border-dashed border-line bg-transparent",
"rounded-default border border-dashed border-line bg-transparent", "px-3 py-2 text-[12.5px] font-medium text-ink-3",
"px-3 py-2 text-[12.5px] font-medium text-ink-3", "hover:border-rubis hover:text-rubis-deep hover:bg-rubis-glow/20 transition-colors",
"hover:border-rubis hover:text-rubis-deep hover:bg-rubis-glow/20 transition-colors", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow", )}
)} >
> <Plus size={13} aria-hidden="true" /> Ajouter en bout de cadence
<Plus size={13} aria-hidden="true" /> Ajouter une étape </button>
</button> )}
)}
<p className="shrink-0 text-[11px] text-ink-3 italic tabular-nums">
{steps.length}/8
</p>
</div>
</div> </div>
); );
} }
function LegendDot({
label,
className,
isDiamond = false,
}: {
label: string;
className: string;
isDiamond?: boolean;
}) {
return (
<span className="inline-flex items-center gap-1">
<span
aria-hidden="true"
className={cn(
"size-2.5 border",
isDiamond ? "rotate-45" : "rounded-full",
className,
)}
/>
{label}
</span>
);
}
// ============================================================================ // ============================================================================
// Grille d'un mois — cellules compactes, h-fixed pour stabilité visuelle // Grille mensuelle — cellules carrées (aspect-square), max-w via container
// ============================================================================ // ============================================================================
const TONE_CELL_CLASS: Record<RelanceTone, string> = { const TONE_CELL_CLASS: Record<RelanceTone, string> = {
@ -180,6 +234,9 @@ function MonthGrid({
stepDates, stepDates,
selectedIndex, selectedIndex,
onSelectStep, onSelectStep,
onClickDueDate,
onClickEmptyDay,
canAddMore,
}: { }: {
month: Date; month: Date;
dueDate: Date; dueDate: Date;
@ -187,6 +244,9 @@ function MonthGrid({
stepDates: Date[]; stepDates: Date[];
selectedIndex: number; selectedIndex: number;
onSelectStep?: (idx: number) => void; onSelectStep?: (idx: number) => void;
onClickDueDate: () => void;
onClickEmptyDay: (date: Date) => void;
canAddMore: boolean;
}) { }) {
const firstDay = new Date(month.getFullYear(), month.getMonth(), 1); const firstDay = new Date(month.getFullYear(), month.getMonth(), 1);
const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0); const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0);
@ -218,6 +278,9 @@ function MonthGrid({
stepDates={stepDates} stepDates={stepDates}
selectedIndex={selectedIndex} selectedIndex={selectedIndex}
onSelectStep={onSelectStep} onSelectStep={onSelectStep}
onClickDueDate={onClickDueDate}
onClickEmptyDay={onClickEmptyDay}
canAddMore={canAddMore}
/> />
))} ))}
</div> </div>
@ -231,6 +294,9 @@ function DayCell({
stepDates, stepDates,
selectedIndex, selectedIndex,
onSelectStep, onSelectStep,
onClickDueDate,
onClickEmptyDay,
canAddMore,
}: { }: {
date: Date | null; date: Date | null;
dueDate: Date; dueDate: Date;
@ -238,37 +304,42 @@ function DayCell({
stepDates: Date[]; stepDates: Date[];
selectedIndex: number; selectedIndex: number;
onSelectStep?: (idx: number) => void; onSelectStep?: (idx: number) => void;
onClickDueDate: () => void;
onClickEmptyDay: (date: Date) => void;
canAddMore: boolean;
}) { }) {
if (!date) return <div aria-hidden="true" className="h-9" />; if (!date) return <div aria-hidden="true" className="aspect-square" />;
const isDue = sameDay(date, dueDate); const isDue = sameDay(date, dueDate);
const stepIdx = lastIndexOf(stepDates, (d) => sameDay(d, date)); const stepIdx = lastIndexOf(stepDates, (d) => sameDay(d, date));
const step = stepIdx >= 0 ? steps[stepIdx] : null; const step = stepIdx >= 0 ? steps[stepIdx] : null;
const isSelected = stepIdx === selectedIndex; const isSelected = stepIdx === selectedIndex;
const isToday = sameDay(date, new Date()); const isToday = sameDay(date, new Date());
const offsetFromDue = daysBetween(dueDate, date);
// Cellule échéance — info-only, pas cliquable // Cellule échéance — solide rubis, ◆ visible, cliquable pour info
if (isDue) { if (isDue) {
return ( return (
<div <button
type="button"
onClick={onClickDueDate}
className={cn( className={cn(
"h-9 flex items-center justify-center rounded-default", "aspect-square flex flex-col items-center justify-center rounded-default",
"border-2 border-rubis bg-rubis-glow text-rubis-deep relative", "border-2 border-rubis bg-rubis text-cream relative",
"shadow-rubis hover:shadow-rubis-hover transition-shadow",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
)} )}
aria-label={`Échéance le ${formatLongDate(date)}`} aria-label={`Échéance fictive le ${formatLongDate(date)}`}
> >
<span <span className="size-2.5 rotate-45 bg-cream" aria-hidden="true" />
className="absolute top-0.5 right-1 size-1.5 rotate-45 bg-rubis" <span className="text-[11px] font-bold tabular-nums mt-0.5">
aria-hidden="true"
/>
<span className="text-[12px] font-bold tabular-nums">
{date.getDate()} {date.getDate()}
</span> </span>
</div> </button>
); );
} }
// Cellule étape — cliquable // Cellule étape — couleur tonalité, badge "Jx" en coin, cliquable
if (step) { if (step) {
return ( return (
<button <button
@ -276,48 +347,73 @@ function DayCell({
onClick={() => onSelectStep?.(stepIdx)} onClick={() => onSelectStep?.(stepIdx)}
aria-pressed={isSelected} aria-pressed={isSelected}
aria-label={`${TONE_LABELS[step.tone]}, J${step.offsetDays >= 0 ? "+" : ""}${step.offsetDays}, le ${formatLongDate(date)}`} aria-label={`${TONE_LABELS[step.tone]}, J${step.offsetDays >= 0 ? "+" : ""}${step.offsetDays}, le ${formatLongDate(date)}`}
title={`${TONE_LABELS[step.tone]} · J${step.offsetDays >= 0 ? "+" : ""}${step.offsetDays}`}
className={cn( className={cn(
"h-9 flex items-center justify-center rounded-default", "aspect-square flex flex-col items-center justify-center rounded-default",
"border-2 transition-all relative", "border-2 transition-all relative",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
TONE_CELL_CLASS[step.tone], TONE_CELL_CLASS[step.tone],
isSelected && "ring-2 ring-rubis-glow scale-[1.06] z-10", isSelected
? "ring-4 ring-rubis-glow scale-[1.08] z-10 shadow-rubis-hover"
: "hover:scale-[1.04]",
)} )}
> >
<span className="text-[12px] font-bold tabular-nums"> <span className="absolute top-0.5 right-1 text-[8.5px] font-bold tabular-nums opacity-70">
J{step.offsetDays >= 0 ? "+" : ""}
{step.offsetDays}
</span>
<span className="text-[13px] font-bold tabular-nums">
{date.getDate()} {date.getDate()}
</span> </span>
</button> </button>
); );
} }
// Jour normal — discret, today souligné // Jour vide — cliquable pour ajouter une étape à cet offset (si dans la plage)
const inRange = offsetFromDue >= -30 && offsetFromDue <= 180 && offsetFromDue !== 0;
return ( return (
<div <button
type="button"
onClick={() => inRange && canAddMore && onClickEmptyDay(date)}
disabled={!inRange || !canAddMore}
aria-label={
inRange && canAddMore
? `Ajouter une relance le ${formatLongDate(date)} (J${offsetFromDue >= 0 ? "+" : ""}${offsetFromDue})`
: formatLongDate(date)
}
className={cn( className={cn(
"h-9 flex items-center justify-center rounded-default", "aspect-square flex items-center justify-center rounded-default group",
"text-[12px] tabular-nums text-ink-3", "text-[12px] tabular-nums text-ink-3 transition-colors",
isToday && "ring-1 ring-rubis-glow", isToday && "ring-1 ring-rubis-glow",
inRange &&
canAddMore &&
"hover:bg-rubis-glow/40 hover:text-rubis-deep cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
(!inRange || !canAddMore) && "cursor-default",
)} )}
> >
{date.getDate()} <span className="group-hover:hidden">{date.getDate()}</span>
</div> {inRange && canAddMore && (
<Plus
size={13}
className="hidden group-hover:block text-rubis"
aria-hidden="true"
/>
)}
</button>
); );
} }
// ============================================================================ // ============================================================================
// Éditeur compact — une ligne d'en-tête + une ligne de champs // Éditeur compact — header explicite + 1 ligne de champs
// ============================================================================ // ============================================================================
function CompactEditor({ function CompactEditor({
step, step,
index,
stepDate, stepDate,
canRemove, canRemove,
onUpdate, onUpdate,
onRemove, onRemove,
}: { }: {
step: DraftStepLite; step: DraftStepLite;
index: number;
stepDate: Date; stepDate: Date;
canRemove: boolean; canRemove: boolean;
onUpdate: (patch: Partial<DraftStepLite>) => void; onUpdate: (patch: Partial<DraftStepLite>) => void;
@ -325,6 +421,7 @@ function CompactEditor({
}) { }) {
return ( return (
<div className="space-y-2.5"> <div className="space-y-2.5">
{/* Header explicite : on sait quelle relance on édite */}
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span <span
@ -334,10 +431,15 @@ function CompactEditor({
TONE_CELL_CLASS[step.tone], TONE_CELL_CLASS[step.tone],
)} )}
/> />
<p className="font-display text-[13px] font-semibold text-ink truncate"> <p className="text-[13px] text-ink-2 truncate leading-tight">
Étape {index + 1} · {formatShortDate(stepDate)} · J Relance du{" "}
{step.offsetDays >= 0 ? "+" : ""} <strong className="font-display font-semibold text-ink">
{step.offsetDays} {formatShortDate(stepDate)}
</strong>{" "}
<span className="text-ink-3">
· J{step.offsetDays >= 0 ? "+" : ""}
{step.offsetDays}
</span>
</p> </p>
</div> </div>
{canRemove && ( {canRemove && (
@ -345,43 +447,49 @@ function CompactEditor({
type="button" type="button"
onClick={onRemove} onClick={onRemove}
className="shrink-0 size-8 flex items-center justify-center rounded-default text-ink-3 hover:text-rubis-deep hover:bg-rubis-glow/40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow" className="shrink-0 size-8 flex items-center justify-center rounded-default text-ink-3 hover:text-rubis-deep hover:bg-rubis-glow/40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow"
aria-label="Supprimer l'étape" aria-label="Supprimer cette relance"
title="Supprimer cette relance"
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
)} )}
</div> </div>
<div className="grid grid-cols-[100px,1fr] gap-2"> <div className="grid grid-cols-[110px,1fr] gap-2 items-end">
<OffsetInput <label className="flex flex-col gap-1">
id={`step-offset-${index}`} <span className="text-[11px] font-semibold text-ink-3">
value={step.offsetDays} Décalage
onCommit={(n) => onUpdate({ offsetDays: n })} </span>
aria-label="Décalage en jours" <OffsetInput
/> id={`step-offset`}
<select value={step.offsetDays}
id={`step-tone-${index}`} onCommit={(n) => onUpdate({ offsetDays: n })}
aria-label="Tonalité" />
className={cn( </label>
"h-10 w-full rounded-default border border-line bg-white px-2.5", <label className="flex flex-col gap-1">
"text-[13.5px] text-ink", <span className="text-[11px] font-semibold text-ink-3">Tonalité</span>
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow focus-visible:border-rubis", <select
)} className={cn(
value={step.tone} "h-10 w-full rounded-default border border-line bg-white px-2.5",
onChange={(e) => { "text-[13.5px] text-ink",
const tone = e.target.value as RelanceTone; "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow focus-visible:border-rubis",
onUpdate({ )}
tone, value={step.tone}
requiresManualValidation: tone === "mise_en_demeure", onChange={(e) => {
}); const tone = e.target.value as RelanceTone;
}} onUpdate({
> tone,
{(Object.keys(TONE_LABELS) as RelanceTone[]).map((t) => ( requiresManualValidation: tone === "mise_en_demeure",
<option key={t} value={t}> });
{TONE_LABELS[t]} }}
</option> >
))} {(Object.keys(TONE_LABELS) as RelanceTone[]).map((t) => (
</select> <option key={t} value={t}>
{TONE_LABELS[t]}
</option>
))}
</select>
</label>
</div> </div>
{step.tone === "mise_en_demeure" && ( {step.tone === "mise_en_demeure" && (
@ -410,6 +518,12 @@ function sameDay(a: Date, b: Date): boolean {
); );
} }
function daysBetween(from: Date, to: Date): number {
const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime();
const b = new Date(to.getFullYear(), to.getMonth(), to.getDate()).getTime();
return Math.round((b - a) / 86400000);
}
function lastIndexOf<T>(arr: T[], pred: (x: T) => boolean): number { function lastIndexOf<T>(arr: T[], pred: (x: T) => boolean): number {
for (let i = arr.length - 1; i >= 0; i--) { for (let i = arr.length - 1; i >= 0; i--) {
if (pred(arr[i]!)) return i; if (pred(arr[i]!)) return i;
@ -430,7 +544,7 @@ const FR_LONG = new Intl.DateTimeFormat("fr-FR", {
const FR_SHORT = new Intl.DateTimeFormat("fr-FR", { const FR_SHORT = new Intl.DateTimeFormat("fr-FR", {
day: "numeric", day: "numeric",
month: "short", month: "long",
}); });
function formatMonth(d: Date): string { function formatMonth(d: Date): string {
@ -446,18 +560,17 @@ function formatShortDate(d: Date): string {
} }
// ============================================================================ // ============================================================================
// OffsetInput (string-controlled, accepte vide/`-` intermédiaires) // OffsetInput
// ============================================================================ // ============================================================================
function OffsetInput({ function OffsetInput({
id, id,
value, value,
onCommit, onCommit,
...rest
}: { }: {
id: string; id: string;
value: number; value: number;
onCommit: (n: number) => void; onCommit: (n: number) => void;
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange">) { }) {
const [local, setLocal] = useState<string>(String(value)); const [local, setLocal] = useState<string>(String(value));
useEffect(() => { useEffect(() => {
@ -490,7 +603,6 @@ function OffsetInput({
onCommit(0); onCommit(0);
} }
}} }}
{...rest}
/> />
); );
} }

View File

@ -415,7 +415,10 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft.globalTone]); }, [draft.globalTone]);
const [selectedIdx, setSelectedIdx] = useState(0); // Pas de présélection : l'utilisateur choisit en touchant une case.
// Évite la confusion "Étape 1 affichée par défaut alors que je n'ai
// rien cliqué".
const [selectedIdx, setSelectedIdx] = useState(-1);
const updateStep = (idx: number, patch: Partial<DraftStep>) => { const updateStep = (idx: number, patch: Partial<DraftStep>) => {
onChange({ onChange({
@ -434,10 +437,25 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
setSelectedIdx(draft.steps.length); setSelectedIdx(draft.steps.length);
}; };
// Création directe par clic sur une case vide du calendrier.
// L'étape est insérée triée par offset croissant pour que l'ordre
// visuel suive l'ordre temporel.
const addStepAtOffset = (offsetDays: number) => {
const newStep = emptyStep(draft.globalTone, offsetDays);
const merged = [...draft.steps, newStep].sort(
(a, b) => a.offsetDays - b.offsetDays,
);
const newIdx = merged.findIndex(
(s) => s.offsetDays === offsetDays && s === newStep,
);
onChange({ ...draft, steps: merged });
setSelectedIdx(newIdx);
};
const removeStep = (idx: number) => { const removeStep = (idx: number) => {
if (draft.steps.length <= 1) return; if (draft.steps.length <= 1) return;
onChange({ ...draft, steps: draft.steps.filter((_, i) => i !== idx) }); onChange({ ...draft, steps: draft.steps.filter((_, i) => i !== idx) });
setSelectedIdx((cur) => Math.min(cur, draft.steps.length - 2)); setSelectedIdx(-1);
}; };
return ( return (
@ -448,18 +466,20 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
Réglez la cadence Réglez la cadence
</h2> </h2>
<p className="mt-1.5 text-[14px] text-ink-3 leading-relaxed max-w-xl"> <p className="mt-1.5 text-[14px] text-ink-3 leading-relaxed max-w-xl">
Chaque étape est un email programmé. Le chiffre indique le nombre de Touchez une <strong>case colorée</strong> pour modifier une relance,
jours <strong>après l'échéance</strong> (négatif pour rappel avant ou un <strong>jour vide</strong> pour en ajouter une. Le rouge =
échéance). Touchez une étape pour modifier son timing ou sa tonalité. échéance fictive de la facture (juste pour visualiser le timing).
</p> </p>
</div> </div>
<CadenceCalendar <CadenceCalendar
steps={draft.steps} steps={draft.steps}
selectedIndex={selectedIdx} selectedIndex={selectedIdx}
globalTone={draft.globalTone}
onSelectStep={setSelectedIdx} onSelectStep={setSelectedIdx}
onUpdateStep={updateStep} onUpdateStep={updateStep}
onAddStep={addStep} onAddStep={addStep}
onAddStepAtOffset={addStepAtOffset}
onRemoveStep={removeStep} onRemoveStep={removeStep}
/> />
</div> </div>