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 { 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 { 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";
import { TONE_LABELS } from "@/lib/plans"; 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 à * É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({ function CompactEditor({
step, step,
stepDate, stepDate,
@ -419,18 +448,22 @@ function CompactEditor({
onUpdate: (patch: Partial<DraftStepLite>) => void; onUpdate: (patch: Partial<DraftStepLite>) => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const HeaderIcon = TONE_ICON[step.tone];
return ( return (
<div className="space-y-2.5"> <div className="space-y-3">
{/* Header explicite : on sait quelle relance on édite */} {/* Header : pastille tonalité (icône + couleur) + date + supprimer */}
<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
aria-hidden="true" aria-hidden="true"
className={cn( 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], TONE_CELL_CLASS[step.tone],
)} )}
/> >
<HeaderIcon size={14} aria-hidden="true" />
</span>
<p className="text-[13px] text-ink-2 truncate leading-tight"> <p className="text-[13px] text-ink-2 truncate leading-tight">
Relance du{" "} Relance du{" "}
<strong className="font-display font-semibold text-ink"> <strong className="font-display font-semibold text-ink">
@ -455,41 +488,43 @@ function CompactEditor({
)} )}
</div> </div>
<div className="grid grid-cols-[110px,1fr] gap-2 items-end"> {/* Sélecteur de tonalité : 4 boutons icône, plus visuel qu'un select */}
<label className="flex flex-col gap-1"> <div>
<span className="text-[11px] font-semibold text-ink-3"> <p className="text-[11px] font-semibold text-ink-3 mb-1.5">Tonalité</p>
Décalage <div className="grid grid-cols-4 gap-1.5">
</span> {TONE_ORDER.map((t) => {
<OffsetInput const Icon = TONE_ICON[t];
id={`step-offset`} const isActive = step.tone === t;
value={step.offsetDays} return (
onCommit={(n) => onUpdate({ offsetDays: n })} <button
/> key={t}
</label> type="button"
<label className="flex flex-col gap-1"> onClick={() =>
<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({ onUpdate({
tone, tone: t,
requiresManualValidation: tone === "mise_en_demeure", 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",
)}
> >
{(Object.keys(TONE_LABELS) as RelanceTone[]).map((t) => ( <Icon size={16} aria-hidden="true" />
<option key={t} value={t}> <span className="text-[10.5px] font-semibold leading-tight">
{TONE_LABELS[t]} {TONE_LABELS[t]}
</option> </span>
))} </button>
</select> );
</label> })}
</div>
</div> </div>
{step.tone === "mise_en_demeure" && ( {step.tone === "mise_en_demeure" && (
@ -559,50 +594,3 @@ function formatShortDate(d: Date): string {
return FR_SHORT.format(d); 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} steps={draft.steps}
selectedIndex={selectedIdx} selectedIndex={selectedIdx}
globalTone={draft.globalTone} 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} onUpdateStep={updateStep}
onAddStep={addStep} onAddStep={addStep}
onAddStepAtOffset={addStepAtOffset} onAddStepAtOffset={addStepAtOffset}