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:
parent
07712da774
commit
0a3b8523ef
@ -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);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user