feat(plans/wizard): cadence sur calendrier mensuel avec tonalités
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 <noreply@anthropic.com>
This commit is contained in:
parent
05ad3fa5cf
commit
a136c54501
530
apps/web/src/components/plans/wizard/CadenceCalendar.tsx
Normal file
530
apps/web/src/components/plans/wizard/CadenceCalendar.tsx
Normal file
@ -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<DraftStepLite>) => 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 (
|
||||||
|
<div className="rounded-card border border-line bg-cream/40 p-4 sm:p-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-3 font-semibold">
|
||||||
|
Calendrier
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[12px] text-ink-3 leading-snug">
|
||||||
|
Pour une facture échue le{" "}
|
||||||
|
<strong className="text-ink-2 tabular-nums">
|
||||||
|
{formatLongDate(dueDate)}
|
||||||
|
</strong>
|
||||||
|
, voici ce que Rubis enverra.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11.5px] text-ink-3 italic tabular-nums shrink-0 mt-0.5">
|
||||||
|
{steps.length}/8
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Légende */}
|
||||||
|
<Legend />
|
||||||
|
|
||||||
|
{/* Grilles mensuelles */}
|
||||||
|
<div className="mt-4 flex flex-col gap-5">
|
||||||
|
{months.map((m) => (
|
||||||
|
<MonthGrid
|
||||||
|
key={`${m.getFullYear()}-${m.getMonth()}`}
|
||||||
|
month={m}
|
||||||
|
dueDate={dueDate}
|
||||||
|
steps={steps}
|
||||||
|
stepDates={stepDates}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onSelectStep={onSelectStep}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor inline pour l'étape sélectionnée */}
|
||||||
|
<div className="mt-5 pt-5 border-t border-line">
|
||||||
|
{selected ? (
|
||||||
|
<StepEditor
|
||||||
|
step={selected}
|
||||||
|
index={selectedIndex}
|
||||||
|
stepDate={stepDates[selectedIndex]!}
|
||||||
|
canRemove={steps.length > 1}
|
||||||
|
onUpdate={(patch) => onUpdateStep?.(selectedIndex, patch)}
|
||||||
|
onRemove={() => onRemoveStep(selectedIndex)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-[13px] text-ink-3 italic text-center py-3">
|
||||||
|
Touchez une case dans le calendrier pour modifier une relance.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ajouter une étape */}
|
||||||
|
{steps.length < 8 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddStep}
|
||||||
|
className={cn(
|
||||||
|
"mt-4 w-full flex items-center justify-center gap-2",
|
||||||
|
"rounded-default border-2 border-dashed border-line bg-transparent",
|
||||||
|
"px-4 py-3 text-[13px] font-medium text-ink-3",
|
||||||
|
"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",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus size={15} aria-hidden="true" />
|
||||||
|
Ajouter une étape
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Légende
|
||||||
|
// ============================================================================
|
||||||
|
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> = {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<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) => (
|
||||||
|
<div
|
||||||
|
key={`wd-${i}`}
|
||||||
|
className="text-center text-[10.5px] font-semibold uppercase tracking-wide text-ink-3"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{cells.map((date, i) => (
|
||||||
|
<DayCell
|
||||||
|
key={i}
|
||||||
|
date={date}
|
||||||
|
dueDate={dueDate}
|
||||||
|
steps={steps}
|
||||||
|
stepDates={stepDates}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onSelectStep={onSelectStep}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div aria-hidden="true" />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative aspect-square flex flex-col items-center justify-center rounded-default",
|
||||||
|
"border-2 border-rubis bg-rubis-glow text-rubis-deep",
|
||||||
|
"shadow-rubis",
|
||||||
|
)}
|
||||||
|
aria-label={`Échéance le ${formatLongDate(date)}`}
|
||||||
|
>
|
||||||
|
<span className="size-3 rotate-45 bg-rubis" aria-hidden="true" />
|
||||||
|
<span className="text-[10.5px] font-bold tabular-nums mt-0.5">
|
||||||
|
{date.getDate()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cellule "étape" : couleur par ton, cliquable
|
||||||
|
if (step) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectStep?.(stepIdx)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
aria-label={`${TONE_LABELS[step.tone]}, J${step.offsetDays >= 0 ? "+" : ""}${step.offsetDays}, le ${formatLongDate(date)}`}
|
||||||
|
className={cn(
|
||||||
|
"relative aspect-square flex flex-col items-center justify-center rounded-default",
|
||||||
|
"border-2 transition-all",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||||
|
TONE_CELL_CLASS[step.tone],
|
||||||
|
isSelected && "ring-4 ring-rubis-glow scale-[1.04]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{date.getDate()}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cellule normale (jour sans event) — affichage muet
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"aspect-square flex items-center justify-center rounded-default",
|
||||||
|
"text-[12px] tabular-nums text-ink-3",
|
||||||
|
isSameDay(date, new Date()) && "ring-1 ring-rubis-glow",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{date.getDate()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Editor inline
|
||||||
|
// ============================================================================
|
||||||
|
function StepEditor({
|
||||||
|
step,
|
||||||
|
index,
|
||||||
|
stepDate,
|
||||||
|
canRemove,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
step: DraftStepLite;
|
||||||
|
index: number;
|
||||||
|
stepDate: Date;
|
||||||
|
canRemove: boolean;
|
||||||
|
onUpdate: (patch: Partial<DraftStepLite>) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 size-3 rounded-full border",
|
||||||
|
TONE_CELL_CLASS[step.tone],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="font-display text-[14px] font-semibold text-ink truncate">
|
||||||
|
Étape {index + 1} · {formatLongDate(stepDate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canRemove && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="text-rubis-deep hover:bg-rubis-glow/40"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> Supprimer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Field
|
||||||
|
label="Décalage (jours)"
|
||||||
|
htmlFor={`step-offset-${index}`}
|
||||||
|
hint="Négatif = avant échéance. 0 = jour J. 30 = J+30."
|
||||||
|
>
|
||||||
|
<OffsetInput
|
||||||
|
id={`step-offset-${index}`}
|
||||||
|
value={step.offsetDays}
|
||||||
|
onCommit={(n) => onUpdate({ offsetDays: n })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Tonalité" htmlFor={`step-tone-${index}`}>
|
||||||
|
<select
|
||||||
|
id={`step-tone-${index}`}
|
||||||
|
className={cn(
|
||||||
|
"h-11 w-full rounded-default border border-line bg-white px-3",
|
||||||
|
"text-[14px] text-ink",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow focus-visible:border-rubis",
|
||||||
|
)}
|
||||||
|
value={step.tone}
|
||||||
|
onChange={(e) => {
|
||||||
|
const tone = e.target.value as RelanceTone;
|
||||||
|
onUpdate({
|
||||||
|
tone,
|
||||||
|
requiresManualValidation: tone === "mise_en_demeure",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(Object.keys(TONE_LABELS) as RelanceTone[]).map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{TONE_LABELS[t]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step.tone === "mise_en_demeure" && (
|
||||||
|
<p className="mt-3 inline-flex items-center gap-2 text-[12px] text-rubis-deep">
|
||||||
|
<AlertTriangle size={12} /> Validation manuelle obligatoire avant
|
||||||
|
envoi (sécurité juridique).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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<T>(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>(String(value));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocal(String(value));
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="-?[0-9]*"
|
||||||
|
value={local}
|
||||||
|
onChange={(e) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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<RelanceTone, string> = {
|
|
||||||
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<DraftStepLite>) => void;
|
|
||||||
onAddStep: () => void;
|
|
||||||
onRemoveStep: (idx: number) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-card border border-line bg-cream/40 p-4 sm:p-5">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-3 font-semibold">
|
|
||||||
Cadence
|
|
||||||
</p>
|
|
||||||
<p className="text-[11.5px] text-ink-3 italic tabular-nums">
|
|
||||||
{steps.length} étape{steps.length > 1 ? "s" : ""} · max 8
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol className="flex flex-col gap-1.5">
|
|
||||||
{steps.map((step, idx) => {
|
|
||||||
const isSelected = idx === selectedIndex;
|
|
||||||
return (
|
|
||||||
<li key={idx}>
|
|
||||||
<StepCard
|
|
||||||
step={step}
|
|
||||||
index={idx}
|
|
||||||
selected={isSelected}
|
|
||||||
canRemove={steps.length > 1}
|
|
||||||
onSelect={() => onSelectStep?.(idx)}
|
|
||||||
onUpdate={(patch) => onUpdateStep?.(idx, patch)}
|
|
||||||
onRemove={() => onRemoveStep(idx)}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
{steps.length < 8 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAddStep}
|
|
||||||
className={cn(
|
|
||||||
"mt-2 w-full flex items-center justify-center gap-2",
|
|
||||||
"rounded-default border-2 border-dashed border-line bg-transparent",
|
|
||||||
"px-4 py-3 text-[13px] font-medium text-ink-3",
|
|
||||||
"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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Plus size={15} aria-hidden="true" />
|
|
||||||
Ajouter une étape
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepCard({
|
|
||||||
step,
|
|
||||||
index,
|
|
||||||
selected,
|
|
||||||
canRemove,
|
|
||||||
onSelect,
|
|
||||||
onUpdate,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
step: DraftStepLite;
|
|
||||||
index: number;
|
|
||||||
selected: boolean;
|
|
||||||
canRemove: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
onUpdate: (patch: Partial<DraftStepLite>) => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-card border bg-white transition-colors",
|
|
||||||
selected
|
|
||||||
? "border-rubis shadow-rubis"
|
|
||||||
: "border-line hover:border-ink-3",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Header — toujours visible, cliquable pour sélectionner */}
|
|
||||||
<div className="flex items-center gap-3 px-4 py-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSelect}
|
|
||||||
className="flex-1 flex items-center gap-3 min-w-0 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow rounded-default"
|
|
||||||
aria-pressed={selected}
|
|
||||||
>
|
|
||||||
{/* Numéro d'ordre */}
|
|
||||||
<span className="shrink-0 flex size-7 items-center justify-center rounded-full bg-cream-2 text-ink-2 font-display text-[12px] font-bold tabular-nums">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* ◆ accent coloré par tonalité — signature de marque */}
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 size-2.5 rotate-45 border",
|
|
||||||
TONE_DOT_CLASS[step.tone],
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Offset + ton */}
|
|
||||||
<div className="flex-1 min-w-0 flex items-baseline gap-2">
|
|
||||||
<span className="font-display text-[15px] font-bold text-ink tabular-nums">
|
|
||||||
J{step.offsetDays >= 0 ? "+" : ""}
|
|
||||||
{step.offsetDays}
|
|
||||||
</span>
|
|
||||||
<span className="text-ink-3">·</span>
|
|
||||||
<span className="text-[13.5px] text-ink-2 truncate">
|
|
||||||
{TONE_LABELS[step.tone]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{canRemove && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemove();
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 flex size-9 items-center justify-center rounded-full",
|
|
||||||
"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={`Retirer l'étape ${index + 1}`}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Éditeur inline — apparaît dans la card sélectionnée */}
|
|
||||||
{selected && (
|
|
||||||
<div className="border-t border-line bg-cream-2/30 px-4 py-4">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
label="Décalage (jours)"
|
|
||||||
htmlFor={`step-offset-${index}`}
|
|
||||||
hint="Négatif = avant échéance. 0 = jour J. 30 = J+30."
|
|
||||||
>
|
|
||||||
<OffsetInput
|
|
||||||
id={`step-offset-${index}`}
|
|
||||||
value={step.offsetDays}
|
|
||||||
onCommit={(n) => onUpdate({ offsetDays: n })}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Tonalité" htmlFor={`step-tone-${index}`}>
|
|
||||||
<select
|
|
||||||
id={`step-tone-${index}`}
|
|
||||||
className={cn(
|
|
||||||
"h-11 w-full rounded-default border border-line bg-white px-3",
|
|
||||||
"text-[14px] text-ink",
|
|
||||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow focus-visible:border-rubis",
|
|
||||||
)}
|
|
||||||
value={step.tone}
|
|
||||||
onChange={(e) => {
|
|
||||||
const tone = e.target.value as RelanceTone;
|
|
||||||
onUpdate({
|
|
||||||
tone,
|
|
||||||
requiresManualValidation: tone === "mise_en_demeure",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(Object.keys(TONE_LABELS) as RelanceTone[]).map((t) => (
|
|
||||||
<option key={t} value={t}>
|
|
||||||
{TONE_LABELS[t]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
{step.tone === "mise_en_demeure" && (
|
|
||||||
<p className="mt-3 inline-flex items-center gap-2 text-[12px] text-rubis-deep">
|
|
||||||
<AlertTriangle size={12} /> Validation manuelle obligatoire
|
|
||||||
avant envoi (sécurité juridique).
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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>(String(value));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocal(String(value));
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
id={id}
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
pattern="-?[0-9]*"
|
|
||||||
value={local}
|
|
||||||
onChange={(e) => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -25,7 +25,7 @@ import { Field } from "@/components/ui/Field";
|
|||||||
import { Input } from "@/components/ui/Input";
|
import { Input } from "@/components/ui/Input";
|
||||||
import { Textarea } from "@/components/ui/Textarea";
|
import { Textarea } from "@/components/ui/Textarea";
|
||||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
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 { EmailPreview } from "@/components/plans/wizard/EmailPreview";
|
||||||
import { AiGenerateModal } from "@/components/plans/wizard/AiGenerateModal";
|
import { AiGenerateModal } from "@/components/plans/wizard/AiGenerateModal";
|
||||||
|
|
||||||
@ -454,7 +454,7 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CadenceTimeline
|
<CadenceCalendar
|
||||||
steps={draft.steps}
|
steps={draft.steps}
|
||||||
selectedIndex={selectedIdx}
|
selectedIndex={selectedIdx}
|
||||||
onSelectStep={setSelectedIdx}
|
onSelectStep={setSelectedIdx}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user