refactor(plans/wizard): refonte cadence en liste verticale lisible (mobile + desktop)
Le précédent layout avec ◆ rotatés en timeline causait des collisions visuelles sur mobile (les coins du diamant débordaient sur les labels et la ligne de connexion). Inutilisable. Nouvelle approche, inspirée des éditeurs de séquences éprouvés (Mailchimp, Klaviyo) : liste verticale de cards de step, identique sur mobile et desktop. Plus prévisible, plus lisible, mêmes tap targets. - Chaque step = card cliquable avec : numéro d'ordre, ◆ accent (petit, coloré par tonalité, signature de marque sans gêner la lecture), J+X, label de tonalité, bouton retirer aligné dans le flux - La card sélectionnée (rubis border + shadow) révèle l'éditeur inline (Décalage + 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 taper - Bouton "Ajouter une étape" en pleine largeur en pied de liste - L'avertissement mise-en-demeure (validation manuelle) s'affiche dans la card sélectionnée - OffsetInput déplacé dans CadenceTimeline avec le reste de l'éditeur ; duplication supprimée du fichier route Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
24cbf35902
commit
05ad3fa5cf
@ -1,12 +1,14 @@
|
||||
import { Plus, X } from "lucide-react";
|
||||
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 timeline (tous les champs ne sont pas connus à
|
||||
* l'étape 2 du wizard — subject/body arrivent à l'étape 3).
|
||||
* Étape minimale pour la liste (subject/body arrivent à l'étape 3 du wizard).
|
||||
*/
|
||||
export type DraftStepLite = {
|
||||
offsetDays: number;
|
||||
@ -14,158 +16,269 @@ export type DraftStepLite = {
|
||||
requiresManualValidation?: boolean;
|
||||
};
|
||||
|
||||
const TONE_NODE_CLASS: Record<RelanceTone, string> = {
|
||||
amical: "bg-rubis-glow border-rubis text-rubis-deep",
|
||||
courtois: "bg-cream-2 border-line text-ink",
|
||||
ferme: "bg-ink text-cream border-ink",
|
||||
mise_en_demeure: "bg-rubis-deep text-cream border-rubis-deep",
|
||||
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",
|
||||
};
|
||||
|
||||
/**
|
||||
* Timeline horizontale (ou verticale en mobile) de la cadence d'un plan.
|
||||
* 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.
|
||||
*
|
||||
* - Chaque étape = un nœud diamant ◆ coloré selon le ton
|
||||
* - Hover = tooltip avec le label
|
||||
* - Bouton + à la fin (et entre étapes en mode insertion)
|
||||
* - Bouton × en hover sur chaque nœud
|
||||
* 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.
|
||||
*
|
||||
* On affiche l'offset (J+3, J+10) sous chaque nœud. Un rail rubis-glow
|
||||
* relie les nœuds — c'est l'identité visuelle ◆ rubis appliquée à un
|
||||
* calendrier.
|
||||
* 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,
|
||||
selectedIndex = -1,
|
||||
onSelectStep,
|
||||
}: {
|
||||
steps: DraftStepLite[];
|
||||
selectedIndex?: number;
|
||||
onSelectStep?: (idx: number) => void;
|
||||
onUpdateStep?: (idx: number, patch: Partial<DraftStepLite>) => void;
|
||||
onAddStep: () => void;
|
||||
onRemoveStep: (idx: number) => void;
|
||||
selectedIndex?: number;
|
||||
onSelectStep?: (idx: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-card border border-line bg-cream/40 p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4 sm:mb-5">
|
||||
<p className="text-[12px] uppercase tracking-[0.1em] text-ink-3 font-semibold">
|
||||
<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">
|
||||
<p className="text-[11.5px] text-ink-3 italic tabular-nums">
|
||||
{steps.length} étape{steps.length > 1 ? "s" : ""} · max 8
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{/* Rail horizontal en desktop uniquement. En mobile, un trait vertical
|
||||
plus court reliera chaque nœud à son label dans la liste verticale. */}
|
||||
{steps.length > 0 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="hidden lg:block absolute left-6 right-6 top-[26px] h-px bg-rubis-glow"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ol className="relative flex flex-col lg:flex-row lg:items-start lg:gap-2">
|
||||
{steps.map((step, idx) => {
|
||||
const isSelected = idx === selectedIndex;
|
||||
const isLast = idx === steps.length - 1 && steps.length >= 8;
|
||||
return (
|
||||
<li
|
||||
key={idx}
|
||||
className={cn(
|
||||
"group relative flex items-center gap-3 lg:flex-1 lg:flex-col lg:items-center lg:gap-0",
|
||||
// Ligne verticale entre nœuds en mobile (sauf le dernier)
|
||||
!isLast &&
|
||||
"lg:after:hidden after:absolute after:left-[25px] after:top-[52px] after:bottom-[-12px] after:w-px after:bg-rubis-glow",
|
||||
idx > 0 && "mt-3 lg:mt-0",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectStep?.(idx)}
|
||||
className={cn(
|
||||
"shrink-0 relative flex size-13 items-center justify-center rotate-45 border-2 transition-all",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||
TONE_NODE_CLASS[step.tone],
|
||||
isSelected && "ring-4 ring-rubis-glow scale-105",
|
||||
)}
|
||||
aria-label={`Étape J${step.offsetDays >= 0 ? "+" : ""}${step.offsetDays}, ${TONE_LABELS[step.tone]}`}
|
||||
style={{ width: 52, height: 52 }}
|
||||
>
|
||||
<span className="-rotate-45 font-display text-[13px] font-bold tabular-nums">
|
||||
{step.offsetDays >= 0 ? "+" : ""}
|
||||
{step.offsetDays}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex-1 lg:flex-none flex flex-col items-start lg:items-center min-w-0 lg:mt-2">
|
||||
<p className="font-display text-[13px] font-semibold text-ink truncate max-w-[180px] lg:max-w-[120px]">
|
||||
{TONE_LABELS[step.tone]}
|
||||
</p>
|
||||
<p className="text-[11px] text-ink-3 tabular-nums">
|
||||
J{step.offsetDays >= 0 ? "+" : ""}
|
||||
{step.offsetDays}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{steps.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveStep(idx);
|
||||
}}
|
||||
className={cn(
|
||||
// Mobile : visible à droite de la ligne, plus accessible
|
||||
"shrink-0 lg:absolute lg:-top-1 lg:right-auto lg:left-[calc(50%+18px)]",
|
||||
"flex size-7 lg:size-5 items-center justify-center rounded-full",
|
||||
"bg-white border border-line text-ink-3",
|
||||
"lg:opacity-0 lg:group-hover:opacity-100 lg:focus:opacity-100",
|
||||
"hover:text-rubis-deep hover:border-rubis transition-opacity",
|
||||
)}
|
||||
aria-label="Retirer cette étape"
|
||||
>
|
||||
<X size={13} className="lg:hidden" />
|
||||
<X size={11} className="hidden lg:block" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* + add at the end */}
|
||||
{steps.length < 8 && (
|
||||
<li
|
||||
className={cn(
|
||||
"flex items-center gap-3 lg:flex-col lg:items-center lg:gap-0",
|
||||
steps.length > 0 && "mt-3 lg:mt-0",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddStep}
|
||||
className={cn(
|
||||
"shrink-0 flex size-13 items-center justify-center rounded-full",
|
||||
"border-2 border-dashed border-line bg-white text-ink-3",
|
||||
"hover:border-rubis hover:text-rubis transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||
)}
|
||||
style={{ width: 52, height: 52 }}
|
||||
aria-label="Ajouter une étape"
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
<span className="text-[13px] sm:text-[11px] font-medium text-ink-2 lg:text-ink-3 lg:mt-2">
|
||||
Ajouter une étape
|
||||
</span>
|
||||
<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",
|
||||
)}
|
||||
</ol>
|
||||
</div>
|
||||
>
|
||||
<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 { Textarea } from "@/components/ui/Textarea";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { CadenceTimeline, type DraftStepLite } from "@/components/plans/wizard/CadenceTimeline";
|
||||
import { CadenceTimeline } from "@/components/plans/wizard/CadenceTimeline";
|
||||
import { EmailPreview } from "@/components/plans/wizard/EmailPreview";
|
||||
import { AiGenerateModal } from "@/components/plans/wizard/AiGenerateModal";
|
||||
|
||||
@ -416,7 +416,6 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
|
||||
}, [draft.globalTone]);
|
||||
|
||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
||||
const selected = draft.steps[selectedIdx];
|
||||
|
||||
const updateStep = (idx: number, patch: Partial<DraftStep>) => {
|
||||
onChange({
|
||||
@ -442,15 +441,16 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-7">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Eyebrow>Étape 2 sur 4</Eyebrow>
|
||||
<h2 className="font-display text-[22px] font-bold tracking-[-0.02em] text-ink mt-2">
|
||||
Réglez la cadence
|
||||
</h2>
|
||||
<p className="mt-1.5 text-[14px] text-ink-3 leading-relaxed max-w-xl">
|
||||
Chaque ◆ est un email programmé. Le chiffre indique le nombre de jours{" "}
|
||||
<strong>après l'échéance</strong> (négatif pour rappel avant échéance).
|
||||
Chaque étape est un email programmé. Le chiffre indique le nombre de
|
||||
jours <strong>après l'échéance</strong> (négatif pour rappel avant
|
||||
échéance). Touchez une étape pour modifier son timing ou sa tonalité.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -458,60 +458,10 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
|
||||
steps={draft.steps}
|
||||
selectedIndex={selectedIdx}
|
||||
onSelectStep={setSelectedIdx}
|
||||
onUpdateStep={updateStep}
|
||||
onAddStep={addStep}
|
||||
onRemoveStep={removeStep}
|
||||
/>
|
||||
|
||||
{selected && (
|
||||
<div className="rounded-card border border-line bg-white p-5">
|
||||
<p className="text-[12px] uppercase tracking-[0.1em] text-ink-3 font-semibold mb-3">
|
||||
Étape {selectedIdx + 1}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Field
|
||||
label="Décalage (jours)"
|
||||
htmlFor="step-offset"
|
||||
hint="Négatif = avant échéance. 0 = jour J. 30 = J+30."
|
||||
>
|
||||
<OffsetInput
|
||||
id="step-offset"
|
||||
value={selected.offsetDays}
|
||||
onCommit={(n) => updateStep(selectedIdx, { offsetDays: n })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Tonalité" htmlFor="step-tone">
|
||||
<select
|
||||
id="step-tone"
|
||||
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={selected.tone}
|
||||
onChange={(e) => {
|
||||
const tone = e.target.value as RelanceTone;
|
||||
updateStep(selectedIdx, {
|
||||
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>
|
||||
{selected.tone === "mise_en_demeure" && (
|
||||
<p className="mt-3 inline-flex items-center gap-2 text-[12px] text-rubis-deep">
|
||||
<AlertTriangle size={12} /> Cette étape exigera une validation
|
||||
manuelle avant envoi (sécurité juridique).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -858,59 +808,6 @@ function stepCanProceed(step: WizardStep, draft: Draft): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. On clamp [-30, 180] sur commit
|
||||
* et on retombe à 0 au blur si l'utilisateur sort en laissant vide.
|
||||
*/
|
||||
function OffsetInput({
|
||||
id,
|
||||
value,
|
||||
onCommit,
|
||||
}: {
|
||||
id: string;
|
||||
value: number;
|
||||
onCommit: (n: number) => void;
|
||||
}) {
|
||||
const [local, setLocal] = useState<string>(String(value));
|
||||
|
||||
// Sync depuis l'extérieur quand on change d'étape sélectionnée.
|
||||
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;
|
||||
// Autorise vide, juste "-", ou entier signé.
|
||||
if (next === "" || next === "-" || /^-?\d{0,3}$/.test(next)) {
|
||||
setLocal(next);
|
||||
if (next !== "" && next !== "-") {
|
||||
const parsed = parseInt(next, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
const clamped = Math.max(-30, Math.min(180, parsed));
|
||||
onCommit(clamped);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (local === "" || local === "-") {
|
||||
setLocal("0");
|
||||
onCommit(0);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détecte les variables sensibles utilisées dans les templates et compte
|
||||
* combien de clients existants n'ont pas le champ correspondant rempli.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user