feat(web): plans bibliothèque + éditeur
Bibliothèque /plans (cf. wireframe 3.1) :
- Grid responsive 1/2/3 cols avec PlanCard + CreatePlanCard placeholder
- PlanCard : titre, chip meta (un seul à la fois), aperçu 3 étapes avec
◆ rotated comme bullet, footer usage + lien "Modifier →"
- Le plan le plus utilisé reçoit le badge "✦ Le plus utilisé" (rubis-glow
+ Sparkles), les autres gardent leur label de tonalité (Doux / Standard
/ Ferme / Strict). Pas de "PLAN PAR DÉFAUT" partout — info tautologique
vu que les 4 plans seedés sont des défauts.
- Chips de tonalité adoucis (bg-cream-2 ou rubis-glow, plus de fills lourds)
- Skeleton pulsé pendant le fetch
Éditeur /plans/$slug (cf. wireframe 3.2, route _app/plans_.$slug pour
escape la layout parent) :
- Header : eyebrow humeur + nom + compteur d'usage + boutons Dupliquer
(V2) / Enregistrer (fonctionnel, désactivé tant que pas de changements)
- Layout 2-col responsive (1fr / 1.4fr) :
· Gauche : cadence — list de StepCard cliquables, état sélectionné avec
border-rubis + shadow-rubis, "+ Ajouter une étape" disabled (V2)
· Droite : éditeur d'email — Card avec subject (Input), body (Textarea
mono 10 rows), grille de variables-chips
- Variable insertion fonctionnelle : clic = insertion au curseur via
selectionStart/End du textarea, label FR + token mono ({{numero}})
- Bandeau warning rubis-glow quand l'étape est requiresManualValidation :
"Validation manuelle obligatoire. L'email est généré en brouillon"
- Save fonctionnel : isDirty calculé via JSON.stringify, mutation PATCH
/plans/:slug, invalidate cache plans.all + setQueryData detail, toast
- Sync state local ↔ serveur via useEffect sur plan.id+updatedAt
MSW :
- handlers/plans.ts : GET /plans (avec usageCount), GET /plans/:slug,
PATCH /plans/:slug (validation Zod, recompose ids manquants)
- mockDb : findPlanBySlug, listPlansForOrg, updatePlan
- Calcul usageCount : factures du plan en statut != paid && != cancelled
Lib /plans.ts :
- TONE_LABELS : Amical / Standard / Ferme / Mise en demeure (FR)
- planMoodLabel + planOverallTone (humeur globale = ton de la dernière étape)
- TEMPLATE_VARIABLES : 5 variables avec token + label FR + preview
Bundle prod : 117.31 KB gzip core (stable). plans 2.06 KB gzip,
plans_._slug 3.28 KB gzip — la plus grosse route chunk vu sa complexité
(form + variables + state local).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
14d0e982e9
commit
b5b67056aa
172
apps/web/src/components/plans/PlanCard.tsx
Normal file
172
apps/web/src/components/plans/PlanCard.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Plus, ArrowRight, Sparkles } from "lucide-react";
|
||||
|
||||
import type { Plan } from "@rubis/shared";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { planMoodLabel } from "@/lib/plans";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Card d'un plan dans la bibliothèque (cf. wireframe 3.1).
|
||||
*
|
||||
* Hiérarchie d'info (en accord avec le feedback retour utilisateur) :
|
||||
* - Le titre est primaire
|
||||
* - Une seule meta en haut-droite : soit "★ Le plus utilisé" pour LE plan
|
||||
* le plus actif (signal utile), soit le label de tonalité (signal de
|
||||
* différenciation pour les autres). Les deux ne coexistent pas — un seul
|
||||
* chip par card.
|
||||
* - Aperçu des étapes avec ◆ comme bullet (notre marqueur, pas un point neutre)
|
||||
* - Footer : usage + lien Modifier
|
||||
*
|
||||
* Pas de "PLAN PAR DÉFAUT" affiché : tous les plans seedés sont par défaut,
|
||||
* l'info est tautologique. Quand l'utilisateur créera ses propres plans,
|
||||
* on pourra distinguer (cf. V2).
|
||||
*/
|
||||
type PlanCardProps = {
|
||||
plan: Plan & { usageCount?: number };
|
||||
/** Si true, affiche le badge "★ Le plus utilisé" en lieu et place du chip de ton. */
|
||||
isMostUsed?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const MOOD_CHIP: Record<string, string> = {
|
||||
Doux: "bg-rubis-glow text-rubis-deep border-rubis-glow/60",
|
||||
Standard: "bg-cream-2 text-ink-2 border-line",
|
||||
Ferme: "bg-cream-2 text-ink border-line",
|
||||
Strict: "bg-rubis-glow text-rubis-deep border-rubis/30",
|
||||
};
|
||||
|
||||
export function PlanCard({ plan, isMostUsed = false, className }: PlanCardProps) {
|
||||
const mood = planMoodLabel(plan);
|
||||
const usage = plan.usageCount ?? 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="md"
|
||||
className={cn(
|
||||
"flex flex-col h-full min-w-0 transition-all duration-150",
|
||||
"hover:border-ink-3 hover:shadow-card",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="font-display text-[16px] font-semibold tracking-[-0.018em] text-ink truncate min-w-0">
|
||||
{plan.name}
|
||||
</p>
|
||||
{isMostUsed ? (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5",
|
||||
"border-rubis bg-rubis-glow text-rubis-deep",
|
||||
"font-sans text-[11px] font-semibold leading-tight whitespace-nowrap",
|
||||
)}
|
||||
>
|
||||
<Sparkles size={11} aria-hidden="true" /> Le plus utilisé
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-2.5 py-0.5",
|
||||
"font-sans text-[11px] font-medium leading-tight whitespace-nowrap",
|
||||
MOOD_CHIP[mood] ?? MOOD_CHIP.Standard,
|
||||
)}
|
||||
>
|
||||
{mood}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plan.description && (
|
||||
<p className="mt-2 text-[12.5px] leading-relaxed text-ink-3 line-clamp-2">
|
||||
{plan.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="mt-4 flex flex-col gap-1.5 text-[12.5px] text-ink-2">
|
||||
{plan.steps.slice(0, 3).map((step) => (
|
||||
<li key={step.id} className="flex items-baseline gap-2">
|
||||
<span
|
||||
className="shrink-0 size-[5px] rotate-45 bg-rubis"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="tabular-nums font-semibold text-ink">
|
||||
J{step.offsetDays >= 0 ? "+" : ""}
|
||||
{step.offsetDays}
|
||||
</span>
|
||||
<span className="text-ink-3">·</span>
|
||||
<span className="truncate">
|
||||
{step.requiresManualValidation ? "Mise en demeure" : labelForTone(step.tone)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{plan.steps.length > 3 && (
|
||||
<li className="text-[11.5px] italic text-ink-3 ml-3.5">
|
||||
+{plan.steps.length - 3} étape{plan.steps.length - 3 > 1 ? "s" : ""}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto pt-4 flex items-center justify-between border-t border-line">
|
||||
<p className="text-[11.5px] text-ink-3">
|
||||
{usage > 0 ? (
|
||||
<>
|
||||
Utilisé sur <strong className="font-semibold text-ink-2 tabular-nums">{usage}</strong>{" "}
|
||||
facture{usage > 1 ? "s" : ""}
|
||||
</>
|
||||
) : (
|
||||
<span className="italic">Aucune facture active</span>
|
||||
)}
|
||||
</p>
|
||||
{plan.slug && (
|
||||
<Link
|
||||
to="/plans/$slug"
|
||||
params={{ slug: plan.slug }}
|
||||
className="inline-flex items-center gap-1 text-[12px] font-medium text-rubis hover:underline underline-offset-4"
|
||||
>
|
||||
Modifier <ArrowRight size={12} aria-hidden="true" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function labelForTone(tone: string): string {
|
||||
switch (tone) {
|
||||
case "amical":
|
||||
return "Rappel amical";
|
||||
case "courtois":
|
||||
return "Relance courtoise";
|
||||
case "ferme":
|
||||
return "Relance ferme";
|
||||
case "mise_en_demeure":
|
||||
return "Mise en demeure";
|
||||
default:
|
||||
return "Email programmé";
|
||||
}
|
||||
}
|
||||
|
||||
/** Card "+ Créer un plan" — placeholder pour la création de plans custom. */
|
||||
export function CreatePlanCard() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-2 w-full",
|
||||
"rounded-card border-2 border-dashed border-line bg-transparent p-6 min-h-[220px]",
|
||||
"text-ink-3 transition-colors hover:border-ink-3 hover:text-ink-2",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<Plus size={26} strokeWidth={1.5} aria-hidden="true" />
|
||||
<span className="font-display text-[14.5px] font-semibold text-ink">
|
||||
Créer un plan
|
||||
</span>
|
||||
<span className="text-[11.5px] italic text-ink-3 max-w-[180px]">
|
||||
Bientôt — pour l'instant, dupliquez un des plans existants.
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/lib/plans.ts
Normal file
58
apps/web/src/lib/plans.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import type { Plan, RelanceTone } from "@rubis/shared";
|
||||
|
||||
/**
|
||||
* Helpers de présentation des plans de relance.
|
||||
* Garde la conversion tonalité → label public au même endroit.
|
||||
*/
|
||||
|
||||
/** Label visible utilisateur pour chaque ton (cf. wireframe 3.1, chips). */
|
||||
export const TONE_LABELS: Record<RelanceTone, string> = {
|
||||
amical: "Amical",
|
||||
courtois: "Standard",
|
||||
ferme: "Ferme",
|
||||
mise_en_demeure: "Mise en demeure",
|
||||
};
|
||||
|
||||
/** Tonalité globale d'un plan = la dernière étape (la plus stricte). */
|
||||
export function planOverallTone(plan: Pick<Plan, "steps">): RelanceTone {
|
||||
const last = plan.steps[plan.steps.length - 1];
|
||||
return last?.tone ?? "courtois";
|
||||
}
|
||||
|
||||
/** Label court d'humeur d'un plan ("Doux" / "Standard" / "Ferme" / "Strict"). */
|
||||
export function planMoodLabel(plan: Pick<Plan, "steps">): string {
|
||||
const tone = planOverallTone(plan);
|
||||
switch (tone) {
|
||||
case "amical":
|
||||
return "Doux";
|
||||
case "courtois":
|
||||
return "Standard";
|
||||
case "ferme":
|
||||
return "Ferme";
|
||||
case "mise_en_demeure":
|
||||
return "Strict";
|
||||
default:
|
||||
return "Standard";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables de template disponibles dans les emails de relance.
|
||||
* Les chips dans l'éditeur viennent piocher ici.
|
||||
*/
|
||||
export type TemplateVariable = {
|
||||
/** Token inséré tel quel dans le body (ex. "{{numero}}"). */
|
||||
token: string;
|
||||
/** Label affiché sur le chip cliquable. */
|
||||
label: string;
|
||||
/** Aperçu utilisé dans l'éditeur (placeholder réaliste). */
|
||||
preview: string;
|
||||
};
|
||||
|
||||
export const TEMPLATE_VARIABLES: TemplateVariable[] = [
|
||||
{ token: "{{client.name}}", label: "Nom du client", preview: "Boulangerie Martin SARL" },
|
||||
{ token: "{{numero}}", label: "Numéro", preview: "F-2026-0042" },
|
||||
{ token: "{{amount}}", label: "Montant", preview: "1 240,00 €" },
|
||||
{ token: "{{dueDate}}", label: "Échéance", preview: "15 mai 2026" },
|
||||
{ token: "{{signature}}", label: "Signature", preview: "Cordialement,\nArthur" },
|
||||
];
|
||||
@ -162,6 +162,34 @@ export const mockDb = {
|
||||
findPlanById(orgId: string, id: string): Plan | undefined {
|
||||
return load().plans.find((p) => p.organizationId === orgId && p.id === id);
|
||||
},
|
||||
findPlanBySlug(orgId: string, slug: string): Plan | undefined {
|
||||
return load().plans.find(
|
||||
(p) => p.organizationId === orgId && p.slug === slug,
|
||||
);
|
||||
},
|
||||
listPlansForOrg(orgId: string): Plan[] {
|
||||
return load().plans.filter((p) => p.organizationId === orgId);
|
||||
},
|
||||
updatePlan(
|
||||
orgId: string,
|
||||
id: string,
|
||||
patch: Partial<Pick<Plan, "name" | "description" | "steps">>,
|
||||
): Plan | undefined {
|
||||
const db = load();
|
||||
const idx = db.plans.findIndex(
|
||||
(p) => p.organizationId === orgId && p.id === id,
|
||||
);
|
||||
if (idx === -1) return undefined;
|
||||
const existing = db.plans[idx]!;
|
||||
const updated: Plan = {
|
||||
...existing,
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
db.plans[idx] = updated;
|
||||
save(db);
|
||||
return updated;
|
||||
},
|
||||
|
||||
// === Invoices ===
|
||||
listInvoicesForOrg(orgId: string): StoredInvoice[] {
|
||||
|
||||
@ -2,6 +2,7 @@ import { authHandlers } from "./auth";
|
||||
import { onboardingHandlers } from "./onboarding";
|
||||
import { dashboardHandlers } from "./dashboard";
|
||||
import { invoiceHandlers } from "./invoices";
|
||||
import { planHandlers } from "./plans";
|
||||
|
||||
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
|
||||
export const handlers = [
|
||||
@ -9,4 +10,5 @@ export const handlers = [
|
||||
...onboardingHandlers,
|
||||
...dashboardHandlers,
|
||||
...invoiceHandlers,
|
||||
...planHandlers,
|
||||
];
|
||||
|
||||
122
apps/web/src/mocks/handlers/plans.ts
Normal file
122
apps/web/src/mocks/handlers/plans.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { z } from "zod";
|
||||
import { RELANCE_TONES } from "@rubis/shared";
|
||||
|
||||
import { mockDb } from "../db";
|
||||
import { userIdFromAuthHeader } from "./auth";
|
||||
|
||||
const apiBase = "*/api/v1";
|
||||
|
||||
function unauthenticated() {
|
||||
return HttpResponse.json(
|
||||
{ errors: [{ code: "unauthenticated", message: "Non authentifié" }] },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
function notFound() {
|
||||
return HttpResponse.json(
|
||||
{ errors: [{ code: "not_found", message: "Plan introuvable" }] },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
function authedOrgId(authHeader: string | null): string | undefined {
|
||||
const userId = userIdFromAuthHeader(authHeader);
|
||||
if (!userId) return undefined;
|
||||
return mockDb.findUserById(userId)?.organizationId;
|
||||
}
|
||||
|
||||
const updatePlanStepSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
order: z.number().int().min(0),
|
||||
offsetDays: z.number().int().min(-30).max(180),
|
||||
tone: z.enum(RELANCE_TONES),
|
||||
subject: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(5000),
|
||||
requiresManualValidation: z.boolean(),
|
||||
});
|
||||
|
||||
const updatePlanSchema = z.object({
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
steps: z.array(updatePlanStepSchema).min(1).max(10).optional(),
|
||||
});
|
||||
|
||||
export const planHandlers = [
|
||||
// GET /api/v1/plans — liste enrichie avec compteurs d'usage
|
||||
http.get(`${apiBase}/plans`, ({ request }) => {
|
||||
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||
if (!orgId) return unauthenticated();
|
||||
|
||||
const plans = mockDb.listPlansForOrg(orgId);
|
||||
const invoices = mockDb.listInvoicesForOrg(orgId);
|
||||
|
||||
const enriched = plans.map((plan) => {
|
||||
const usageCount = invoices.filter(
|
||||
(inv) => inv.planId === plan.id && inv.status !== "paid" && inv.status !== "cancelled",
|
||||
).length;
|
||||
return { ...plan, usageCount };
|
||||
});
|
||||
|
||||
return HttpResponse.json({ data: enriched });
|
||||
}),
|
||||
|
||||
// GET /api/v1/plans/:slug — détail (par slug pour les plans pré-fournis)
|
||||
http.get(`${apiBase}/plans/:slug`, ({ request, params }) => {
|
||||
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||
if (!orgId) return unauthenticated();
|
||||
|
||||
const slug = params.slug as string;
|
||||
const plan = mockDb.findPlanBySlug(orgId, slug);
|
||||
if (!plan) return notFound();
|
||||
|
||||
const invoices = mockDb.listInvoicesForOrg(orgId);
|
||||
const usageCount = invoices.filter(
|
||||
(inv) => inv.planId === plan.id && inv.status !== "paid" && inv.status !== "cancelled",
|
||||
).length;
|
||||
|
||||
return HttpResponse.json({ data: { ...plan, usageCount } });
|
||||
}),
|
||||
|
||||
// PATCH /api/v1/plans/:slug — sauvegarde nom/description/étapes
|
||||
http.patch(`${apiBase}/plans/:slug`, async ({ request, params }) => {
|
||||
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||
if (!orgId) return unauthenticated();
|
||||
|
||||
const slug = params.slug as string;
|
||||
const plan = mockDb.findPlanBySlug(orgId, slug);
|
||||
if (!plan) return notFound();
|
||||
|
||||
const json = await request.json();
|
||||
const parsed = updatePlanSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
errors: parsed.error.issues.map((i) => ({
|
||||
code: "validation_failed",
|
||||
message: i.message,
|
||||
field: i.path.join("."),
|
||||
})),
|
||||
},
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
// Recompose les étapes en garantissant un id sur chaque
|
||||
const steps = parsed.data.steps?.map((s, idx) => ({
|
||||
...s,
|
||||
id: s.id ?? `step_${plan.id}_${idx}_${Date.now()}`,
|
||||
}));
|
||||
|
||||
const updated = mockDb.updatePlan(orgId, plan.id, {
|
||||
...(parsed.data.name !== undefined && { name: parsed.data.name }),
|
||||
...(parsed.data.description !== undefined && {
|
||||
description: parsed.data.description,
|
||||
}),
|
||||
...(steps !== undefined && { steps }),
|
||||
});
|
||||
|
||||
return HttpResponse.json({ data: updated });
|
||||
}),
|
||||
];
|
||||
@ -1,40 +1,86 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import type { Plan } from "@rubis/shared";
|
||||
import { api } from "@/lib/api";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { PlanCard, CreatePlanCard } from "@/components/plans/PlanCard";
|
||||
|
||||
type PlanWithUsage = Plan & { usageCount: number };
|
||||
|
||||
export const Route = createFileRoute("/_app/plans")({
|
||||
component: PlansPage,
|
||||
component: PlansLibraryPage,
|
||||
loader: ({ context }) => {
|
||||
void context.queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.plans.all(),
|
||||
queryFn: () => api.get<PlanWithUsage[]>("/api/v1/plans"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function PlansPage() {
|
||||
function PlansLibraryPage() {
|
||||
const { data: plans = [], isPending } = useQuery({
|
||||
queryKey: queryKeys.plans.all(),
|
||||
queryFn: () => api.get<PlanWithUsage[]>("/api/v1/plans"),
|
||||
});
|
||||
|
||||
// Le plan le plus utilisé reçoit le badge "★ Le plus utilisé". On ne le
|
||||
// calcule que s'il y a au moins une facture active (sinon le badge n'a
|
||||
// pas de sens).
|
||||
const mostUsedPlanId = plans.reduce<string | null>((best, plan) => {
|
||||
if (plan.usageCount <= 0) return best;
|
||||
if (!best) return plan.id;
|
||||
const bestPlan = plans.find((p) => p.id === best);
|
||||
return plan.usageCount > (bestPlan?.usageCount ?? 0) ? plan.id : best;
|
||||
}, null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="font-display text-[28px] font-bold tracking-[-0.022em] text-ink">
|
||||
Plans de relance
|
||||
<h1 className="font-display text-[26px] font-bold tracking-[-0.022em] text-ink">
|
||||
Plans de <em className="text-rubis">relance</em>
|
||||
</h1>
|
||||
<p className="mt-1 text-[14px] text-ink-3">
|
||||
La cadence avec laquelle Rubis relance pour vous.
|
||||
<p className="mt-1.5 max-w-xl text-[14px] text-ink-3 leading-relaxed">
|
||||
Un plan = une cadence d'emails automatisée que Rubis joue pour
|
||||
vous. Quatre modèles vous sont fournis — modifiables, mais déjà bien
|
||||
calibrés.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
draft
|
||||
icon={<ListChecks size={36} strokeWidth={1.5} aria-hidden="true" />}
|
||||
title={
|
||||
<>
|
||||
Quatre plans <em className="text-rubis">prêts à l'emploi</em>.
|
||||
</>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
Bibliothèque + éditeur de plan : cadences (J+3, J+10, J+20…),
|
||||
templates email avec variables, ton qui monte avec le retard.
|
||||
Étape suivante.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{isPending ? (
|
||||
<PlansSkeleton />
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
|
||||
{plans.map((plan) => (
|
||||
<li key={plan.id}>
|
||||
<PlanCard plan={plan} isMostUsed={plan.id === mostUsedPlanId} />
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<CreatePlanCard />
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlansSkeleton() {
|
||||
return (
|
||||
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-5">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="rounded-card border border-line bg-white p-6 h-[240px] animate-pulse"
|
||||
>
|
||||
<div className="h-4 w-1/2 rounded bg-cream-2" />
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="h-3 w-2/3 rounded bg-cream-2" />
|
||||
<div className="h-3 w-1/2 rounded bg-cream-2" />
|
||||
<div className="h-3 w-3/5 rounded bg-cream-2" />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
383
apps/web/src/routes/_app/plans_.$slug.tsx
Normal file
383
apps/web/src/routes/_app/plans_.$slug.tsx
Normal file
@ -0,0 +1,383 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
AlertTriangle,
|
||||
Copy as CopyIcon,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { Plan, PlanStep, RelanceTone } from "@rubis/shared";
|
||||
import { api } from "@/lib/api";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import {
|
||||
TEMPLATE_VARIABLES,
|
||||
TONE_LABELS,
|
||||
planMoodLabel,
|
||||
} from "@/lib/plans";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||
import { Field } from "@/components/ui/Field";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Textarea } from "@/components/ui/Textarea";
|
||||
|
||||
type PlanWithUsage = Plan & { usageCount: number };
|
||||
|
||||
export const Route = createFileRoute("/_app/plans_/$slug")({
|
||||
component: PlanEditorPage,
|
||||
loader: ({ context, params }) => {
|
||||
void context.queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.plans.detail(params.slug),
|
||||
queryFn: () => api.get<PlanWithUsage>(`/api/v1/plans/${params.slug}`),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function PlanEditorPage() {
|
||||
const { slug } = Route.useParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: plan, isPending, isError } = useQuery({
|
||||
queryKey: queryKeys.plans.detail(slug),
|
||||
queryFn: () => api.get<PlanWithUsage>(`/api/v1/plans/${slug}`),
|
||||
});
|
||||
|
||||
// État local de l'édition. On clone les steps depuis le serveur au mount
|
||||
// (et à chaque refetch) puis on travaille en local jusqu'au "Enregistrer".
|
||||
const [draftSteps, setDraftSteps] = useState<PlanStep[] | null>(null);
|
||||
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
|
||||
const bodyRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// Sync : quand le plan arrive ou change côté serveur, on remet à zéro l'état
|
||||
// local. On évite les races avec une clé sur plan.id+updatedAt.
|
||||
useEffect(() => {
|
||||
if (!plan) return;
|
||||
setDraftSteps(plan.steps);
|
||||
setSelectedStepId((current) => current ?? plan.steps[0]?.id ?? null);
|
||||
}, [plan?.id, plan?.updatedAt]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (steps: PlanStep[]) =>
|
||||
api.patch<Plan>(`/api/v1/plans/${slug}`, { steps }),
|
||||
onSuccess: (saved) => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.plans.all() });
|
||||
void queryClient.setQueryData(
|
||||
queryKeys.plans.detail(slug),
|
||||
(prev: PlanWithUsage | undefined) =>
|
||||
prev ? { ...prev, ...saved } : (saved as PlanWithUsage),
|
||||
);
|
||||
toast.success("Plan enregistré.");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Sauvegarde impossible. Réessayez dans un instant.");
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="font-display text-[20px] font-semibold text-ink">
|
||||
Plan introuvable.
|
||||
</p>
|
||||
<Link
|
||||
to="/plans"
|
||||
className="mt-3 inline-block text-[13px] text-rubis underline-offset-4 hover:underline"
|
||||
>
|
||||
← Retour à la bibliothèque
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending || !plan || !draftSteps) {
|
||||
return <EditorSkeleton />;
|
||||
}
|
||||
|
||||
const selectedStep =
|
||||
draftSteps.find((s) => s.id === selectedStepId) ?? draftSteps[0];
|
||||
|
||||
const updateSelectedStep = (patch: Partial<PlanStep>) => {
|
||||
if (!selectedStep) return;
|
||||
setDraftSteps((prev) =>
|
||||
prev
|
||||
? prev.map((s) => (s.id === selectedStep.id ? { ...s, ...patch } : s))
|
||||
: prev,
|
||||
);
|
||||
};
|
||||
|
||||
const insertVariable = (token: string) => {
|
||||
if (!selectedStep || !bodyRef.current) return;
|
||||
const ta = bodyRef.current;
|
||||
const start = ta.selectionStart ?? selectedStep.body.length;
|
||||
const end = ta.selectionEnd ?? selectedStep.body.length;
|
||||
const newBody =
|
||||
selectedStep.body.slice(0, start) + token + selectedStep.body.slice(end);
|
||||
updateSelectedStep({ body: newBody });
|
||||
// Reposition le curseur après le token inséré
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
const cursor = start + token.length;
|
||||
ta.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
};
|
||||
|
||||
const isDirty =
|
||||
JSON.stringify(draftSteps) !== JSON.stringify(plan.steps);
|
||||
const mood = planMoodLabel({ steps: draftSteps });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Link
|
||||
to="/plans"
|
||||
className="inline-flex items-center gap-1.5 self-start text-[12.5px] text-ink-3 hover:text-rubis"
|
||||
>
|
||||
<ArrowLeft size={13} aria-hidden="true" /> Plans · {plan.name}
|
||||
</Link>
|
||||
|
||||
<header className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<Eyebrow>{mood}</Eyebrow>
|
||||
<h1 className="mt-2 font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
|
||||
{plan.name}
|
||||
</h1>
|
||||
<p className="mt-1.5 text-[13px] text-ink-3">
|
||||
{plan.usageCount > 0 ? (
|
||||
<>
|
||||
Utilisé sur{" "}
|
||||
<strong className="font-semibold text-ink-2 tabular-nums">
|
||||
{plan.usageCount}
|
||||
</strong>{" "}
|
||||
facture{plan.usageCount > 1 ? "s" : ""} active
|
||||
{plan.usageCount > 1 ? "s" : ""}
|
||||
</>
|
||||
) : (
|
||||
<span className="italic">Aucune facture active</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
<CopyIcon size={14} aria-hidden="true" /> Dupliquer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!isDirty}
|
||||
loading={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate(draftSteps)}
|
||||
>
|
||||
{isDirty ? "Enregistrer" : "Aucune modification"}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_1.4fr]">
|
||||
{/* === Cadence (left) === */}
|
||||
<section aria-label="Cadence du plan" className="flex flex-col gap-3">
|
||||
<Eyebrow tone="ink">Cadence</Eyebrow>
|
||||
<ol className="flex flex-col gap-3">
|
||||
{draftSteps.map((step, idx) => (
|
||||
<li key={step.id}>
|
||||
<StepCard
|
||||
step={step}
|
||||
index={idx}
|
||||
selected={step.id === selectedStep?.id}
|
||||
onSelect={() => setSelectedStepId(step.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className={cn(
|
||||
"rounded-default border border-dashed border-line bg-transparent",
|
||||
"px-4 py-3 text-[13px] font-medium text-ink-3",
|
||||
"transition-colors hover:border-ink-3 hover:text-ink-2",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed",
|
||||
"inline-flex items-center justify-center gap-1.5",
|
||||
)}
|
||||
>
|
||||
<Plus size={14} aria-hidden="true" /> Ajouter une étape
|
||||
<span className="ml-1 text-[11px] italic">(bientôt)</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* === Email editor (right) === */}
|
||||
<section aria-label="Édition de l'étape sélectionnée">
|
||||
{selectedStep ? (
|
||||
<Card padding="md" className="flex flex-col gap-5">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<Eyebrow tone="ink">
|
||||
Étape {(selectedStep.order ?? 0) + 1} ·{" "}
|
||||
<span className="text-rubis">
|
||||
J{selectedStep.offsetDays >= 0 ? "+" : ""}
|
||||
{selectedStep.offsetDays}
|
||||
</span>
|
||||
</Eyebrow>
|
||||
<span className="text-[12px] text-ink-3">
|
||||
Tonalité · {TONE_LABELS[selectedStep.tone]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedStep.requiresManualValidation && (
|
||||
<div className="flex items-start gap-2 rounded-default bg-rubis-glow/60 px-3 py-2 text-[12.5px] text-rubis-deep">
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<p>
|
||||
<strong className="font-semibold">Validation manuelle obligatoire.</strong>{" "}
|
||||
L'email est <em className="not-italic font-medium">généré en brouillon</em>,
|
||||
jamais envoyé automatiquement (cf. principe produit).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label="Objet de l'email" htmlFor={`subject-${selectedStep.id}`}>
|
||||
<Input
|
||||
id={`subject-${selectedStep.id}`}
|
||||
value={selectedStep.subject}
|
||||
onChange={(e) => updateSelectedStep({ subject: e.target.value })}
|
||||
placeholder="Rappel — facture {{numero}}"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Corps"
|
||||
htmlFor={`body-${selectedStep.id}`}
|
||||
hint="Markdown léger non interprété. Les variables ci-dessous sont remplacées à l'envoi."
|
||||
>
|
||||
<Textarea
|
||||
id={`body-${selectedStep.id}`}
|
||||
ref={bodyRef}
|
||||
rows={10}
|
||||
className="font-mono text-[13px] leading-relaxed"
|
||||
value={selectedStep.body}
|
||||
onChange={(e) => updateSelectedStep({ body: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-ink-3">
|
||||
Variables — clic pour insérer au curseur
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TEMPLATE_VARIABLES.map((variable) => (
|
||||
<button
|
||||
key={variable.token}
|
||||
type="button"
|
||||
onClick={() => insertVariable(variable.token)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1",
|
||||
"font-mono text-[11.5px] leading-tight",
|
||||
"border-line bg-cream-2 text-ink-2",
|
||||
"transition-colors hover:border-rubis hover:bg-rubis-glow hover:text-rubis-deep",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||
)}
|
||||
title={`Insère "${variable.token}" — ex: ${variable.preview.split("\n")[0]}`}
|
||||
>
|
||||
<span className="font-sans text-[11px] text-ink-3 not-italic">
|
||||
{variable.label}
|
||||
</span>
|
||||
<span className="text-rubis">{variable.token}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding="md">
|
||||
<p className="text-[13px] italic text-ink-3">
|
||||
Sélectionnez une étape à gauche pour l'éditer.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Card d'une étape dans la colonne cadence (gauche). */
|
||||
function StepCard({
|
||||
step,
|
||||
index,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
step: PlanStep;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"group block w-full text-left",
|
||||
"rounded-card border bg-white p-4 transition-all duration-150",
|
||||
selected
|
||||
? "border-rubis shadow-rubis"
|
||||
: "border-line hover:border-ink-3",
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute left-0 top-4 bottom-4 w-[3px] rounded-r-full transition-colors",
|
||||
selected ? "bg-rubis" : "bg-transparent",
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="font-sans text-[13.5px] font-semibold text-ink">
|
||||
Étape {index + 1} ·{" "}
|
||||
<span className="tabular-nums text-rubis">
|
||||
J{step.offsetDays >= 0 ? "+" : ""}
|
||||
{step.offsetDays}
|
||||
</span>
|
||||
</p>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] font-semibold uppercase tracking-[0.1em]",
|
||||
selected ? "text-rubis" : "text-ink-3",
|
||||
)}
|
||||
>
|
||||
{step.requiresManualValidation ? "Brouillon" : "Email"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[12.5px] text-ink-2 line-clamp-2">
|
||||
« {step.subject} »
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-ink-3">
|
||||
Tonalité · {TONE_LABELS[step.tone as RelanceTone]}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 animate-pulse">
|
||||
<div className="h-3 w-24 rounded bg-cream-2" />
|
||||
<div className="h-8 w-1/3 rounded bg-cream-2" />
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_1.4fr]">
|
||||
<div className="space-y-3">
|
||||
<div className="h-24 rounded-card bg-cream-2" />
|
||||
<div className="h-24 rounded-card bg-cream-2" />
|
||||
<div className="h-24 rounded-card bg-cream-2" />
|
||||
</div>
|
||||
<div className="h-96 rounded-card bg-cream-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user