fix(plans/wizard): calendrier vraiment pratique (3 problèmes UX)
Issues remontées : - Cellules étirées en horizontal sur desktop (rectangles plats) - Échéance et "Doux" indiscernables (tous deux en rubis-glow) - Pas de feedback au clic, "Étape 1" déjà affichée par défaut sans qu'on l'ait sélectionnée - "Étape 1 · 20 juin · J+5" pas parlant - Cliquer sur une case vide ne faisait rien Refonte : - Calendrier max-w-md mx-auto + cells aspect-square → carrés équilibrés - Échéance = bg-rubis solide (pas glow) + ◆ blanc + ombre rubis → visuellement distincte de toutes les tonalités - Cellule étape = couleur tonalité + badge "J+X" en coin haut-droit - Sélection forte : ring-4 + scale-1.08 + shadow-rubis-hover sur la case sélectionnée → impossible de la rater - Default selectedIdx = -1 (pas de présélection) → hint clair : "Touchez une case colorée pour modifier, ou un jour vide pour ajouter" - **Click sur case vide → crée une étape à cet offset**, triée par ordre temporel (insertion smart, pas en bout). Plus l'usage le plus naturel de l'outil : "je veux relancer le 5 juin" → clic. - Click sur échéance → toast explicatif (pas une no-op silencieuse) - Header de l'éditeur : "Relance du **5 juin** · J-10" (pas "Étape 1") - Hover sur jour vide : "+" rubis apparaît → affordance d'ajout claire - Hors plage [-30, +180] ou >= 8 étapes : cellule disabled, toast info Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
149f60dbb0
commit
07712da774
@ -1,5 +1,6 @@
|
|||||||
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 } from "lucide-react";
|
||||||
|
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";
|
||||||
@ -17,14 +18,17 @@ export type DraftStepLite = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mini-calendrier mensuel comme outil de **navigation** dans la cadence.
|
* Mini-calendrier mensuel pratique : on **navigue** dans la cadence,
|
||||||
* Compact : un mois à la fois, prev/next, éditeur compact en dessous.
|
* on **ajoute** une relance en touchant une case vide, on **modifie**
|
||||||
|
* en touchant une case colorée. Édition compacte juste en dessous.
|
||||||
*
|
*
|
||||||
* - Date d'échéance fictive : le 15 du mois prochain.
|
* Distinctions visuelles importantes :
|
||||||
* - Auto-jump au mois de l'étape sélectionnée → la cellule reste visible.
|
* - Échéance : ◆ rubis solide (le repère héros, pas une "tonalité")
|
||||||
* - Cellule échéance = ◆ rubis (info-only).
|
* - Étape : couleur de fond selon la tonalité (4 niveaux escalade)
|
||||||
* - Cellule étape = couleur de fond selon la tonalité, cliquable pour
|
* - Sélectionnée : ring rubis + scale
|
||||||
* sélectionner et éditer juste en dessous.
|
* - Vide : numéro discret, hover montre que c'est cliquable
|
||||||
|
*
|
||||||
|
* Date de référence : le 15 du mois prochain.
|
||||||
*/
|
*/
|
||||||
export function CadenceCalendar({
|
export function CadenceCalendar({
|
||||||
steps,
|
steps,
|
||||||
@ -32,14 +36,20 @@ export function CadenceCalendar({
|
|||||||
onSelectStep,
|
onSelectStep,
|
||||||
onUpdateStep,
|
onUpdateStep,
|
||||||
onAddStep,
|
onAddStep,
|
||||||
|
onAddStepAtOffset,
|
||||||
onRemoveStep,
|
onRemoveStep,
|
||||||
|
globalTone,
|
||||||
}: {
|
}: {
|
||||||
steps: DraftStepLite[];
|
steps: DraftStepLite[];
|
||||||
selectedIndex?: number;
|
selectedIndex?: number;
|
||||||
onSelectStep?: (idx: number) => void;
|
onSelectStep?: (idx: number) => void;
|
||||||
onUpdateStep?: (idx: number, patch: Partial<DraftStepLite>) => void;
|
onUpdateStep?: (idx: number, patch: Partial<DraftStepLite>) => void;
|
||||||
onAddStep: () => void;
|
onAddStep: () => void;
|
||||||
|
/** Création directe en cliquant une case vide. Reçoit l'offset depuis l'échéance. */
|
||||||
|
onAddStepAtOffset?: (offsetDays: number) => void;
|
||||||
onRemoveStep: (idx: number) => void;
|
onRemoveStep: (idx: number) => void;
|
||||||
|
/** Tonalité par défaut pour une étape créée depuis le calendrier. */
|
||||||
|
globalTone?: RelanceTone;
|
||||||
}) {
|
}) {
|
||||||
const dueDate = useMemo(() => {
|
const dueDate = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -51,12 +61,11 @@ export function CadenceCalendar({
|
|||||||
[steps, dueDate],
|
[steps, dueDate],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mois affiché : par défaut = mois de l'échéance ; auto-jump au mois de
|
|
||||||
// l'étape sélectionnée si on en a une.
|
|
||||||
const [viewMonth, setViewMonth] = useState<Date>(
|
const [viewMonth, setViewMonth] = useState<Date>(
|
||||||
() => new Date(dueDate.getFullYear(), dueDate.getMonth(), 1),
|
() => new Date(dueDate.getFullYear(), dueDate.getMonth(), 1),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Auto-jump au mois de l'étape sélectionnée pour ne pas perdre la cellule.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedIndex < 0) return;
|
if (selectedIndex < 0) return;
|
||||||
const target = stepDates[selectedIndex];
|
const target = stepDates[selectedIndex];
|
||||||
@ -71,10 +80,23 @@ export function CadenceCalendar({
|
|||||||
|
|
||||||
const selected = selectedIndex >= 0 ? steps[selectedIndex] : null;
|
const selected = selectedIndex >= 0 ? steps[selectedIndex] : null;
|
||||||
|
|
||||||
|
const handleEmptyDayClick = (date: Date) => {
|
||||||
|
if (steps.length >= 8) {
|
||||||
|
toast.info("8 étapes max par plan.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const offset = daysBetween(dueDate, date);
|
||||||
|
if (offset < -30 || offset > 180) {
|
||||||
|
toast.info("Hors plage : on accepte de J-30 à J+180.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onAddStepAtOffset?.(offset);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-card border border-line bg-cream/40 p-3 sm:p-4">
|
<div className="rounded-card border border-line bg-cream/40 p-3 sm:p-4 mx-auto max-w-md">
|
||||||
{/* Header — month nav + compteur */}
|
{/* Header — month nav */}
|
||||||
<div className="flex items-center justify-between gap-2 mb-3">
|
<div className="flex items-center justify-between gap-2 mb-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@ -100,7 +122,6 @@ export function CadenceCalendar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grille du mois */}
|
|
||||||
<MonthGrid
|
<MonthGrid
|
||||||
month={viewMonth}
|
month={viewMonth}
|
||||||
dueDate={dueDate}
|
dueDate={dueDate}
|
||||||
@ -108,60 +129,93 @@ export function CadenceCalendar({
|
|||||||
stepDates={stepDates}
|
stepDates={stepDates}
|
||||||
selectedIndex={selectedIndex}
|
selectedIndex={selectedIndex}
|
||||||
onSelectStep={onSelectStep}
|
onSelectStep={onSelectStep}
|
||||||
|
onClickDueDate={() =>
|
||||||
|
toast.info(
|
||||||
|
`Le 15 du mois est l'échéance fictive de votre facture. Elle ne se modifie pas — c'est juste un repère pour visualiser le timing.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClickEmptyDay={handleEmptyDayClick}
|
||||||
|
canAddMore={steps.length < 8}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Légende compacte */}
|
{/* Légende compacte avec les 4 tonalités + échéance */}
|
||||||
<p className="mt-2 text-[10.5px] text-ink-3 leading-snug">
|
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10.5px] text-ink-3">
|
||||||
◆ Échéance fictive le{" "}
|
<LegendDot
|
||||||
<strong className="text-ink-2">{formatLongDate(dueDate)}</strong> · couleur =
|
label="Échéance"
|
||||||
tonalité
|
className="bg-rubis border-rubis"
|
||||||
</p>
|
isDiamond
|
||||||
|
/>
|
||||||
|
<LegendDot label="Doux" className="bg-rubis-glow border-rubis" />
|
||||||
|
<LegendDot label="Standard" className="bg-cream-2 border-ink-3" />
|
||||||
|
<LegendDot label="Ferme" className="bg-ink border-ink" />
|
||||||
|
<LegendDot label="Strict" className="bg-rubis-deep border-rubis-deep" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Éditeur inline compact */}
|
{/* Éditeur ou hint */}
|
||||||
<div className="mt-3 pt-3 border-t border-line">
|
<div className="mt-3 pt-3 border-t border-line">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<CompactEditor
|
<CompactEditor
|
||||||
step={selected}
|
step={selected}
|
||||||
index={selectedIndex}
|
|
||||||
stepDate={stepDates[selectedIndex]!}
|
stepDate={stepDates[selectedIndex]!}
|
||||||
canRemove={steps.length > 1}
|
canRemove={steps.length > 1}
|
||||||
onUpdate={(patch) => onUpdateStep?.(selectedIndex, patch)}
|
onUpdate={(patch) => onUpdateStep?.(selectedIndex, patch)}
|
||||||
onRemove={() => onRemoveStep(selectedIndex)}
|
onRemove={() => onRemoveStep(selectedIndex)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[12.5px] text-ink-3 italic text-center py-1.5">
|
<p className="text-[12.5px] text-ink-3 italic text-center py-2 leading-snug">
|
||||||
Touchez une case colorée pour modifier la relance.
|
Touchez une <strong className="text-ink-2">case colorée</strong> pour
|
||||||
|
modifier une relance, ou un <strong className="text-ink-2">jour vide</strong>{" "}
|
||||||
|
pour en ajouter une.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer — ajouter + compteur */}
|
{/* Footer — bouton Ajouter de secours */}
|
||||||
<div className="mt-3 flex items-center gap-2">
|
{steps.length < 8 && (
|
||||||
{steps.length < 8 && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={onAddStep}
|
||||||
onClick={onAddStep}
|
className={cn(
|
||||||
className={cn(
|
"mt-3 w-full inline-flex items-center justify-center gap-1.5",
|
||||||
"flex-1 inline-flex items-center justify-center gap-1.5",
|
"rounded-default border border-dashed border-line bg-transparent",
|
||||||
"rounded-default border border-dashed border-line bg-transparent",
|
"px-3 py-2 text-[12.5px] font-medium text-ink-3",
|
||||||
"px-3 py-2 text-[12.5px] font-medium text-ink-3",
|
"hover:border-rubis hover:text-rubis-deep hover:bg-rubis-glow/20 transition-colors",
|
||||||
"hover:border-rubis hover:text-rubis-deep hover:bg-rubis-glow/20 transition-colors",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<Plus size={13} aria-hidden="true" /> Ajouter en bout de cadence
|
||||||
<Plus size={13} aria-hidden="true" /> Ajouter une étape
|
</button>
|
||||||
</button>
|
)}
|
||||||
)}
|
|
||||||
<p className="shrink-0 text-[11px] text-ink-3 italic tabular-nums">
|
|
||||||
{steps.length}/8
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LegendDot({
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
isDiamond = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
className: string;
|
||||||
|
isDiamond?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"size-2.5 border",
|
||||||
|
isDiamond ? "rotate-45" : "rounded-full",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Grille d'un mois — cellules compactes, h-fixed pour stabilité visuelle
|
// Grille mensuelle — cellules carrées (aspect-square), max-w via container
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const TONE_CELL_CLASS: Record<RelanceTone, string> = {
|
const TONE_CELL_CLASS: Record<RelanceTone, string> = {
|
||||||
@ -180,6 +234,9 @@ function MonthGrid({
|
|||||||
stepDates,
|
stepDates,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
onSelectStep,
|
onSelectStep,
|
||||||
|
onClickDueDate,
|
||||||
|
onClickEmptyDay,
|
||||||
|
canAddMore,
|
||||||
}: {
|
}: {
|
||||||
month: Date;
|
month: Date;
|
||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
@ -187,6 +244,9 @@ function MonthGrid({
|
|||||||
stepDates: Date[];
|
stepDates: Date[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelectStep?: (idx: number) => void;
|
onSelectStep?: (idx: number) => void;
|
||||||
|
onClickDueDate: () => void;
|
||||||
|
onClickEmptyDay: (date: Date) => void;
|
||||||
|
canAddMore: boolean;
|
||||||
}) {
|
}) {
|
||||||
const firstDay = new Date(month.getFullYear(), month.getMonth(), 1);
|
const firstDay = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||||
const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0);
|
const lastDay = new Date(month.getFullYear(), month.getMonth() + 1, 0);
|
||||||
@ -218,6 +278,9 @@ function MonthGrid({
|
|||||||
stepDates={stepDates}
|
stepDates={stepDates}
|
||||||
selectedIndex={selectedIndex}
|
selectedIndex={selectedIndex}
|
||||||
onSelectStep={onSelectStep}
|
onSelectStep={onSelectStep}
|
||||||
|
onClickDueDate={onClickDueDate}
|
||||||
|
onClickEmptyDay={onClickEmptyDay}
|
||||||
|
canAddMore={canAddMore}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -231,6 +294,9 @@ function DayCell({
|
|||||||
stepDates,
|
stepDates,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
onSelectStep,
|
onSelectStep,
|
||||||
|
onClickDueDate,
|
||||||
|
onClickEmptyDay,
|
||||||
|
canAddMore,
|
||||||
}: {
|
}: {
|
||||||
date: Date | null;
|
date: Date | null;
|
||||||
dueDate: Date;
|
dueDate: Date;
|
||||||
@ -238,37 +304,42 @@ function DayCell({
|
|||||||
stepDates: Date[];
|
stepDates: Date[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelectStep?: (idx: number) => void;
|
onSelectStep?: (idx: number) => void;
|
||||||
|
onClickDueDate: () => void;
|
||||||
|
onClickEmptyDay: (date: Date) => void;
|
||||||
|
canAddMore: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (!date) return <div aria-hidden="true" className="h-9" />;
|
if (!date) return <div aria-hidden="true" className="aspect-square" />;
|
||||||
|
|
||||||
const isDue = sameDay(date, dueDate);
|
const isDue = sameDay(date, dueDate);
|
||||||
const stepIdx = lastIndexOf(stepDates, (d) => sameDay(d, date));
|
const stepIdx = lastIndexOf(stepDates, (d) => sameDay(d, date));
|
||||||
const step = stepIdx >= 0 ? steps[stepIdx] : null;
|
const step = stepIdx >= 0 ? steps[stepIdx] : null;
|
||||||
const isSelected = stepIdx === selectedIndex;
|
const isSelected = stepIdx === selectedIndex;
|
||||||
const isToday = sameDay(date, new Date());
|
const isToday = sameDay(date, new Date());
|
||||||
|
const offsetFromDue = daysBetween(dueDate, date);
|
||||||
|
|
||||||
// Cellule échéance — info-only, pas cliquable
|
// Cellule échéance — solide rubis, ◆ visible, cliquable pour info
|
||||||
if (isDue) {
|
if (isDue) {
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClickDueDate}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 flex items-center justify-center rounded-default",
|
"aspect-square flex flex-col items-center justify-center rounded-default",
|
||||||
"border-2 border-rubis bg-rubis-glow text-rubis-deep relative",
|
"border-2 border-rubis bg-rubis text-cream relative",
|
||||||
|
"shadow-rubis hover:shadow-rubis-hover transition-shadow",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
|
||||||
)}
|
)}
|
||||||
aria-label={`Échéance le ${formatLongDate(date)}`}
|
aria-label={`Échéance fictive le ${formatLongDate(date)}`}
|
||||||
>
|
>
|
||||||
<span
|
<span className="size-2.5 rotate-45 bg-cream" aria-hidden="true" />
|
||||||
className="absolute top-0.5 right-1 size-1.5 rotate-45 bg-rubis"
|
<span className="text-[11px] font-bold tabular-nums mt-0.5">
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="text-[12px] font-bold tabular-nums">
|
|
||||||
{date.getDate()}
|
{date.getDate()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cellule étape — cliquable
|
// Cellule étape — couleur tonalité, badge "Jx" en coin, cliquable
|
||||||
if (step) {
|
if (step) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -276,48 +347,73 @@ function DayCell({
|
|||||||
onClick={() => onSelectStep?.(stepIdx)}
|
onClick={() => onSelectStep?.(stepIdx)}
|
||||||
aria-pressed={isSelected}
|
aria-pressed={isSelected}
|
||||||
aria-label={`${TONE_LABELS[step.tone]}, J${step.offsetDays >= 0 ? "+" : ""}${step.offsetDays}, le ${formatLongDate(date)}`}
|
aria-label={`${TONE_LABELS[step.tone]}, J${step.offsetDays >= 0 ? "+" : ""}${step.offsetDays}, le ${formatLongDate(date)}`}
|
||||||
|
title={`${TONE_LABELS[step.tone]} · J${step.offsetDays >= 0 ? "+" : ""}${step.offsetDays}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 flex items-center justify-center rounded-default",
|
"aspect-square flex flex-col items-center justify-center rounded-default",
|
||||||
"border-2 transition-all relative",
|
"border-2 transition-all relative",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
|
||||||
TONE_CELL_CLASS[step.tone],
|
TONE_CELL_CLASS[step.tone],
|
||||||
isSelected && "ring-2 ring-rubis-glow scale-[1.06] z-10",
|
isSelected
|
||||||
|
? "ring-4 ring-rubis-glow scale-[1.08] z-10 shadow-rubis-hover"
|
||||||
|
: "hover:scale-[1.04]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="text-[12px] font-bold tabular-nums">
|
<span className="absolute top-0.5 right-1 text-[8.5px] font-bold tabular-nums opacity-70">
|
||||||
|
J{step.offsetDays >= 0 ? "+" : ""}
|
||||||
|
{step.offsetDays}
|
||||||
|
</span>
|
||||||
|
<span className="text-[13px] font-bold tabular-nums">
|
||||||
{date.getDate()}
|
{date.getDate()}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Jour normal — discret, today souligné
|
// Jour vide — cliquable pour ajouter une étape à cet offset (si dans la plage)
|
||||||
|
const inRange = offsetFromDue >= -30 && offsetFromDue <= 180 && offsetFromDue !== 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inRange && canAddMore && onClickEmptyDay(date)}
|
||||||
|
disabled={!inRange || !canAddMore}
|
||||||
|
aria-label={
|
||||||
|
inRange && canAddMore
|
||||||
|
? `Ajouter une relance le ${formatLongDate(date)} (J${offsetFromDue >= 0 ? "+" : ""}${offsetFromDue})`
|
||||||
|
: formatLongDate(date)
|
||||||
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 flex items-center justify-center rounded-default",
|
"aspect-square flex items-center justify-center rounded-default group",
|
||||||
"text-[12px] tabular-nums text-ink-3",
|
"text-[12px] tabular-nums text-ink-3 transition-colors",
|
||||||
isToday && "ring-1 ring-rubis-glow",
|
isToday && "ring-1 ring-rubis-glow",
|
||||||
|
inRange &&
|
||||||
|
canAddMore &&
|
||||||
|
"hover:bg-rubis-glow/40 hover:text-rubis-deep cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
|
||||||
|
(!inRange || !canAddMore) && "cursor-default",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{date.getDate()}
|
<span className="group-hover:hidden">{date.getDate()}</span>
|
||||||
</div>
|
{inRange && canAddMore && (
|
||||||
|
<Plus
|
||||||
|
size={13}
|
||||||
|
className="hidden group-hover:block text-rubis"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Éditeur compact — une ligne d'en-tête + une ligne de champs
|
// Éditeur compact — header explicite + 1 ligne de champs
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
function CompactEditor({
|
function CompactEditor({
|
||||||
step,
|
step,
|
||||||
index,
|
|
||||||
stepDate,
|
stepDate,
|
||||||
canRemove,
|
canRemove,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
step: DraftStepLite;
|
step: DraftStepLite;
|
||||||
index: number;
|
|
||||||
stepDate: Date;
|
stepDate: Date;
|
||||||
canRemove: boolean;
|
canRemove: boolean;
|
||||||
onUpdate: (patch: Partial<DraftStepLite>) => void;
|
onUpdate: (patch: Partial<DraftStepLite>) => void;
|
||||||
@ -325,6 +421,7 @@ function CompactEditor({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
|
{/* Header explicite : on sait quelle relance on édite */}
|
||||||
<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
|
||||||
@ -334,10 +431,15 @@ function CompactEditor({
|
|||||||
TONE_CELL_CLASS[step.tone],
|
TONE_CELL_CLASS[step.tone],
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<p className="font-display text-[13px] font-semibold text-ink truncate">
|
<p className="text-[13px] text-ink-2 truncate leading-tight">
|
||||||
Étape {index + 1} · {formatShortDate(stepDate)} · J
|
Relance du{" "}
|
||||||
{step.offsetDays >= 0 ? "+" : ""}
|
<strong className="font-display font-semibold text-ink">
|
||||||
{step.offsetDays}
|
{formatShortDate(stepDate)}
|
||||||
|
</strong>{" "}
|
||||||
|
<span className="text-ink-3">
|
||||||
|
· J{step.offsetDays >= 0 ? "+" : ""}
|
||||||
|
{step.offsetDays}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{canRemove && (
|
{canRemove && (
|
||||||
@ -345,43 +447,49 @@ function CompactEditor({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
className="shrink-0 size-8 flex items-center justify-center rounded-default 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"
|
className="shrink-0 size-8 flex items-center justify-center rounded-default 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="Supprimer l'étape"
|
aria-label="Supprimer cette relance"
|
||||||
|
title="Supprimer cette relance"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-[100px,1fr] gap-2">
|
<div className="grid grid-cols-[110px,1fr] gap-2 items-end">
|
||||||
<OffsetInput
|
<label className="flex flex-col gap-1">
|
||||||
id={`step-offset-${index}`}
|
<span className="text-[11px] font-semibold text-ink-3">
|
||||||
value={step.offsetDays}
|
Décalage
|
||||||
onCommit={(n) => onUpdate({ offsetDays: n })}
|
</span>
|
||||||
aria-label="Décalage en jours"
|
<OffsetInput
|
||||||
/>
|
id={`step-offset`}
|
||||||
<select
|
value={step.offsetDays}
|
||||||
id={`step-tone-${index}`}
|
onCommit={(n) => onUpdate({ offsetDays: n })}
|
||||||
aria-label="Tonalité"
|
/>
|
||||||
className={cn(
|
</label>
|
||||||
"h-10 w-full rounded-default border border-line bg-white px-2.5",
|
<label className="flex flex-col gap-1">
|
||||||
"text-[13.5px] text-ink",
|
<span className="text-[11px] font-semibold text-ink-3">Tonalité</span>
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow focus-visible:border-rubis",
|
<select
|
||||||
)}
|
className={cn(
|
||||||
value={step.tone}
|
"h-10 w-full rounded-default border border-line bg-white px-2.5",
|
||||||
onChange={(e) => {
|
"text-[13.5px] text-ink",
|
||||||
const tone = e.target.value as RelanceTone;
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow focus-visible:border-rubis",
|
||||||
onUpdate({
|
)}
|
||||||
tone,
|
value={step.tone}
|
||||||
requiresManualValidation: tone === "mise_en_demeure",
|
onChange={(e) => {
|
||||||
});
|
const tone = e.target.value as RelanceTone;
|
||||||
}}
|
onUpdate({
|
||||||
>
|
tone,
|
||||||
{(Object.keys(TONE_LABELS) as RelanceTone[]).map((t) => (
|
requiresManualValidation: tone === "mise_en_demeure",
|
||||||
<option key={t} value={t}>
|
});
|
||||||
{TONE_LABELS[t]}
|
}}
|
||||||
</option>
|
>
|
||||||
))}
|
{(Object.keys(TONE_LABELS) as RelanceTone[]).map((t) => (
|
||||||
</select>
|
<option key={t} value={t}>
|
||||||
|
{TONE_LABELS[t]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step.tone === "mise_en_demeure" && (
|
{step.tone === "mise_en_demeure" && (
|
||||||
@ -410,6 +518,12 @@ function sameDay(a: Date, b: Date): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function daysBetween(from: Date, to: Date): number {
|
||||||
|
const a = new Date(from.getFullYear(), from.getMonth(), from.getDate()).getTime();
|
||||||
|
const b = new Date(to.getFullYear(), to.getMonth(), to.getDate()).getTime();
|
||||||
|
return Math.round((b - a) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
function lastIndexOf<T>(arr: T[], pred: (x: T) => boolean): number {
|
function lastIndexOf<T>(arr: T[], pred: (x: T) => boolean): number {
|
||||||
for (let i = arr.length - 1; i >= 0; i--) {
|
for (let i = arr.length - 1; i >= 0; i--) {
|
||||||
if (pred(arr[i]!)) return i;
|
if (pred(arr[i]!)) return i;
|
||||||
@ -430,7 +544,7 @@ const FR_LONG = new Intl.DateTimeFormat("fr-FR", {
|
|||||||
|
|
||||||
const FR_SHORT = new Intl.DateTimeFormat("fr-FR", {
|
const FR_SHORT = new Intl.DateTimeFormat("fr-FR", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "short",
|
month: "long",
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatMonth(d: Date): string {
|
function formatMonth(d: Date): string {
|
||||||
@ -446,18 +560,17 @@ function formatShortDate(d: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// OffsetInput (string-controlled, accepte vide/`-` intermédiaires)
|
// OffsetInput
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
function OffsetInput({
|
function OffsetInput({
|
||||||
id,
|
id,
|
||||||
value,
|
value,
|
||||||
onCommit,
|
onCommit,
|
||||||
...rest
|
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
value: number;
|
value: number;
|
||||||
onCommit: (n: number) => void;
|
onCommit: (n: number) => void;
|
||||||
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange">) {
|
}) {
|
||||||
const [local, setLocal] = useState<string>(String(value));
|
const [local, setLocal] = useState<string>(String(value));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -490,7 +603,6 @@ function OffsetInput({
|
|||||||
onCommit(0);
|
onCommit(0);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
{...rest}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -415,7 +415,10 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [draft.globalTone]);
|
}, [draft.globalTone]);
|
||||||
|
|
||||||
const [selectedIdx, setSelectedIdx] = useState(0);
|
// Pas de présélection : l'utilisateur choisit en touchant une case.
|
||||||
|
// Évite la confusion "Étape 1 affichée par défaut alors que je n'ai
|
||||||
|
// rien cliqué".
|
||||||
|
const [selectedIdx, setSelectedIdx] = useState(-1);
|
||||||
|
|
||||||
const updateStep = (idx: number, patch: Partial<DraftStep>) => {
|
const updateStep = (idx: number, patch: Partial<DraftStep>) => {
|
||||||
onChange({
|
onChange({
|
||||||
@ -434,10 +437,25 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
|
|||||||
setSelectedIdx(draft.steps.length);
|
setSelectedIdx(draft.steps.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Création directe par clic sur une case vide du calendrier.
|
||||||
|
// L'étape est insérée triée par offset croissant pour que l'ordre
|
||||||
|
// visuel suive l'ordre temporel.
|
||||||
|
const addStepAtOffset = (offsetDays: number) => {
|
||||||
|
const newStep = emptyStep(draft.globalTone, offsetDays);
|
||||||
|
const merged = [...draft.steps, newStep].sort(
|
||||||
|
(a, b) => a.offsetDays - b.offsetDays,
|
||||||
|
);
|
||||||
|
const newIdx = merged.findIndex(
|
||||||
|
(s) => s.offsetDays === offsetDays && s === newStep,
|
||||||
|
);
|
||||||
|
onChange({ ...draft, steps: merged });
|
||||||
|
setSelectedIdx(newIdx);
|
||||||
|
};
|
||||||
|
|
||||||
const removeStep = (idx: number) => {
|
const removeStep = (idx: number) => {
|
||||||
if (draft.steps.length <= 1) return;
|
if (draft.steps.length <= 1) return;
|
||||||
onChange({ ...draft, steps: draft.steps.filter((_, i) => i !== idx) });
|
onChange({ ...draft, steps: draft.steps.filter((_, i) => i !== idx) });
|
||||||
setSelectedIdx((cur) => Math.min(cur, draft.steps.length - 2));
|
setSelectedIdx(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -448,18 +466,20 @@ function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
|
|||||||
Réglez la cadence
|
Réglez la cadence
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1.5 text-[14px] text-ink-3 leading-relaxed max-w-xl">
|
<p className="mt-1.5 text-[14px] text-ink-3 leading-relaxed max-w-xl">
|
||||||
Chaque étape est un email programmé. Le chiffre indique le nombre de
|
Touchez une <strong>case colorée</strong> pour modifier une relance,
|
||||||
jours <strong>après l'échéance</strong> (négatif pour rappel avant
|
ou un <strong>jour vide</strong> pour en ajouter une. Le ◆ rouge =
|
||||||
échéance). Touchez une étape pour modifier son timing ou sa tonalité.
|
échéance fictive de la facture (juste pour visualiser le timing).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CadenceCalendar
|
<CadenceCalendar
|
||||||
steps={draft.steps}
|
steps={draft.steps}
|
||||||
selectedIndex={selectedIdx}
|
selectedIndex={selectedIdx}
|
||||||
|
globalTone={draft.globalTone}
|
||||||
onSelectStep={setSelectedIdx}
|
onSelectStep={setSelectedIdx}
|
||||||
onUpdateStep={updateStep}
|
onUpdateStep={updateStep}
|
||||||
onAddStep={addStep}
|
onAddStep={addStep}
|
||||||
|
onAddStepAtOffset={addStepAtOffset}
|
||||||
onRemoveStep={removeStep}
|
onRemoveStep={removeStep}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user