diff --git a/apps/web/src/components/plans/wizard/CadenceTimeline.tsx b/apps/web/src/components/plans/wizard/CadenceTimeline.tsx index 8cfe3e3..8843ee5 100644 --- a/apps/web/src/components/plans/wizard/CadenceTimeline.tsx +++ b/apps/web/src/components/plans/wizard/CadenceTimeline.tsx @@ -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 = { - 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 = { + 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) => void; onAddStep: () => void; onRemoveStep: (idx: number) => void; - selectedIndex?: number; - onSelectStep?: (idx: number) => void; }) { return ( -
-
-

+

+
+

Cadence

-

+

{steps.length} étape{steps.length > 1 ? "s" : ""} · max 8

-
- {/* 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 && ( - + > +
); } + +function StepCard({ + step, + index, + selected, + canRemove, + onSelect, + onUpdate, + onRemove, +}: { + step: DraftStepLite; + index: number; + selected: boolean; + canRemove: boolean; + onSelect: () => void; + onUpdate: (patch: Partial) => void; + onRemove: () => void; +}) { + return ( +
+ {/* Header — toujours visible, cliquable pour sélectionner */} +
+ + + {canRemove && ( + + )} +
+ + {/* Éditeur inline — apparaît dans la card sélectionnée */} + {selected && ( +
+
+ + onUpdate({ offsetDays: n })} + /> + + + + +
+ {step.tone === "mise_en_demeure" && ( +

+ Validation manuelle obligatoire + avant envoi (sécurité juridique). +

+ )} +
+ )} +
+ ); +} + +/** + * 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(value)); + + useEffect(() => { + setLocal(String(value)); + }, [value]); + + return ( + { + 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); + } + }} + /> + ); +} diff --git a/apps/web/src/routes/_app/plans_.nouveau.tsx b/apps/web/src/routes/_app/plans_.nouveau.tsx index 37519c2..37d1552 100644 --- a/apps/web/src/routes/_app/plans_.nouveau.tsx +++ b/apps/web/src/routes/_app/plans_.nouveau.tsx @@ -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) => { onChange({ @@ -442,15 +441,16 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) = }; return ( -
+
Étape 2 sur 4

Réglez la cadence

- Chaque ◆ est un email programmé. Le chiffre indique le nombre de jours{" "} - après l'échéance (négatif pour rappel avant échéance). + Chaque étape est un email programmé. Le chiffre indique le nombre de + jours après l'échéance (négatif pour rappel avant + échéance). Touchez une étape pour modifier son timing ou sa tonalité.

@@ -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 && ( -
-

- Étape {selectedIdx + 1} -

-
- - updateStep(selectedIdx, { offsetDays: n })} - /> - - - - -
- {selected.tone === "mise_en_demeure" && ( -

- Cette étape exigera une validation - manuelle avant envoi (sécurité juridique). -

- )} -
- )}
); } @@ -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(value)); - - // Sync depuis l'extérieur quand on change d'étape sélectionnée. - useEffect(() => { - setLocal(String(value)); - }, [value]); - - return ( - { - 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.