From b5b67056aad27b9e2422234f9464189db4c833ec Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Wed, 6 May 2026 11:05:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20plans=20biblioth=C3=A8que=20+=20?= =?UTF-8?q?=C3=A9diteur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/components/plans/PlanCard.tsx | 172 +++++++++ apps/web/src/lib/plans.ts | 58 ++++ apps/web/src/mocks/db.ts | 28 ++ apps/web/src/mocks/handlers/index.ts | 2 + apps/web/src/mocks/handlers/plans.ts | 122 +++++++ apps/web/src/routes/_app/plans.tsx | 94 +++-- apps/web/src/routes/_app/plans_.$slug.tsx | 383 +++++++++++++++++++++ 7 files changed, 835 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/plans/PlanCard.tsx create mode 100644 apps/web/src/lib/plans.ts create mode 100644 apps/web/src/mocks/handlers/plans.ts create mode 100644 apps/web/src/routes/_app/plans_.$slug.tsx diff --git a/apps/web/src/components/plans/PlanCard.tsx b/apps/web/src/components/plans/PlanCard.tsx new file mode 100644 index 0000000..36e7d11 --- /dev/null +++ b/apps/web/src/components/plans/PlanCard.tsx @@ -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 = { + 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 ( + +
+

+ {plan.name} +

+ {isMostUsed ? ( + + + ) : ( + + {mood} + + )} +
+ + {plan.description && ( +

+ {plan.description} +

+ )} + +
    + {plan.steps.slice(0, 3).map((step) => ( +
  • +
  • + ))} + {plan.steps.length > 3 && ( +
  • + +{plan.steps.length - 3} étape{plan.steps.length - 3 > 1 ? "s" : ""} +
  • + )} +
+ +
+

+ {usage > 0 ? ( + <> + Utilisé sur {usage}{" "} + facture{usage > 1 ? "s" : ""} + + ) : ( + Aucune facture active + )} +

+ {plan.slug && ( + + Modifier
+
+ ); +} + +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 ( + + ); +} diff --git a/apps/web/src/lib/plans.ts b/apps/web/src/lib/plans.ts new file mode 100644 index 0000000..e7536d8 --- /dev/null +++ b/apps/web/src/lib/plans.ts @@ -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 = { + 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): 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): 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" }, +]; diff --git a/apps/web/src/mocks/db.ts b/apps/web/src/mocks/db.ts index 926584e..c7d8df8 100644 --- a/apps/web/src/mocks/db.ts +++ b/apps/web/src/mocks/db.ts @@ -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>, + ): 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[] { diff --git a/apps/web/src/mocks/handlers/index.ts b/apps/web/src/mocks/handlers/index.ts index 4b731e5..0f027ba 100644 --- a/apps/web/src/mocks/handlers/index.ts +++ b/apps/web/src/mocks/handlers/index.ts @@ -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, ]; diff --git a/apps/web/src/mocks/handlers/plans.ts b/apps/web/src/mocks/handlers/plans.ts new file mode 100644 index 0000000..917da25 --- /dev/null +++ b/apps/web/src/mocks/handlers/plans.ts @@ -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 }); + }), +]; diff --git a/apps/web/src/routes/_app/plans.tsx b/apps/web/src/routes/_app/plans.tsx index d5c0993..7906fa2 100644 --- a/apps/web/src/routes/_app/plans.tsx +++ b/apps/web/src/routes/_app/plans.tsx @@ -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("/api/v1/plans"), + }); + }, }); -function PlansPage() { +function PlansLibraryPage() { + const { data: plans = [], isPending } = useQuery({ + queryKey: queryKeys.plans.all(), + queryFn: () => api.get("/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((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 (
-

- Plans de relance +

+ Plans de relance

-

- La cadence avec laquelle Rubis relance pour vous. +

+ 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.

-
); } + +function PlansSkeleton() { + return ( +
    + {Array.from({ length: 4 }).map((_, idx) => ( +
  • +
    +
    +
    +
    +
    +
    +
  • + ))} +
+ ); +} diff --git a/apps/web/src/routes/_app/plans_.$slug.tsx b/apps/web/src/routes/_app/plans_.$slug.tsx new file mode 100644 index 0000000..b8c0ded --- /dev/null +++ b/apps/web/src/routes/_app/plans_.$slug.tsx @@ -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(`/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(`/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(null); + const [selectedStepId, setSelectedStepId] = useState(null); + const bodyRef = useRef(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(`/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 ( +
+

+ Plan introuvable. +

+ + ← Retour à la bibliothèque + +
+ ); + } + + if (isPending || !plan || !draftSteps) { + return ; + } + + const selectedStep = + draftSteps.find((s) => s.id === selectedStepId) ?? draftSteps[0]; + + const updateSelectedStep = (patch: Partial) => { + 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 ( +
+ +