feat(plans/wizard): éditeur avec icônes de tonalité + toggle de sélection

- Champ Décalage retiré : on change le timing en cliquant une autre
  case du calendrier (delete + click ailleurs), c'est plus aligné
  avec la métaphore calendrier
- Tonalité passée d'un select à un groupe de 4 boutons icônes :
  · Doux → Smile (sourire chaleureux)
  · Standard → MessageCircle (bulle de conversation polie)
  · Ferme → AlertTriangle (alerte mesurée)
  · Strict → Gavel (marteau de juge)
  Chaque bouton actif prend la couleur de fond de sa tonalité, plus
  visuel et compact qu'un dropdown
- Header de l'éditeur : la pastille colorée devient une pastille avec
  l'icône de tonalité dedans → on lit la tonalité d'un coup d'œil
- Toggle : re-cliquer la case déjà sélectionnée la désélectionne
  (retour à l'état "vue d'ensemble" avec le hint), au lieu d'avoir
  une sélection permanente

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 23:34:33 +02:00
parent 07712da774
commit 0a3b8523ef
2 changed files with 82 additions and 90 deletions

View File

@ -1,11 +1,32 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronLeft, ChevronRight, Plus, Trash2, AlertTriangle } from "lucide-react";
import {
ChevronLeft,
ChevronRight,
Plus,
Trash2,
AlertTriangle,
Smile,
MessageCircle,
Gavel,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
import type { RelanceTone } from "@rubis/shared";
import { cn } from "@/lib/utils";
import { TONE_LABELS } from "@/lib/plans";
import { Input } from "@/components/ui/Input";
/**
* Icône qui illustre chaque tonalité. Cohérent avec l'escalade émotionnelle :
* sourire pour amical, bulle de conversation pour standard, alerte pour
* ferme, marteau de juge pour mise en demeure.
*/
const TONE_ICON: Record<RelanceTone, LucideIcon> = {
amical: Smile,
courtois: MessageCircle,
ferme: AlertTriangle,
mise_en_demeure: Gavel,
};
/**
* Étape minimale pour la visu calendrier (subject/body arrivent à
@ -404,8 +425,16 @@ function DayCell({
}
// ============================================================================
// Éditeur compact — header explicite + 1 ligne de champs
// Éditeur compact — header avec icône de tonalité + sélecteur 4 icônes
// ============================================================================
const TONE_ORDER: RelanceTone[] = [
"amical",
"courtois",
"ferme",
"mise_en_demeure",
];
function CompactEditor({
step,
stepDate,
@ -419,18 +448,22 @@ function CompactEditor({
onUpdate: (patch: Partial<DraftStepLite>) => void;
onRemove: () => void;
}) {
const HeaderIcon = TONE_ICON[step.tone];
return (
<div className="space-y-2.5">
{/* Header explicite : on sait quelle relance on édite */}
<div className="space-y-3">
{/* Header : pastille tonalité (icône + couleur) + date + supprimer */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span
aria-hidden="true"
className={cn(
"shrink-0 size-2.5 rounded-full border",
"shrink-0 flex items-center justify-center size-7 rounded-full border-2",
TONE_CELL_CLASS[step.tone],
)}
/>
>
<HeaderIcon size={14} aria-hidden="true" />
</span>
<p className="text-[13px] text-ink-2 truncate leading-tight">
Relance du{" "}
<strong className="font-display font-semibold text-ink">
@ -455,41 +488,43 @@ function CompactEditor({
)}
</div>
<div className="grid grid-cols-[110px,1fr] gap-2 items-end">
<label className="flex flex-col gap-1">
<span className="text-[11px] font-semibold text-ink-3">
Décalage
</span>
<OffsetInput
id={`step-offset`}
value={step.offsetDays}
onCommit={(n) => onUpdate({ offsetDays: n })}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11px] font-semibold text-ink-3">Tonalité</span>
<select
className={cn(
"h-10 w-full rounded-default border border-line bg-white px-2.5",
"text-[13.5px] text-ink",
"focus-visible:outline-none focus-visible:ring-2 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>
</label>
{/* Sélecteur de tonalité : 4 boutons icône, plus visuel qu'un select */}
<div>
<p className="text-[11px] font-semibold text-ink-3 mb-1.5">Tonalité</p>
<div className="grid grid-cols-4 gap-1.5">
{TONE_ORDER.map((t) => {
const Icon = TONE_ICON[t];
const isActive = step.tone === t;
return (
<button
key={t}
type="button"
onClick={() =>
onUpdate({
tone: t,
requiresManualValidation: t === "mise_en_demeure",
})
}
aria-pressed={isActive}
aria-label={TONE_LABELS[t]}
title={TONE_LABELS[t]}
className={cn(
"flex flex-col items-center justify-center gap-0.5 py-2 px-1",
"rounded-default border-2 transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
isActive
? cn(TONE_CELL_CLASS[t], "ring-2 ring-rubis-glow scale-[1.04]")
: "border-line bg-white text-ink-2 hover:border-ink-3 hover:bg-cream-2/50",
)}
>
<Icon size={16} aria-hidden="true" />
<span className="text-[10.5px] font-semibold leading-tight">
{TONE_LABELS[t]}
</span>
</button>
);
})}
</div>
</div>
{step.tone === "mise_en_demeure" && (
@ -559,50 +594,3 @@ function formatShortDate(d: Date): string {
return FR_SHORT.format(d);
}
// ============================================================================
// OffsetInput
// ============================================================================
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]*"
className="h-10 text-center tabular-nums"
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);
}
}}
/>
);
}

View File

@ -476,7 +476,11 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
steps={draft.steps}
selectedIndex={selectedIdx}
globalTone={draft.globalTone}
onSelectStep={setSelectedIdx}
// Toggle : re-cliquer une étape déjà sélectionnée la désélectionne
// pour revenir à la vue d'ensemble.
onSelectStep={(idx) =>
setSelectedIdx((cur) => (cur === idx ? -1 : idx))
}
onUpdateStep={updateStep}
onAddStep={addStep}
onAddStepAtOffset={addStepAtOffset}