refactor(plans/wizard): calendrier compact en outil de navigation

Le calendrier précédent prenait toute la page (1-2 mois empilés en
pleine taille). Refonte en mini-calendrier de navigation :

- Un seul mois affiché à la fois, navigation prev/next via chevrons
- Auto-jump au mois de l'étape sélectionnée pour ne jamais perdre
  la cellule de vue
- Cellules h-9 fixe (plus de aspect-square qui gonflait sur écran large)
- Header compact : juste mois + chevrons (pas de gros titre)
- Légende inline une ligne ("◆ Échéance le X · couleur = tonalité")
- Éditeur compact en dessous : 1 ligne d'en-tête (◆ tonalité · étape N
  · 18 mai · J+3) + 1 ligne 2-cols (input offset + select tonalité +
  bouton supprimer en icône). Plus de Field / hint volumineux.
- Footer : bouton Ajouter en pleine largeur (sauf compteur 3/8 à droite)

Hauteur totale ~400px en pratique vs 700px+ avant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 23:22:13 +02:00
parent a136c54501
commit 149f60dbb0

View File

@ -1,12 +1,10 @@
import { useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { Plus, Trash2, AlertTriangle } from "lucide-react"; import { ChevronLeft, ChevronRight, Plus, Trash2, AlertTriangle } from "lucide-react";
import type { RelanceTone } from "@rubis/shared"; import type { RelanceTone } from "@rubis/shared";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TONE_LABELS } from "@/lib/plans"; import { TONE_LABELS } from "@/lib/plans";
import { Field } from "@/components/ui/Field";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Button } from "@/components/ui/Button";
/** /**
* Étape minimale pour la visu calendrier (subject/body arrivent à * É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 * - Date d'échéance fictive : le 15 du mois prochain.
* l'utilisateur se projette dans le timing réel. * - Auto-jump au mois de l'étape sélectionnée la cellule reste visible.
* - Chaque étape s'affiche sur la case du jour J+offset, colorée par * - Cellule échéance = rubis (info-only).
* tonalité. * - Cellule étape = couleur de fond selon la tonalité, cliquable pour
* - Cliquer une case = sélectionner l'étape, l'éditeur (offset + ton + * sélectionner et éditer juste en dessous.
* 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({ export function CadenceCalendar({
steps, steps,
@ -59,53 +51,76 @@ export function CadenceCalendar({
[steps, dueDate], [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<Date>(
() => 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; const selected = selectedIndex >= 0 ? steps[selectedIndex] : null;
return ( return (
<div className="rounded-card border border-line bg-cream/40 p-4 sm:p-5"> <div className="rounded-card border border-line bg-cream/40 p-3 sm:p-4">
{/* Header */} {/* Header — month nav + compteur */}
<div className="flex items-start justify-between gap-3 mb-4"> <div className="flex items-center justify-between gap-2 mb-3">
<div> <button
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-3 font-semibold"> type="button"
Calendrier onClick={() =>
</p> setViewMonth((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1))
<p className="mt-1 text-[12px] text-ink-3 leading-snug"> }
Pour une facture échue le{" "} className="size-8 flex items-center justify-center rounded-default text-ink-2 hover:bg-cream-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow"
<strong className="text-ink-2 tabular-nums"> aria-label="Mois précédent"
{formatLongDate(dueDate)} >
</strong> <ChevronLeft size={16} />
, voici ce que Rubis enverra. </button>
</p> <p className="font-display text-[14px] font-semibold text-ink capitalize tabular-nums">
</div> {formatMonth(viewMonth)}
<p className="text-[11.5px] text-ink-3 italic tabular-nums shrink-0 mt-0.5">
{steps.length}/8
</p> </p>
<button
type="button"
onClick={() =>
setViewMonth((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1))
}
className="size-8 flex items-center justify-center rounded-default text-ink-2 hover:bg-cream-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow"
aria-label="Mois suivant"
>
<ChevronRight size={16} />
</button>
</div> </div>
{/* Légende */} {/* Grille du mois */}
<Legend />
{/* Grilles mensuelles */}
<div className="mt-4 flex flex-col gap-5">
{months.map((m) => (
<MonthGrid <MonthGrid
key={`${m.getFullYear()}-${m.getMonth()}`} month={viewMonth}
month={m}
dueDate={dueDate} dueDate={dueDate}
steps={steps} steps={steps}
stepDates={stepDates} stepDates={stepDates}
selectedIndex={selectedIndex} selectedIndex={selectedIndex}
onSelectStep={onSelectStep} onSelectStep={onSelectStep}
/> />
))}
</div>
{/* Editor inline pour l'étape sélectionnée */} {/* Légende compacte */}
<div className="mt-5 pt-5 border-t border-line"> <p className="mt-2 text-[10.5px] text-ink-3 leading-snug">
Échéance fictive le{" "}
<strong className="text-ink-2">{formatLongDate(dueDate)}</strong> · couleur =
tonalité
</p>
{/* Éditeur inline compact */}
<div className="mt-3 pt-3 border-t border-line">
{selected ? ( {selected ? (
<StepEditor <CompactEditor
step={selected} step={selected}
index={selectedIndex} index={selectedIndex}
stepDate={stepDates[selectedIndex]!} stepDate={stepDates[selectedIndex]!}
@ -114,65 +129,39 @@ export function CadenceCalendar({
onRemove={() => onRemoveStep(selectedIndex)} onRemove={() => onRemoveStep(selectedIndex)}
/> />
) : ( ) : (
<p className="text-[13px] text-ink-3 italic text-center py-3"> <p className="text-[12.5px] text-ink-3 italic text-center py-1.5">
Touchez une case dans le calendrier pour modifier une relance. Touchez une case colorée pour modifier la relance.
</p> </p>
)} )}
</div> </div>
{/* Ajouter une étape */} {/* Footer — ajouter + compteur */}
<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-4 w-full flex items-center justify-center gap-2", "flex-1 inline-flex items-center justify-center gap-1.5",
"rounded-default border-2 border-dashed border-line bg-transparent", "rounded-default border border-dashed border-line bg-transparent",
"px-4 py-3 text-[13px] 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-4 focus-visible:ring-rubis-glow", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
)} )}
> >
<Plus size={15} aria-hidden="true" /> <Plus size={13} aria-hidden="true" /> Ajouter une étape
Ajouter une étape
</button> </button>
)} )}
<p className="shrink-0 text-[11px] text-ink-3 italic tabular-nums">
{steps.length}/8
</p>
</div>
</div> </div>
); );
} }
// ============================================================================ // ============================================================================
// Légende // Grille d'un mois — cellules compactes, h-fixed pour stabilité visuelle
// ============================================================================
function Legend() {
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[11px] text-ink-3">
<span className="inline-flex items-center gap-1.5">
<span className="size-2.5 rotate-45 bg-rubis border border-rubis" aria-hidden="true" />
Échéance
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2.5 rounded-full bg-rubis-glow border border-rubis" aria-hidden="true" />
Doux
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2.5 rounded-full bg-cream-2 border border-line" aria-hidden="true" />
Standard
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2.5 rounded-full bg-ink border border-ink" aria-hidden="true" />
Ferme
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-2.5 rounded-full bg-rubis-deep border border-rubis-deep" aria-hidden="true" />
Strict
</span>
</div>
);
}
// ============================================================================
// Grille d'un mois
// ============================================================================ // ============================================================================
const TONE_CELL_CLASS: Record<RelanceTone, string> = { const TONE_CELL_CLASS: Record<RelanceTone, string> = {
@ -201,26 +190,21 @@ function MonthGrid({
}) { }) {
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);
const firstDayOfWeek = (firstDay.getDay() + 6) % 7; // L=0...D=6 const firstDayOfWeek = (firstDay.getDay() + 6) % 7;
const daysInMonth = lastDay.getDate();
const cells: (Date | null)[] = []; const cells: (Date | null)[] = [];
for (let i = 0; i < firstDayOfWeek; i++) cells.push(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)); cells.push(new Date(month.getFullYear(), month.getMonth(), d));
} }
while (cells.length % 7 !== 0) cells.push(null); while (cells.length % 7 !== 0) cells.push(null);
return ( return (
<div> <div className="grid grid-cols-7 gap-1">
<p className="font-display text-[14px] font-semibold text-ink-2 mb-2 capitalize">
{formatMonth(month)}
</p>
<div className="grid grid-cols-7 gap-1 sm:gap-1.5">
{WEEKDAY_LABELS.map((label, i) => ( {WEEKDAY_LABELS.map((label, i) => (
<div <div
key={`wd-${i}`} key={`wd-${i}`}
className="text-center text-[10.5px] font-semibold uppercase tracking-wide text-ink-3" className="text-center text-[10px] font-semibold uppercase tracking-wide text-ink-3 pb-0.5"
> >
{label} {label}
</div> </div>
@ -237,7 +221,6 @@ function MonthGrid({
/> />
))} ))}
</div> </div>
</div>
); );
} }
@ -256,36 +239,36 @@ function DayCell({
selectedIndex: number; selectedIndex: number;
onSelectStep?: (idx: number) => void; onSelectStep?: (idx: number) => void;
}) { }) {
if (!date) return <div aria-hidden="true" />; if (!date) return <div aria-hidden="true" className="h-9" />;
const isDue = sameDay(date, dueDate); 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 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 isInteractive = isDue || step !== null; const isToday = sameDay(date, new Date());
// Cellule "due date" : ◆ rubis sur fond glow, pas cliquable (info-only) // Cellule échéance — info-only, pas cliquable
if (isDue) { if (isDue) {
return ( return (
<div <div
className={cn( className={cn(
"relative aspect-square flex flex-col items-center justify-center rounded-default", "h-9 flex items-center justify-center rounded-default",
"border-2 border-rubis bg-rubis-glow text-rubis-deep", "border-2 border-rubis bg-rubis-glow text-rubis-deep relative",
"shadow-rubis",
)} )}
aria-label={`Échéance le ${formatLongDate(date)}`} aria-label={`Échéance le ${formatLongDate(date)}`}
> >
<span className="size-3 rotate-45 bg-rubis" aria-hidden="true" /> <span
<span className="text-[10.5px] font-bold tabular-nums mt-0.5"> className="absolute top-0.5 right-1 size-1.5 rotate-45 bg-rubis"
aria-hidden="true"
/>
<span className="text-[12px] font-bold tabular-nums">
{date.getDate()} {date.getDate()}
</span> </span>
</div> </div>
); );
} }
// Cellule "étape" : couleur par ton, cliquable // Cellule étape — cliquable
if (step) { if (step) {
return ( return (
<button <button
@ -294,17 +277,13 @@ function DayCell({
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)}`}
className={cn( className={cn(
"relative aspect-square flex flex-col items-center justify-center rounded-default", "h-9 flex items-center justify-center rounded-default",
"border-2 transition-all", "border-2 transition-all relative",
"focus-visible:outline-none focus-visible:ring-4 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-4 ring-rubis-glow scale-[1.04]", isSelected && "ring-2 ring-rubis-glow scale-[1.06] z-10",
)} )}
> >
<span className="text-[10px] font-semibold opacity-70 tabular-nums">
J{step.offsetDays >= 0 ? "+" : ""}
{step.offsetDays}
</span>
<span className="text-[12px] font-bold tabular-nums"> <span className="text-[12px] font-bold tabular-nums">
{date.getDate()} {date.getDate()}
</span> </span>
@ -312,13 +291,13 @@ function DayCell({
); );
} }
// Cellule normale (jour sans event) — affichage muet // Jour normal — discret, today souligné
return ( return (
<div <div
className={cn( className={cn(
"aspect-square flex items-center justify-center rounded-default", "h-9 flex items-center justify-center rounded-default",
"text-[12px] tabular-nums text-ink-3", "text-[12px] tabular-nums text-ink-3",
isSameDay(date, new Date()) && "ring-1 ring-rubis-glow", isToday && "ring-1 ring-rubis-glow",
)} )}
> >
{date.getDate()} {date.getDate()}
@ -327,9 +306,9 @@ function DayCell({
} }
// ============================================================================ // ============================================================================
// Editor inline // Éditeur compact — une ligne d'en-tête + une ligne de champs
// ============================================================================ // ============================================================================
function StepEditor({ function CompactEditor({
step, step,
index, index,
stepDate, stepDate,
@ -345,51 +324,48 @@ function StepEditor({
onRemove: () => void; onRemove: () => void;
}) { }) {
return ( return (
<div> <div className="space-y-2.5">
<div className="flex items-center justify-between gap-3 mb-3"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span <span
aria-hidden="true" aria-hidden="true"
className={cn( className={cn(
"shrink-0 size-3 rounded-full border", "shrink-0 size-2.5 rounded-full border",
TONE_CELL_CLASS[step.tone], TONE_CELL_CLASS[step.tone],
)} )}
/> />
<p className="font-display text-[14px] font-semibold text-ink truncate"> <p className="font-display text-[13px] font-semibold text-ink truncate">
Étape {index + 1} · {formatLongDate(stepDate)} Étape {index + 1} · {formatShortDate(stepDate)} · J
{step.offsetDays >= 0 ? "+" : ""}
{step.offsetDays}
</p> </p>
</div> </div>
{canRemove && ( {canRemove && (
<Button <button
variant="ghost" type="button"
size="sm"
onClick={onRemove} onClick={onRemove}
className="text-rubis-deep hover:bg-rubis-glow/40" 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"
> >
<Trash2 size={14} /> Supprimer <Trash2 size={14} />
</Button> </button>
)} )}
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-[100px,1fr] gap-2">
<Field
label="Décalage (jours)"
htmlFor={`step-offset-${index}`}
hint="Négatif = avant échéance. 0 = jour J. 30 = J+30."
>
<OffsetInput <OffsetInput
id={`step-offset-${index}`} id={`step-offset-${index}`}
value={step.offsetDays} value={step.offsetDays}
onCommit={(n) => onUpdate({ offsetDays: n })} onCommit={(n) => onUpdate({ offsetDays: n })}
aria-label="Décalage en jours"
/> />
</Field>
<Field label="Tonalité" htmlFor={`step-tone-${index}`}>
<select <select
id={`step-tone-${index}`} id={`step-tone-${index}`}
aria-label="Tonalité"
className={cn( className={cn(
"h-11 w-full rounded-default border border-line bg-white px-3", "h-10 w-full rounded-default border border-line bg-white px-2.5",
"text-[14px] text-ink", "text-[13.5px] text-ink",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow focus-visible:border-rubis", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow focus-visible:border-rubis",
)} )}
value={step.tone} value={step.tone}
onChange={(e) => { onChange={(e) => {
@ -406,13 +382,11 @@ function StepEditor({
</option> </option>
))} ))}
</select> </select>
</Field>
</div> </div>
{step.tone === "mise_en_demeure" && ( {step.tone === "mise_en_demeure" && (
<p className="mt-3 inline-flex items-center gap-2 text-[12px] text-rubis-deep"> <p className="inline-flex items-center gap-1.5 text-[11.5px] text-rubis-deep">
<AlertTriangle size={12} /> Validation manuelle obligatoire avant <AlertTriangle size={11} /> Validation manuelle obligatoire avant envoi.
envoi (sécurité juridique).
</p> </p>
)} )}
</div> </div>
@ -436,10 +410,6 @@ function sameDay(a: Date, b: Date): boolean {
); );
} }
function isSameDay(a: Date, b: Date): boolean {
return sameDay(a, b);
}
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;
@ -447,20 +417,6 @@ function lastIndexOf<T>(arr: T[], pred: (x: T) => boolean): number {
return -1; 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", { const FR_MONTH = new Intl.DateTimeFormat("fr-FR", {
month: "long", month: "long",
year: "numeric", year: "numeric",
@ -472,6 +428,11 @@ const FR_LONG = new Intl.DateTimeFormat("fr-FR", {
month: "long", month: "long",
}); });
const FR_SHORT = new Intl.DateTimeFormat("fr-FR", {
day: "numeric",
month: "short",
});
function formatMonth(d: Date): string { function formatMonth(d: Date): string {
return FR_MONTH.format(d); return FR_MONTH.format(d);
} }
@ -480,20 +441,23 @@ function formatLongDate(d: Date): string {
return FR_LONG.format(d); return FR_LONG.format(d);
} }
function formatShortDate(d: Date): string {
return FR_SHORT.format(d);
}
// ============================================================================ // ============================================================================
// OffsetInput (string-controlled, accepte vide/`-` intermédiaires) // OffsetInput (string-controlled, accepte vide/`-` intermédiaires)
// ============================================================================ // ============================================================================
import { useEffect, useState } from "react";
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(() => {
@ -506,6 +470,7 @@ function OffsetInput({
type="text" type="text"
inputMode="numeric" inputMode="numeric"
pattern="-?[0-9]*" pattern="-?[0-9]*"
className="h-10 text-center tabular-nums"
value={local} value={local}
onChange={(e) => { onChange={(e) => {
const next = e.target.value; const next = e.target.value;
@ -525,6 +490,7 @@ function OffsetInput({
onCommit(0); onCommit(0);
} }
}} }}
{...rest}
/> />
); );
} }