feat(plans): wizard de création de plan custom + génération IA Mistral

Backend
- migration : champs contact_first_name / contact_last_name (nullable)
  sur clients pour personnaliser les variables de relance
- POST /api/v1/plans : création de plan custom avec slug auto-généré
  (suffixé en cas de collision, "nouveau"/"new"/"create" réservés)
- POST /api/v1/ai/generate-relance : génération de subject+body via
  mistral-small-latest, avec brief utilisateur et tonalité ciblée
- mail_dispatcher : nouvelles variables {{daysLate}}, {{issueDate}},
  {{user.fullName}}, {{user.companyName}}, {{client.contactFirstName}},
  {{client.contactLastName}} (helper buildRelanceVars exposé pour preview)
- send_relance_job preload désormais l'organization pour exposer son name

Frontend
- /plans/nouveau : wizard 4 étapes (Identité → Cadence → Messages → Récap)
  - Stepper en haut, navigation guidée, validation par étape
  - Étape 1 : nom + tonalité globale (4 cards Doux/Standard/Ferme/Strict)
    avec aperçu de la cadence par défaut associée
  - Étape 2 : timeline horizontale (rail rubis-glow + nœuds ◆ teintés
    selon la tonalité), édition décalage/ton de l'étape sélectionnée
  - Étape 3 : édition par étape avec preview live à droite, chips de
    variables cliquables, bouton "Générer avec l'IA" qui ouvre une modale
    Mistral (brief + résultat + régénérer)
  - Étape 4 : récap avec preview de chaque email rendu sur un client fictif
- Détection des variables sensibles → warning si X clients existants n'ont
  pas le champ contactFirstName/contactLastName rempli (UX informative,
  fallback vide à l'envoi)
- "Dupliquer" sur chaque card de plan → /plans/nouveau?from=<slug>
  pour pré-remplir le wizard à partir d'un plan existant
- ClientCreateDialog : ajout des champs prénom/nom du contact dédié
- TEMPLATE_VARIABLES étendu, helper renderTemplate côté front en miroir
  exact de l'implémentation API
- MSW handlers ai/plans/clients alignés sur le nouveau contrat

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-05-06 22:55:00 +02:00
parent 8742cabebf
commit 9e531e32a9
27 changed files with 1979 additions and 39 deletions

View File

@ -0,0 +1,50 @@
import vine from '@vinejs/vine'
import { Exception } from '@adonisjs/core/exceptions'
import type { HttpContext } from '@adonisjs/core/http'
import { generateRelance } from '#services/ai_relance_generator'
const RELANCE_TONES = ['amical', 'courtois', 'ferme', 'mise_en_demeure'] as const
const generateRelanceValidator = vine.create({
tone: vine.enum(RELANCE_TONES),
offsetDays: vine.number().min(-30).max(180),
// Brief libre. On accepte vide : Mistral génère alors un message standard
// pour la tonalité + timing donnés.
prompt: vine.string().maxLength(1000).optional(),
planContext: vine.string().maxLength(500).optional(),
})
/**
* Endpoints IA. V1 : uniquement génération de templates de relance pour le
* wizard de création de plan custom. Mistral est déjà utilisé pour l'OCR
* (cf. mistral_ocr_provider.ts) on réutilise la même clé API.
*/
export default class AiController {
/**
* POST /ai/generate-relance
*
* Génère subject + body avec des placeholders Mustache prêts à insérer.
* L'utilisateur peut régénérer pour avoir une variante.
*/
async generateRelance({ auth, request, response }: HttpContext) {
auth.getUserOrFail() // auth requise
const payload = await request.validateUsing(generateRelanceValidator)
try {
const result = await generateRelance({
tone: payload.tone,
offsetDays: payload.offsetDays,
prompt: payload.prompt ?? '',
planContext: payload.planContext,
})
return response.json({ data: result })
} catch (err) {
// On wrap pour passer par le handler global et garder le format
// d'erreur uniforme côté front.
throw new Exception(
err instanceof Error ? err.message : 'Génération IA indisponible',
{ status: 502, code: 'ai_generation_failed' }
)
}
}
}

View File

@ -137,6 +137,8 @@ export default class ClientsController {
organizationId,
name: payload.name,
email: payload.email,
contactFirstName: payload.contactFirstName ?? null,
contactLastName: payload.contactLastName ?? null,
phone: payload.phone ?? null,
address: payload.address ?? null,
siret: payload.siret ?? null,

View File

@ -1,11 +1,44 @@
import Plan from '#models/plan'
import PlanStep from '#models/plan_step'
import PlanTransformer from '#transformers/plan_transformer'
import { updatePlanValidator } from '#validators/plan'
import { createPlanValidator, updatePlanValidator } from '#validators/plan'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
/**
* Slug à partir d'un nom de plan : minuscules, ASCII safe, tirets.
* On garantit l'unicité par org en suffixant un compteur si collision.
*/
function slugify(input: string): string {
return input
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60) || 'plan'
}
// Slugs réservés côté front (routes statiques type /plans/nouveau).
// Si l'utilisateur nomme son plan "nouveau", on suffixe d'office.
const RESERVED_SLUGS = new Set(['nouveau', 'new', 'create'])
async function nextAvailableSlug(organizationId: string, base: string): Promise<string> {
const start = RESERVED_SLUGS.has(base) ? `${base}-1` : base
const existing = await Plan.query()
.where('organization_id', organizationId)
.whereILike('slug', `${base}%`)
.select('slug')
const taken = new Set(existing.map((p) => p.slug))
if (!taken.has(start) && !RESERVED_SLUGS.has(start)) return start
for (let i = 2; i < 100; i++) {
const candidate = `${base}-${i}`
if (!taken.has(candidate) && !RESERVED_SLUGS.has(candidate)) return candidate
}
return `${base}-${Date.now()}`
}
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
function requireOrgId(auth: HttpContext['auth']): string {
@ -146,4 +179,50 @@ export default class PlansController {
await plan.load('steps')
return response.json({ data: serializePlan(plan) })
}
/**
* POST /plans création d'un plan custom.
*
* Slug auto-généré depuis `name`, suffixé en cas de collision dans l'org.
* Le plan custom n'est pas marqué `isDefault` il peut être supprimé
* (V2) sans toucher à la bibliothèque.
*/
async store({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const payload = await request.validateUsing(createPlanValidator)
const baseSlug = slugify(payload.name)
const slug = await nextAvailableSlug(organizationId, baseSlug)
const plan = await db.transaction(async (trx) => {
const created = await Plan.create(
{
organizationId,
slug,
name: payload.name,
description: payload.description ?? '',
isDefault: false,
},
{ client: trx }
)
await PlanStep.createMany(
payload.steps.map((s) => ({
planId: created.id,
order: s.order,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
{ client: trx }
)
return created
})
await plan.load('steps')
return response.status(201).json({ data: serializePlan(plan) })
}
}

View File

@ -36,6 +36,7 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
const invoice = await Invoice.query()
.where('id', task.invoiceId)
.preload('client')
.preload('organization')
.first()
if (!invoice) {
task.status = 'cancelled'
@ -78,7 +79,13 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
}
// Envoi normal
await sendRelanceEmail({ invoice, client: invoice.client, step, user })
await sendRelanceEmail({
invoice,
client: invoice.client,
step,
user,
organization: invoice.organization,
})
await db.transaction(async (trx) => {
task.useTransaction(trx)

View File

@ -0,0 +1,142 @@
import env from '#start/env'
const MISTRAL_API = 'https://api.mistral.ai/v1'
// Modèle chat rapide et bon en français pour générer du texte court.
// `mistral-small-latest` est ~10x moins cher que `mistral-large` et
// largement suffisant pour 3 paragraphes de relance.
const GENERATION_MODEL = 'mistral-small-latest'
export type RelanceTone = 'amical' | 'courtois' | 'ferme' | 'mise_en_demeure'
export type GenerateRelanceInput = {
/** Tonalité ciblée — guide le ton du modèle. */
tone: RelanceTone
/** Position de l'étape dans le plan (J+3, J+10…). Influence l'urgence. */
offsetDays: number
/** Brief libre rédigé par l'utilisateur (ex. "rappelle qu'on accepte les virements"). */
prompt: string
/** Contexte du plan, pour cohérence (ex. nom du plan, étapes voisines). */
planContext?: string
}
export type GenerateRelanceOutput = {
subject: string
body: string
}
const TONE_GUIDANCE: Record<RelanceTone, string> = {
amical:
"Ton chaleureux et bienveillant, presque comme un message à un partenaire de confiance. Pas de pression. On commence par 'Bonjour' suivi du prénom si dispo, sinon du nom de l'entreprise.",
courtois:
'Ton professionnel et factuel. Poli, neutre, pas de chaleur excessive ni de menace. Standard B2B.',
ferme:
"Ton ferme et direct. Rappelle l'engagement contractuel. Reste poli mais sans formule de politesse excessive. Pas d'agressivité.",
mise_en_demeure:
"Ton formel et juridique. Mentionne explicitement 'mise en demeure', un délai de paiement (8 jours), et les conséquences légales (pénalités de retard, voie judiciaire). Reste factuel, pas émotionnel.",
}
const SYSTEM_PROMPT = `Tu rédiges des emails de relance de factures impayées en français pour une TPE-PME.
Règles strictes :
- Toujours en français.
- Toujours tutoyer le **destinataire** ? NON, vouvoie systématiquement (B2B France).
- Reste concis : 4 à 8 phrases maximum pour le corps.
- Ne saute jamais de salutation ni de signature.
- Insère naturellement les variables fournies (sans les commenter).
- Si le prénom du contact n'est pas dispo, retombe sur une formule générale ("Bonjour,").
Variables disponibles à insérer (utilise la syntaxe Mustache exacte) :
- {{client.name}} : raison sociale du client
- {{client.contactFirstName}} : prénom du contact (peut être vide fallback "Bonjour,")
- {{client.contactLastName}} : nom du contact (peut être vide)
- {{numero}} : numéro de la facture
- {{amount}} : montant TTC formaté ("1 240,00 €")
- {{dueDate}} : date d'échéance ("15/04/2026")
- {{issueDate}} : date d'émission
- {{daysLate}} : nombre de jours de retard (entier)
- {{user.fullName}} : nom de l'expéditeur (la TPE)
- {{user.companyName}} : nom de l'entreprise expéditrice
- {{signature}} : bloc signature de l'expéditeur
Tu retournes un JSON strict avec deux clés : "subject" (max 100 caractères) et "body" (max 2000 caractères).`
/**
* Génère un email de relance via Mistral. Retourne `{ subject, body }`
* avec des placeholders Mustache prêts à être interpolés à l'envoi.
*
* Coût : ~0.0001 par appel sur `mistral-small-latest` (négligeable).
*/
export async function generateRelance(input: GenerateRelanceInput): Promise<GenerateRelanceOutput> {
const apiKey = env.get('MISTRAL_API_KEY', '')
if (!apiKey) {
throw new Error('MISTRAL_API_KEY manquante : génération IA indisponible.')
}
const userMessage = [
`Tonalité ciblée : ${input.tone}${TONE_GUIDANCE[input.tone]}`,
`Position dans le plan : J${input.offsetDays >= 0 ? '+' : ''}${input.offsetDays} (${
input.offsetDays < 0
? "rappel avant l'échéance"
: input.offsetDays === 0
? 'jour de l\'échéance'
: `${input.offsetDays} jours après l'échéance`
}).`,
input.planContext ? `Contexte du plan : ${input.planContext}` : null,
'',
'Brief de l\'utilisateur :',
input.prompt.trim() || '(aucun brief — génère un message standard pour cette tonalité et ce timing)',
]
.filter(Boolean)
.join('\n')
const res = await fetch(`${MISTRAL_API}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: GENERATION_MODEL,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userMessage },
],
response_format: {
type: 'json_schema',
json_schema: {
name: 'relance_email',
strict: true,
schema: {
type: 'object',
additionalProperties: false,
properties: {
subject: { type: 'string' },
body: { type: 'string' },
},
required: ['subject', 'body'],
},
},
},
temperature: 0.7,
}),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`Mistral génération relance → HTTP ${res.status}: ${text}`)
}
const json = (await res.json()) as {
choices?: { message?: { content?: string } }[]
}
const content = json?.choices?.[0]?.message?.content
if (typeof content !== 'string') {
throw new Error('Mistral chat: pas de content string dans la réponse')
}
const parsed = JSON.parse(content) as GenerateRelanceOutput
return {
subject: parsed.subject.slice(0, 200),
body: parsed.body.slice(0, 5000),
}
}

View File

@ -1,36 +1,86 @@
import mail from '@adonisjs/mail/services/main'
import env from '#start/env'
import { DateTime } from 'luxon'
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
import type Invoice from '#models/invoice'
import type Client from '#models/client'
import type PlanStep from '#models/plan_step'
import type User from '#models/user'
import type Organization from '#models/organization'
type RelancePayload = {
invoice: Invoice
client: Client
step: PlanStep
user: User | null
organization?: Organization | null
}
/**
* Construit l'objet `vars` interpolé dans subject/body. Exposé pour
* permettre la preview côté contrôleur (wizard de création de plan)
* avec les mêmes variables que ce qui sera réellement envoyé.
*
* Variables disponibles :
* - {{client.name}}, {{client.email}}
* - {{client.contactFirstName}}, {{client.contactLastName}} (peuvent être vides)
* - {{numero}}, {{amount}}, {{dueDate}}, {{issueDate}}
* - {{daysLate}} (jours de retard depuis dueDate, négatif = avant échéance)
* - {{user.fullName}}, {{user.companyName}}
* - {{signature}}
*/
export function buildRelanceVars({
invoice,
client,
user,
organization,
}: {
invoice: Pick<Invoice, 'numero' | 'amountTtcCents' | 'dueDate' | 'issueDate'>
client: Pick<Client, 'name' | 'email' | 'contactFirstName' | 'contactLastName'>
user: Pick<User, 'fullName' | 'signature' | 'email'> | null
organization?: Pick<Organization, 'name'> | null
}) {
const dueDate = invoice.dueDate.toJSDate()
// Jours de retard arrondis à l'entier (UTC pour cohérence).
const daysLate = Math.floor(
DateTime.utc().startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days
)
return {
client: {
name: client.name,
email: client.email,
contactFirstName: client.contactFirstName ?? '',
contactLastName: client.contactLastName ?? '',
},
user: {
fullName: user?.fullName ?? '',
companyName: organization?.name ?? '',
},
numero: invoice.numero,
amount: formatAmountFr(invoice.amountTtcCents),
dueDate: formatDateFr(dueDate),
issueDate: formatDateFr(invoice.issueDate.toJSDate()),
daysLate: String(daysLate),
signature: user?.signature ?? user?.fullName ?? '',
}
}
/**
* Envoie un email de relance à un client à partir d'un step.
* Le subject/body du step contiennent des placeholders Mustache-like
* (`{{client.name}}`, `{{numero}}`, `{{amount}}`, `{{dueDate}}`,
* `{{signature}}`) qu'on interpole avant l'envoi.
* qu'on interpole avant l'envoi (cf. `buildRelanceVars`).
*
* Le mailer effectif est piloté par MAIL_DRIVER (`smtp` Mailpit en dev,
* `resend` en prod).
*/
export async function sendRelanceEmail({ invoice, client, step, user }: RelancePayload) {
const vars = {
client: { name: client.name, email: client.email },
numero: invoice.numero,
amount: formatAmountFr(invoice.amountTtcCents),
dueDate: formatDateFr(invoice.dueDate.toJSDate()),
issueDate: formatDateFr(invoice.issueDate.toJSDate()),
signature: user?.signature ?? user?.fullName ?? '',
}
export async function sendRelanceEmail({
invoice,
client,
step,
user,
organization,
}: RelancePayload) {
const vars = buildRelanceVars({ invoice, client, user, organization })
const subject = renderTemplate(step.subject, vars)
const body = renderTemplate(step.body, vars)

View File

@ -9,6 +9,8 @@ export default class ClientTransformer extends BaseTransformer<Client> {
organizationId: c.organizationId,
name: c.name,
email: c.email,
contactFirstName: c.contactFirstName,
contactLastName: c.contactLastName,
phone: c.phone,
address: c.address,
siret: c.siret,

View File

@ -7,6 +7,9 @@ const siret = () => vine.string().regex(/^\d{14}$/)
const phone = () => vine.string().maxLength(40)
const address = () => vine.string().maxLength(500)
const notes = () => vine.string().maxLength(2000)
// Prénom/nom du contact dédié — utilisés comme variables dans les templates
// custom ({{client.contactFirstName}}). Optionnels.
const contactName = () => vine.string().minLength(1).maxLength(80)
/**
* Validator pour POST /clients. Email **requis** : sans email, Rubis ne
@ -15,6 +18,8 @@ const notes = () => vine.string().maxLength(2000)
export const createClientValidator = vine.create({
name: name(),
email: email(),
contactFirstName: contactName().nullable().optional(),
contactLastName: contactName().nullable().optional(),
phone: phone().nullable().optional(),
address: address().nullable().optional(),
siret: siret().nullable().optional(),
@ -27,6 +32,8 @@ export const createClientValidator = vine.create({
export const updateClientValidator = vine.create({
name: name().optional(),
email: email().optional(),
contactFirstName: contactName().nullable().optional(),
contactLastName: contactName().nullable().optional(),
phone: phone().nullable().optional(),
address: address().nullable().optional(),
siret: siret().nullable().optional(),

View File

@ -24,3 +24,13 @@ export const updatePlanValidator = vine.create({
description: vine.string().maxLength(500).optional(),
steps: vine.array(planStep).minLength(1).maxLength(10).optional(),
})
/**
* Validator pour POST /plans création d'un plan custom.
* Le slug est généré côté contrôleur depuis le name.
*/
export const createPlanValidator = vine.create({
name: vine.string().minLength(1).maxLength(80),
description: vine.string().maxLength(500).optional(),
steps: vine.array(planStep).minLength(1).maxLength(10),
})

View File

@ -0,0 +1,25 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Ajoute le prénom/nom du contact dédié sur la fiche client. Optionnels :
* une fiche minimale (raison sociale + email) doit rester valide. Utilisés
* comme variables `{{client.contactFirstName}}` / `{{client.contactLastName}}`
* dans les templates de relance custom.
*/
export default class extends BaseSchema {
protected tableName = 'clients'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('contact_first_name', 80).nullable()
table.string('contact_last_name', 80).nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('contact_first_name')
table.dropColumn('contact_last_name')
})
}
}

View File

@ -81,10 +81,14 @@ export class CheckinTaskSchema extends BaseModel {
}
export class ClientSchema extends BaseModel {
static $columns = ['address', 'createdAt', 'email', 'id', 'name', 'notes', 'organizationId', 'phone', 'siret', 'updatedAt'] as const
static $columns = ['address', 'contactFirstName', 'contactLastName', 'createdAt', 'email', 'id', 'name', 'notes', 'organizationId', 'phone', 'siret', 'updatedAt'] as const
$columns = ClientSchema.$columns
@column()
declare address: string | null
@column()
declare contactFirstName: string | null
@column()
declare contactLastName: string | null
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()

View File

@ -88,13 +88,27 @@ router
.as('clients')
.use(middleware.auth())
/**
* IA auth requise. Génération de templates de relance avec Mistral
* pour le wizard de création de plan custom.
*/
router
.group(() => {
router.post('generate-relance', [controllers.Ai, 'generateRelance']).as('generate-relance')
})
.prefix('ai')
.as('ai')
.use(middleware.auth())
/**
* Plans auth requise. Lookup par slug (stable et lisible :
* /parametres/plans/standard-30j). POST plan custom = V2.
* /parametres/plans/standard-30j). POST = création d'un plan custom,
* slug auto-généré depuis le name.
*/
router
.group(() => {
router.get('', [controllers.Plans, 'index']).as('index')
router.post('', [controllers.Plans, 'store']).as('store')
router.get(':slug', [controllers.Plans, 'show']).as('show')
router.patch(':slug', [controllers.Plans, 'update']).as('update')
})

View File

@ -42,6 +42,8 @@ import { Textarea } from "@/components/ui/Textarea";
type FormValues = {
name: string;
email: string;
contactFirstName: string;
contactLastName: string;
phone: string;
address: string;
siret: string;
@ -88,6 +90,10 @@ export function ClientCreateDialog({
api.post<Client>("/api/v1/clients", {
name: input.name.trim(),
email: input.email.trim(),
contactFirstName:
input.contactFirstName.trim() === "" ? null : input.contactFirstName.trim(),
contactLastName:
input.contactLastName.trim() === "" ? null : input.contactLastName.trim(),
phone: input.phone.trim() === "" ? null : input.phone.trim(),
address: input.address.trim() === "" ? null : input.address.trim(),
siret: input.siret.trim() === "" ? null : input.siret.trim(),
@ -112,6 +118,8 @@ export function ClientCreateDialog({
const initialValues: FormValues = {
name: defaultName,
email: "",
contactFirstName: "",
contactLastName: "",
phone: "",
address: "",
siret: "",
@ -202,6 +210,39 @@ export function ClientCreateDialog({
)}
</form.Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<form.Field name="contactFirstName">
{(field) => (
<Field
label="Prénom du contact"
htmlFor={field.name}
hint="Optionnel. Permet de personnaliser les relances ({{client.contactFirstName}})."
>
<Input
id={field.name}
autoComplete="off"
placeholder="Marie"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
)}
</form.Field>
<form.Field name="contactLastName">
{(field) => (
<Field label="Nom du contact" htmlFor={field.name} hint="Optionnel.">
<Input
id={field.name}
autoComplete="off"
placeholder="Martin"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
/>
</Field>
)}
</form.Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<form.Field name="phone">
{(field) => (

View File

@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router";
import { Plus, ArrowRight, Sparkles } from "lucide-react";
import { Plus, ArrowRight, Sparkles, Copy as CopyIcon } from "lucide-react";
import type { Plan } from "@rubis/shared";
import { Card } from "@/components/ui/Card";
@ -117,15 +117,27 @@ export function PlanCard({ plan, isMostUsed = false, className }: PlanCardProps)
<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 className="flex items-center gap-3">
{plan.slug && (
<Link
to="/plans/nouveau"
search={{ from: plan.slug }}
className="inline-flex items-center gap-1 text-[11.5px] font-medium text-ink-3 hover:text-ink-2"
title="Créer un plan en partant de celui-ci"
>
<CopyIcon size={11} aria-hidden="true" /> Dupliquer
</Link>
)}
{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>
</div>
</Card>
);
@ -146,27 +158,29 @@ function labelForTone(tone: string): string {
}
}
/** Card "+ Créer un plan" — placeholder pour la création de plans custom. */
/**
* Card "+ Créer un plan" entrée du wizard 4 étapes.
* Lien direct vers `/plans/nouveau`. Pour partir d'un plan existant,
* passer par "Dupliquer" sur la card du plan source.
*/
export function CreatePlanCard() {
return (
<button
type="button"
disabled
<Link
to="/plans/nouveau"
className={cn(
"flex flex-col items-center justify-center gap-2 w-full",
"flex flex-col items-center justify-center gap-2 w-full h-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",
"text-ink-3 transition-colors hover:border-rubis hover:text-rubis-deep hover:bg-rubis-glow/20",
"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&apos;instant, dupliquez un des plans existants.
<span className="text-[11.5px] italic text-ink-3 max-w-[200px] text-center leading-snug">
Wizard guidé en 4 étapes, avec assistance IA pour rédiger les emails.
</span>
</button>
</Link>
);
}

View File

@ -0,0 +1,162 @@
import { useState, useEffect } from "react";
import { useMutation } from "@tanstack/react-query";
import { Sparkles, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import type { RelanceTone } from "@rubis/shared";
import { api } from "@/lib/api";
import { TONE_LABELS } from "@/lib/plans";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/Dialog";
import { Button } from "@/components/ui/Button";
import { Field } from "@/components/ui/Field";
import { Textarea } from "@/components/ui/Textarea";
import { EmailPreview } from "./EmailPreview";
type GenerateResult = { subject: string; body: string };
const DEFAULT_PROMPTS: Record<RelanceTone, string> = {
amical:
"Premier rappel chaleureux, pas de pression. Si possible, mentionne qu'on accepte les virements et qu'on reste dispo.",
courtois:
"Relance standard B2B, factuelle, polie mais directe. Demande un règlement dans les meilleurs délais.",
ferme:
"Relance ferme : on a déjà relancé plusieurs fois, on attend le règlement sous 8 jours avant la mise en demeure.",
mise_en_demeure:
"Mise en demeure formelle. Mentionne le délai de 8 jours, les pénalités de retard, l'indemnité forfaitaire de 40€, et la possibilité d'engager une procédure judiciaire.",
};
/**
* Modale qui appelle l'IA pour générer le subject + body d'une étape.
* L'utilisateur peut régénérer pour avoir une variante avant d'accepter.
*/
export function AiGenerateModal({
open,
onOpenChange,
tone,
offsetDays,
planContext,
onAccept,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
tone: RelanceTone;
offsetDays: number;
planContext?: string;
onAccept: (result: GenerateResult) => void;
}) {
const [prompt, setPrompt] = useState(DEFAULT_PROMPTS[tone]);
const [result, setResult] = useState<GenerateResult | null>(null);
// Reset à chaque ouverture pour repartir d'un état propre.
useEffect(() => {
if (open) {
setPrompt(DEFAULT_PROMPTS[tone]);
setResult(null);
}
}, [open, tone]);
const generateMutation = useMutation({
mutationFn: () =>
api.post<GenerateResult>("/api/v1/ai/generate-relance", {
tone,
offsetDays,
prompt,
planContext,
}),
onSuccess: (data) => setResult(data),
onError: () => toast.error("Génération impossible. Réessayez dans un instant."),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent maxWidth={720}>
<DialogHeader>
<DialogTitle>
<span className="inline-flex items-center gap-2">
<Sparkles size={16} className="text-rubis" /> Générer avec l'IA
</span>
</DialogTitle>
<DialogDescription>
L'IA va rédiger un email de relance avec une tonalité{" "}
<strong>{TONE_LABELS[tone].toLowerCase()}</strong>, programmé J
{offsetDays >= 0 ? "+" : ""}
{offsetDays}. Affinez le brief si besoin.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-5">
<Field
label="Brief pour l'IA"
htmlFor="ai-prompt"
hint="Décrivez ce que vous voulez transmettre. Plus vous êtes précis, mieux c'est."
>
<Textarea
id="ai-prompt"
rows={3}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Ex. Relance amicale, on accepte les virements, le client est de longue date…"
/>
</Field>
{result && (
<div className="space-y-2">
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-3 font-semibold">
Résultat
</p>
<EmailPreview subject={result.subject} body={result.body} />
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary" size="sm" type="button">
Annuler
</Button>
</DialogClose>
{result ? (
<>
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => generateMutation.mutate()}
loading={generateMutation.isPending}
>
<RefreshCw size={13} /> Régénérer
</Button>
<Button
size="sm"
type="button"
onClick={() => {
onAccept(result);
onOpenChange(false);
}}
>
Utiliser ce message
</Button>
</>
) : (
<Button
size="sm"
type="button"
onClick={() => generateMutation.mutate()}
loading={generateMutation.isPending}
>
<Sparkles size={13} /> Générer
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,154 @@
import { Plus, X } from "lucide-react";
import type { RelanceTone } from "@rubis/shared";
import { cn } from "@/lib/utils";
import { TONE_LABELS } from "@/lib/plans";
/**
* Étape minimale pour la timeline (tous les champs ne sont pas connus à
* l'étape 2 du wizard — subject/body arrivent à l'étape 3).
*/
export type DraftStepLite = {
offsetDays: number;
tone: RelanceTone;
requiresManualValidation?: boolean;
};
const TONE_NODE_CLASS: Record<RelanceTone, string> = {
amical: "bg-rubis-glow border-rubis text-rubis-deep",
courtois: "bg-cream-2 border-line text-ink",
ferme: "bg-ink text-cream border-ink",
mise_en_demeure: "bg-rubis-deep text-cream border-rubis-deep",
};
/**
* Timeline horizontale (ou verticale en mobile) de la cadence d'un plan.
*
* - 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
*
* 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.
*/
export function CadenceTimeline({
steps,
onUpdateStep,
onAddStep,
onRemoveStep,
selectedIndex = -1,
onSelectStep,
}: {
steps: DraftStepLite[];
onUpdateStep?: (idx: number, patch: Partial<DraftStepLite>) => void;
onAddStep: () => void;
onRemoveStep: (idx: number) => void;
selectedIndex?: number;
onSelectStep?: (idx: number) => void;
}) {
return (
<div className="rounded-card border border-line bg-cream/40 p-6">
<div className="flex items-center justify-between mb-5">
<p className="text-[12px] uppercase tracking-[0.1em] text-ink-3 font-semibold">
Cadence
</p>
<p className="text-[11.5px] text-ink-3 italic">
{steps.length} étape{steps.length > 1 ? "s" : ""} · max 8
</p>
</div>
<div className="relative">
{/* Rail */}
{steps.length > 0 && (
<div
aria-hidden="true"
className="absolute left-4 right-4 top-[26px] h-px bg-rubis-glow lg:block"
/>
)}
<ol className="relative flex flex-col gap-3 lg:flex-row lg:items-start lg:gap-2">
{steps.map((step, idx) => {
const isSelected = idx === selectedIndex;
return (
<li
key={idx}
className="group relative flex items-center gap-3 lg:flex-1 lg:flex-col lg:items-center"
>
<button
type="button"
onClick={() => onSelectStep?.(idx)}
className={cn(
"relative flex size-13 items-center justify-center rotate-45 border-2 transition-all",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
TONE_NODE_CLASS[step.tone],
isSelected && "ring-4 ring-rubis-glow scale-105",
)}
aria-label={`Étape J${step.offsetDays >= 0 ? "+" : ""}${step.offsetDays}, ${TONE_LABELS[step.tone]}`}
style={{ width: 52, height: 52 }}
>
<span className="-rotate-45 font-display text-[13px] font-bold tabular-nums">
{step.offsetDays >= 0 ? "+" : ""}
{step.offsetDays}
</span>
</button>
<div className="flex flex-col items-start lg:items-center min-w-0 lg:mt-2">
<p className="font-display text-[13px] font-semibold text-ink truncate max-w-[120px]">
{TONE_LABELS[step.tone]}
</p>
<p className="text-[11px] text-ink-3 tabular-nums">
J{step.offsetDays >= 0 ? "+" : ""}
{step.offsetDays}
</p>
</div>
{steps.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveStep(idx);
}}
className={cn(
"absolute -top-1 -right-1 lg:right-auto lg:left-[calc(50%+18px)]",
"flex size-5 items-center justify-center rounded-full",
"bg-white border border-line text-ink-3",
"opacity-0 group-hover:opacity-100 focus:opacity-100",
"hover:text-rubis-deep hover:border-rubis transition-opacity",
)}
aria-label="Retirer cette étape"
>
<X size={11} />
</button>
)}
</li>
);
})}
{/* + add at the end */}
{steps.length < 8 && (
<li className="flex items-center gap-3 lg:flex-col lg:items-center">
<button
type="button"
onClick={onAddStep}
className={cn(
"flex size-13 items-center justify-center rounded-full",
"border-2 border-dashed border-line bg-white text-ink-3",
"hover:border-rubis hover:text-rubis transition-colors",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
style={{ width: 52, height: 52 }}
aria-label="Ajouter une étape"
>
<Plus size={20} />
</button>
<span className="text-[11px] text-ink-3 lg:mt-2">Ajouter</span>
</li>
)}
</ol>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
import { Mail } from "lucide-react";
import { renderTemplate, PREVIEW_VARS } from "@/lib/plans";
import { cn } from "@/lib/utils";
/**
* Preview d'un email rendu avec des données fictives. Utilisée à l'étape
* Récap du wizard et en aperçu live de l'étape Messages.
*
* On simule le chrome d'un client mail (de/à/objet) pour rendre l'aperçu
* tangible. C'est cosmétique mais ça aide l'utilisateur à se projeter.
*/
export function EmailPreview({
subject,
body,
fromName = PREVIEW_VARS.user.fullName,
fromAddress = "rubis@arthurbarre.fr",
className,
}: {
subject: string;
body: string;
fromName?: string;
fromAddress?: string;
className?: string;
}) {
const renderedSubject = renderTemplate(subject, PREVIEW_VARS);
const renderedBody = renderTemplate(body, PREVIEW_VARS);
return (
<div
className={cn(
"rounded-card border border-line bg-white shadow-card overflow-hidden",
className,
)}
>
<header className="border-b border-line bg-cream-2/50 px-5 py-3 flex items-center gap-2">
<Mail size={14} className="text-ink-3" />
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-3 font-semibold">
Aperçu de l'email
</span>
</header>
<div className="px-5 py-4 space-y-1 border-b border-line">
<p className="text-[12px] text-ink-3">
<span className="font-semibold text-ink-2">De :</span> {fromName}{" "}
&lt;{fromAddress}&gt;
</p>
<p className="text-[12px] text-ink-3">
<span className="font-semibold text-ink-2">À :</span>{" "}
{PREVIEW_VARS.client.contactFirstName} {PREVIEW_VARS.client.contactLastName}
{" — "}
{PREVIEW_VARS.client.email}
</p>
<p className="text-[14px] font-display font-semibold text-ink mt-1.5 leading-tight">
{renderedSubject || (
<span className="italic text-ink-3 font-normal">(sans objet)</span>
)}
</p>
</div>
<div className="px-5 py-4">
{renderedBody.trim() === "" ? (
<p className="italic text-ink-3 text-[13px]">
Le corps de l'email s'affichera ici dès que vous l'aurez rédigé.
</p>
) : (
<pre className="whitespace-pre-wrap font-sans text-[13.5px] leading-relaxed text-ink-2">
{renderedBody}
</pre>
)}
</div>
</div>
);
}

View File

@ -1,5 +1,45 @@
import type { Plan, RelanceTone } from "@rubis/shared";
/**
* Mini interpolateur Mustache-like, miroir de
* `apps/api/app/services/template.ts:renderTemplate`. Utilisé pour la
* preview live dans le wizard de création de plan custom.
*/
export function renderTemplate(template: string, vars: Record<string, unknown>): string {
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path: string) => {
const parts = path.split(".");
let val: unknown = vars;
for (const p of parts) {
if (val == null || typeof val !== "object") return "";
val = (val as Record<string, unknown>)[p];
}
return val == null ? "" : String(val);
});
}
/**
* Vars de preview : utilisées par le wizard pour montrer l'email tel
* qu'il sera reçu, avec un client/facture fictifs.
*/
export const PREVIEW_VARS = {
client: {
name: "Boulangerie Martin SARL",
email: "compta@boulangerie-martin.fr",
contactFirstName: "Marie",
contactLastName: "Martin",
},
user: {
fullName: "Arthur Barré",
companyName: "Maçonnerie Dupont",
},
numero: "F-2026-0042",
amount: "1 240,00 €",
dueDate: "15/04/2026",
issueDate: "15/03/2026",
daysLate: "12",
signature: "Cordialement,\nArthur Barré\nMaçonnerie Dupont",
} as const;
/**
* Helpers de présentation des plans de relance.
* Garde la conversion tonalité label public au même endroit.
@ -47,12 +87,41 @@ export type TemplateVariable = {
label: string;
/** Aperçu utilisé dans l'éditeur (placeholder réaliste). */
preview: string;
/** Si présent, exige qu'un champ correspondant soit rempli sur la fiche
* client pour fonctionner. Sinon le token est interpolé en chaîne vide. */
requiresClientField?: "contactFirstName" | "contactLastName";
};
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" },
// Client
{ token: "{{client.name}}", label: "Raison sociale", preview: "Boulangerie Martin SARL" },
{
token: "{{client.contactFirstName}}",
label: "Prénom contact",
preview: "Marie",
requiresClientField: "contactFirstName",
},
{
token: "{{client.contactLastName}}",
label: "Nom contact",
preview: "Martin",
requiresClientField: "contactLastName",
},
// Facture
{ token: "{{numero}}", label: "Numéro facture", preview: "F-2026-0042" },
{ token: "{{amount}}", label: "Montant TTC", preview: "1 240,00 €" },
{ token: "{{dueDate}}", label: "Échéance", preview: "15/04/2026" },
{ token: "{{issueDate}}", label: "Date émission", preview: "15/03/2026" },
{ token: "{{daysLate}}", label: "Jours de retard", preview: "12" },
// Expéditeur (la TPE qui envoie)
{ token: "{{user.fullName}}", label: "Votre nom", preview: "Arthur Barré" },
{ token: "{{user.companyName}}", label: "Votre entreprise", preview: "Maçonnerie Dupont" },
{ token: "{{signature}}", label: "Signature", preview: "Cordialement,\nArthur" },
];
/**
* Variables qui exigent qu'un champ correspondant soit rempli sur la fiche
* client. Pour chaque token utilisé dans un template, on peut détecter
* combien de clients existants n'ont pas le champ requis (warning UX).
*/
export type ClientRequiredField = "contactFirstName" | "contactLastName";

View File

@ -286,6 +286,23 @@ export const mockDb = {
listPlansForOrg(orgId: string): Plan[] {
return load().plans.filter((p) => p.organizationId === orgId);
},
createPlan(
orgId: string,
input: Omit<Plan, "id" | "organizationId" | "createdAt" | "updatedAt">,
): Plan {
const db = load();
const now = new Date().toISOString();
const plan: Plan = {
...input,
id: `plan_${crypto.randomUUID()}`,
organizationId: orgId,
createdAt: now,
updatedAt: now,
};
db.plans.push(plan);
save(db);
return plan;
},
updatePlan(
orgId: string,
id: string,

View File

@ -0,0 +1,117 @@
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 },
);
}
const generateSchema = z.object({
tone: z.enum(RELANCE_TONES),
offsetDays: z.number().int().min(-30).max(180),
prompt: z.string().max(1000).optional(),
planContext: z.string().max(500).optional(),
});
/**
* Mock de Mistral pour le dev sans clé API. Renvoie un template plausible
* en fonction du ton et du timing. Le wording n'est pas calibré pour la
* prod c'est juste pour valider l'UX du wizard sans coût.
*/
function mockGenerate(
tone: z.infer<typeof generateSchema>["tone"],
offsetDays: number,
prompt: string,
): { subject: string; body: string } {
const briefSuffix = prompt.trim() ? ` (${prompt.trim().slice(0, 60)}…)` : "";
switch (tone) {
case "amical":
return {
subject: "Petit rappel — facture {{numero}}",
body: [
"Bonjour {{client.contactFirstName}},",
"",
`J'espère que tout va bien chez {{client.name}}. Un petit rappel concernant la facture {{numero}} d'un montant de {{amount}}, échue le {{dueDate}}.${briefSuffix}`,
"",
"Si le règlement est déjà parti, n'en tenez pas compte. Sinon, merci d'avance.",
"",
"{{signature}}",
].join("\n"),
};
case "courtois":
return {
subject: "Relance — facture {{numero}} en attente de règlement",
body: [
"Bonjour {{client.contactFirstName}},",
"",
`Sauf erreur de notre part, la facture {{numero}} d'un montant de {{amount}} émise le {{issueDate}} reste impayée à ce jour ({{daysLate}} jours de retard).${briefSuffix}`,
"",
"Merci de procéder au règlement dans les meilleurs délais.",
"",
"{{signature}}",
].join("\n"),
};
case "ferme":
return {
subject: "Relance ferme — facture {{numero}}",
body: [
"Bonjour,",
"",
`Malgré nos précédentes relances, la facture {{numero}} d'un montant de {{amount}} reste impayée. Le retard atteint aujourd'hui {{daysLate}} jours.${briefSuffix}`,
"",
"Nous vous demandons de régulariser cette situation sous 8 jours afin d'éviter une mise en demeure formelle.",
"",
"{{signature}}",
].join("\n"),
};
case "mise_en_demeure":
return {
subject: "Mise en demeure — facture {{numero}}",
body: [
"Madame, Monsieur,",
"",
`Par la présente, nous vous mettons en demeure de procéder, sous 8 jours, au règlement de la facture {{numero}} d'un montant de {{amount}}, émise le {{issueDate}} et échue depuis {{daysLate}} jours.${briefSuffix}`,
"",
"À défaut, nous nous réservons le droit d'engager toute procédure utile à la préservation de nos droits, en ce compris la voie judiciaire, et de réclamer les pénalités de retard ainsi que l'indemnité forfaitaire pour frais de recouvrement prévues par la loi.",
"",
"{{signature}}",
].join("\n"),
};
}
}
export const aiHandlers = [
// POST /api/v1/ai/generate-relance — génération d'un template
http.post(`${apiBase}/ai/generate-relance`, async ({ request }) => {
const userId = userIdFromAuthHeader(request.headers.get("authorization"));
if (!userId || !mockDb.findUserById(userId)) return unauthenticated();
const json = await request.json();
const parsed = generateSchema.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 },
);
}
// Latence simulée pour que le spinner soit visible.
await new Promise((r) => setTimeout(r, 600));
const result = mockGenerate(parsed.data.tone, parsed.data.offsetDays, parsed.data.prompt ?? "");
return HttpResponse.json({ data: result });
}),
];

View File

@ -74,6 +74,8 @@ function computeStats(invoices: StoredInvoice[], now = new Date()) {
const updateClientSchema = z.object({
name: z.string().min(1).max(120).optional(),
email: z.string().email("Email invalide").min(1).optional(),
contactFirstName: z.string().min(1).max(80).nullable().optional(),
contactLastName: z.string().min(1).max(80).nullable().optional(),
phone: z.string().max(40).nullable().optional(),
address: z.string().max(500).nullable().optional(),
siret: z
@ -90,6 +92,8 @@ const createClientSchema = z.object({
// automatiques. C'est le pivot du produit, on n'accepte pas de fiche
// client sans canal de communication actif.
email: z.string().min(1, "Email requis").email("Format d'email invalide"),
contactFirstName: z.string().min(1).max(80).nullable().optional().default(null),
contactLastName: z.string().min(1).max(80).nullable().optional().default(null),
phone: z.string().max(40).nullable().optional().default(null),
address: z.string().max(500).nullable().optional().default(null),
siret: z
@ -192,6 +196,8 @@ export const clientHandlers = [
const created = mockDb.createClient(orgId, {
name: parsed.data.name,
email: parsed.data.email,
contactFirstName: parsed.data.contactFirstName,
contactLastName: parsed.data.contactLastName,
phone: parsed.data.phone,
address: parsed.data.address,
siret: parsed.data.siret,

View File

@ -4,6 +4,7 @@ import { dashboardHandlers } from "./dashboard";
import { invoiceHandlers } from "./invoices";
import { planHandlers } from "./plans";
import { clientHandlers } from "./clients";
import { aiHandlers } from "./ai";
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
export const handlers = [
@ -13,4 +14,5 @@ export const handlers = [
...invoiceHandlers,
...planHandlers,
...clientHandlers,
...aiHandlers,
];

View File

@ -43,6 +43,37 @@ const updatePlanSchema = z.object({
steps: z.array(updatePlanStepSchema).min(1).max(10).optional(),
});
const createPlanSchema = z.object({
name: z.string().min(1).max(80),
description: z.string().max(500).optional(),
steps: z.array(updatePlanStepSchema).min(1).max(10),
});
const RESERVED_SLUGS = new Set(["nouveau", "new", "create"]);
function slugify(input: string): string {
return (
input
.normalize("NFD")
.replace(/[̀-ͯ]/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/gu, "-")
.replace(/^-+|-+$/gu, "")
.slice(0, 60) || "plan"
);
}
function uniqueSlug(orgId: string, base: string): string {
const start = RESERVED_SLUGS.has(base) ? `${base}-1` : base;
const taken = new Set(mockDb.listPlansForOrg(orgId).map((p) => p.slug));
if (!taken.has(start) && !RESERVED_SLUGS.has(start)) return start;
for (let i = 2; i < 100; i++) {
const candidate = `${base}-${i}`;
if (!taken.has(candidate) && !RESERVED_SLUGS.has(candidate)) return candidate;
}
return `${base}-${Date.now()}`;
}
export const planHandlers = [
// GET /api/v1/plans — liste enrichie avec compteurs d'usage
http.get(`${apiBase}/plans`, ({ request }) => {
@ -79,6 +110,41 @@ export const planHandlers = [
return HttpResponse.json({ data: { ...plan, usageCount } });
}),
// POST /api/v1/plans — création d'un plan custom (slug auto-généré)
http.post(`${apiBase}/plans`, async ({ request }) => {
const orgId = authedOrgId(request.headers.get("authorization"));
if (!orgId) return unauthenticated();
const json = await request.json();
const parsed = createPlanSchema.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 },
);
}
const slug = uniqueSlug(orgId, slugify(parsed.data.name));
const planId = `plan_${crypto.randomUUID()}`;
const created = mockDb.createPlan(orgId, {
slug,
name: parsed.data.name,
description: parsed.data.description ?? "",
isDefault: false,
steps: parsed.data.steps.map((s, idx) => ({
...s,
id: s.id ?? `step_${planId}_${idx}_${Date.now()}`,
})),
});
return HttpResponse.json({ data: created }, { status: 201 });
}),
// PATCH /api/v1/plans/:slug — sauvegarde nom/description/étapes
http.patch(`${apiBase}/plans/:slug`, async ({ request, params }) => {
const orgId = authedOrgId(request.headers.get("authorization"));

View File

@ -24,6 +24,8 @@ export const SEED_CLIENTS: Client[] = [
organizationId: ORG,
name: "Boulangerie Martin SARL",
email: "compta@boulangerie-martin.fr",
contactFirstName: "Marie",
contactLastName: "Martin",
phone: "+33 1 23 45 67 89",
address: "12 rue du Pain, 75011 Paris",
siret: "82345678900012",
@ -36,6 +38,8 @@ export const SEED_CLIENTS: Client[] = [
organizationId: ORG,
name: "Atelier Durand",
email: "contact@atelier-durand.fr",
contactFirstName: null,
contactLastName: null,
phone: null,
address: null,
siret: null,
@ -48,6 +52,8 @@ export const SEED_CLIENTS: Client[] = [
organizationId: ORG,
name: "Cabinet Rousseau",
email: "facturation@cabinet-rousseau.fr",
contactFirstName: "Julien",
contactLastName: "Rousseau",
phone: "+33 4 56 78 90 12",
address: "8 place de la République, 69002 Lyon",
siret: "53412987600028",
@ -60,6 +66,8 @@ export const SEED_CLIENTS: Client[] = [
organizationId: ORG,
name: "Garage Lemoine",
email: "admin@garage-lemoine.fr",
contactFirstName: null,
contactLastName: null,
phone: null,
address: null,
siret: null,
@ -72,6 +80,8 @@ export const SEED_CLIENTS: Client[] = [
organizationId: ORG,
name: "Studio Lefèvre",
email: "hello@studio-lefevre.com",
contactFirstName: "Camille",
contactLastName: "Lefèvre",
phone: null,
address: null,
siret: null,

View File

@ -0,0 +1,812 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import {
ArrowLeft,
ArrowRight,
AlertTriangle,
Sparkles,
Check,
Pencil,
} from "lucide-react";
import { toast } from "sonner";
import type { Client, Plan, PlanStep, RelanceTone } from "@rubis/shared";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import { TONE_LABELS, TEMPLATE_VARIABLES, PREVIEW_VARS } from "@/lib/plans";
import { cn } from "@/lib/utils";
import { Stepper } from "@/components/ui/Stepper";
import { Card } from "@/components/ui/Card";
import { Button } from "@/components/ui/Button";
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 { EmailPreview } from "@/components/plans/wizard/EmailPreview";
import { AiGenerateModal } from "@/components/plans/wizard/AiGenerateModal";
type WizardStep = 1 | 2 | 3 | 4;
type DraftStep = {
offsetDays: number;
tone: RelanceTone;
subject: string;
body: string;
requiresManualValidation: boolean;
};
type Draft = {
name: string;
description: string;
/** Tonalité globale choisie à l'étape 1 — sert de défaut pour les nouvelles étapes. */
globalTone: RelanceTone;
steps: DraftStep[];
};
const TONALITY_CARDS: Array<{
tone: RelanceTone;
title: string;
helper: string;
defaultCadence: { offsetDays: number; tone: RelanceTone }[];
}> = [
{
tone: "amical",
title: "Doux",
helper: "Pour les clients de longue date — on prend des gants.",
defaultCadence: [
{ offsetDays: 5, tone: "amical" },
{ offsetDays: 20, tone: "amical" },
{ offsetDays: 45, tone: "courtois" },
],
},
{
tone: "courtois",
title: "Standard",
helper: "Cadence sobre, ton qui monte progressivement. Le défaut B2B.",
defaultCadence: [
{ offsetDays: 3, tone: "amical" },
{ offsetDays: 10, tone: "courtois" },
{ offsetDays: 25, tone: "ferme" },
],
},
{
tone: "ferme",
title: "Ferme",
helper: "Cadence resserrée pour les retards récurrents.",
defaultCadence: [
{ offsetDays: 1, tone: "courtois" },
{ offsetDays: 7, tone: "ferme" },
{ offsetDays: 15, tone: "ferme" },
],
},
{
tone: "mise_en_demeure",
title: "Strict",
helper: "Pour les clients à risque, mise en demeure rapide.",
defaultCadence: [
{ offsetDays: 1, tone: "courtois" },
{ offsetDays: 5, tone: "ferme" },
{ offsetDays: 10, tone: "mise_en_demeure" },
],
},
];
function emptyStep(tone: RelanceTone, offsetDays: number): DraftStep {
return {
offsetDays,
tone,
subject: "",
body: "",
// Mise en demeure → validation manuelle obligatoire (cf. CLAUDE.md §4).
requiresManualValidation: tone === "mise_en_demeure",
};
}
const STEPS = [
{ id: "identity", label: "Identité" },
{ id: "cadence", label: "Cadence" },
{ id: "messages", label: "Messages" },
{ id: "recap", label: "Récap" },
] as const;
export const Route = createFileRoute("/_app/plans_/nouveau")({
component: PlanCreateWizard,
validateSearch: z.object({
/** Slug d'un plan existant à dupliquer pour pré-remplir le wizard. */
from: z.string().optional(),
}),
});
function PlanCreateWizard() {
const navigate = useNavigate();
const { from } = Route.useSearch();
const queryClient = useQueryClient();
const [step, setStep] = useState<WizardStep>(1);
// Si on duplique, on charge le plan source pour pré-remplir le draft.
const { data: sourcePlan } = useQuery({
queryKey: queryKeys.plans.detail(from ?? ""),
queryFn: () => api.get<Plan>(`/api/v1/plans/${from}`),
enabled: !!from,
});
// Liste des clients pour le warning "X clients sans prénom" si on utilise
// {{client.contactFirstName}} dans un template.
const { data: clients = [] } = useQuery({
queryKey: queryKeys.clients.all(),
queryFn: () => api.get<Client[]>("/api/v1/clients"),
});
const [draft, setDraft] = useState<Draft>(() => ({
name: "",
description: "",
globalTone: "courtois",
steps: [],
}));
// Quand le plan source est chargé (mode duplication), on injecte ses
// étapes dans le draft. Une seule fois — l'utilisateur peut ensuite
// modifier librement.
const seededRef = useRef(false);
useEffect(() => {
if (!sourcePlan || seededRef.current) return;
seededRef.current = true;
setDraft({
name: `${sourcePlan.name} (copie)`,
description: sourcePlan.description,
globalTone: lastTone(sourcePlan.steps) ?? "courtois",
steps: sourcePlan.steps.map((s) => ({
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
});
}, [sourcePlan]);
const createMutation = useMutation({
mutationFn: (payload: { name: string; description: string; steps: DraftStep[] }) =>
api.post<Plan>("/api/v1/plans", {
name: payload.name,
description: payload.description,
steps: payload.steps.map((s, idx) => ({
order: idx,
offsetDays: s.offsetDays,
tone: s.tone,
subject: s.subject,
body: s.body,
requiresManualValidation: s.requiresManualValidation,
})),
}),
onSuccess: (created) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.plans.all() });
toast.success(`Plan « ${created.name} » créé.`);
void navigate({
to: "/plans/$slug",
params: { slug: created.slug ?? "" },
});
},
onError: () => {
toast.error("Création impossible. Vérifiez les champs obligatoires.");
},
});
const canProceed = stepCanProceed(step, draft);
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<Button asChild variant="ghost" size="sm">
<Link to="/plans">
<ArrowLeft size={14} /> Plans
</Link>
</Button>
<Stepper steps={[...STEPS]} currentIndex={step - 1} className="flex-1 max-w-md" />
<span className="hidden sm:block text-[12px] text-ink-3 tabular-nums">
{step}/4
</span>
</div>
<Card padding="lg" className="overflow-visible">
{step === 1 && <StepIdentity draft={draft} onChange={setDraft} />}
{step === 2 && <StepCadence draft={draft} onChange={setDraft} />}
{step === 3 && (
<StepMessages draft={draft} onChange={setDraft} clients={clients} />
)}
{step === 4 && <StepRecap draft={draft} />}
</Card>
<div className="flex items-center justify-between gap-3">
<Button
variant="ghost"
size="md"
onClick={() => {
if (step === 1) void navigate({ to: "/plans" });
else setStep((s) => (s - 1) as WizardStep);
}}
>
<ArrowLeft size={14} /> {step === 1 ? "Annuler" : "Retour"}
</Button>
{step < 4 ? (
<Button
size="md"
disabled={!canProceed}
onClick={() => setStep((s) => (s + 1) as WizardStep)}
>
Continuer <ArrowRight size={14} />
</Button>
) : (
<Button
size="md"
loading={createMutation.isPending}
disabled={!canProceed}
onClick={() =>
createMutation.mutate({
name: draft.name,
description: draft.description,
steps: draft.steps,
})
}
>
Créer le plan
</Button>
)}
</div>
</div>
);
}
// ============================================================================
// Step 1 — Identité (nom + tonalité globale)
// ============================================================================
function StepIdentity({ draft, onChange }: { draft: Draft; onChange: (d: Draft) => void }) {
return (
<div className="flex flex-col gap-7">
<div>
<Eyebrow>Étape 1 sur 4</Eyebrow>
<h2 className="font-display text-[22px] font-bold tracking-[-0.02em] text-ink mt-2">
Donnez une identité à votre plan
</h2>
<p className="mt-1.5 text-[14px] text-ink-3 leading-relaxed max-w-xl">
Choisissez un nom clair et une tonalité globale. La tonalité oriente la
cadence par défaut vous pourrez tout ajuster ensuite.
</p>
</div>
<Field
label="Nom du plan"
htmlFor="plan-name"
hint="Visible uniquement dans votre bibliothèque, jamais envoyé."
>
<Input
id="plan-name"
autoFocus
maxLength={80}
placeholder="Ex. Clients fidèles, Nouveaux clients, Sociétés à risque…"
value={draft.name}
onChange={(e) => onChange({ ...draft, name: e.target.value })}
/>
</Field>
<Field
label="Description"
htmlFor="plan-description"
hint="Optionnel. Un rappel à vous-même de quand utiliser ce plan."
>
<Textarea
id="plan-description"
rows={2}
maxLength={500}
placeholder="Ex. Pour les nouveaux clients sans historique de paiement."
value={draft.description}
onChange={(e) => onChange({ ...draft, description: e.target.value })}
/>
</Field>
<div>
<p className="font-sans text-[13px] font-semibold text-ink mb-2">
Tonalité globale
</p>
<p className="text-[12.5px] text-ink-3 mb-4 leading-snug">
On s'en sert pour pré-remplir la cadence à l'étape suivante. Vous
pourrez retoucher chaque étape individuellement.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{TONALITY_CARDS.map((card) => {
const selected = draft.globalTone === card.tone;
return (
<button
key={card.tone}
type="button"
onClick={() => onChange({ ...draft, globalTone: card.tone })}
className={cn(
"text-left rounded-card border p-4 transition-all",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
selected
? "border-rubis bg-rubis-glow/40 shadow-rubis"
: "border-line bg-white hover:border-ink-3",
)}
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-display text-[15px] font-semibold text-ink">
{card.title}
</p>
<p className="text-[12.5px] text-ink-3 mt-1 leading-snug">
{card.helper}
</p>
</div>
<span
aria-hidden="true"
className={cn(
"shrink-0 size-5 rounded-full border-2 flex items-center justify-center",
selected ? "bg-rubis border-rubis text-white" : "border-line bg-white",
)}
>
{selected && <Check size={11} strokeWidth={3} />}
</span>
</div>
<div className="mt-3 flex items-center gap-1.5 text-[11px] tabular-nums text-ink-3">
{card.defaultCadence.map((s, i) => (
<span key={i} className="inline-flex items-center gap-1">
<span
className={cn(
"size-2 rotate-45",
s.tone === "amical" && "bg-rubis-glow",
s.tone === "courtois" && "bg-cream-2",
s.tone === "ferme" && "bg-ink",
s.tone === "mise_en_demeure" && "bg-rubis-deep",
)}
/>
<span>
J{s.offsetDays >= 0 ? "+" : ""}
{s.offsetDays}
</span>
{i < card.defaultCadence.length - 1 && (
<span className="text-ink-3"></span>
)}
</span>
))}
</div>
</button>
);
})}
</div>
</div>
</div>
);
}
// ============================================================================
// Step 2 — Cadence (timeline avec ajout/retrait/édition)
// ============================================================================
function StepCadence({ draft, onChange }: { draft: Draft; onChange: (d: Draft) => void }) {
// Si pas encore d'étapes, on injecte la cadence par défaut de la tonalité.
useEffect(() => {
if (draft.steps.length > 0) return;
const defaults = TONALITY_CARDS.find((c) => c.tone === draft.globalTone)
?.defaultCadence;
if (!defaults) return;
onChange({
...draft,
steps: defaults.map((s) => emptyStep(s.tone, s.offsetDays)),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [draft.globalTone]);
const [selectedIdx, setSelectedIdx] = useState(0);
const selected = draft.steps[selectedIdx];
const updateStep = (idx: number, patch: Partial<DraftStep>) => {
onChange({
...draft,
steps: draft.steps.map((s, i) => (i === idx ? { ...s, ...patch } : s)),
});
};
const addStep = () => {
const last = draft.steps[draft.steps.length - 1];
const offsetDays = (last?.offsetDays ?? 0) + 7;
onChange({
...draft,
steps: [...draft.steps, emptyStep(draft.globalTone, offsetDays)],
});
setSelectedIdx(draft.steps.length);
};
const removeStep = (idx: number) => {
if (draft.steps.length <= 1) return;
onChange({ ...draft, steps: draft.steps.filter((_, i) => i !== idx) });
setSelectedIdx((cur) => Math.min(cur, draft.steps.length - 2));
};
return (
<div className="flex flex-col gap-7">
<div>
<Eyebrow>Étape 2 sur 4</Eyebrow>
<h2 className="font-display text-[22px] font-bold tracking-[-0.02em] text-ink mt-2">
Réglez la cadence
</h2>
<p className="mt-1.5 text-[14px] text-ink-3 leading-relaxed max-w-xl">
Chaque est un email programmé. Le chiffre indique le nombre de jours{" "}
<strong>après l'échéance</strong> (négatif pour rappel avant échéance).
</p>
</div>
<CadenceTimeline
steps={draft.steps}
selectedIndex={selectedIdx}
onSelectStep={setSelectedIdx}
onAddStep={addStep}
onRemoveStep={removeStep}
/>
{selected && (
<div className="rounded-card border border-line bg-white p-5">
<p className="text-[12px] uppercase tracking-[0.1em] text-ink-3 font-semibold mb-3">
Étape {selectedIdx + 1}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Field
label="Décalage (jours)"
htmlFor="step-offset"
hint="Négatif = avant échéance. 0 = jour J. 30 = J+30."
>
<Input
id="step-offset"
type="number"
min={-30}
max={180}
value={selected.offsetDays}
onChange={(e) =>
updateStep(selectedIdx, {
offsetDays: parseInt(e.target.value, 10) || 0,
})
}
/>
</Field>
<Field label="Tonalité" htmlFor="step-tone">
<select
id="step-tone"
className={cn(
"h-11 w-full rounded-default border border-line bg-white px-3",
"text-[14px] text-ink",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow focus-visible:border-rubis",
)}
value={selected.tone}
onChange={(e) => {
const tone = e.target.value as RelanceTone;
updateStep(selectedIdx, {
tone,
requiresManualValidation: tone === "mise_en_demeure",
});
}}
>
{(Object.keys(TONE_LABELS) as RelanceTone[]).map((t) => (
<option key={t} value={t}>
{TONE_LABELS[t]}
</option>
))}
</select>
</Field>
</div>
{selected.tone === "mise_en_demeure" && (
<p className="mt-3 inline-flex items-center gap-2 text-[12px] text-rubis-deep">
<AlertTriangle size={12} /> Cette étape exigera une validation
manuelle avant envoi (sécurité juridique).
</p>
)}
</div>
)}
</div>
);
}
// ============================================================================
// Step 3 — Messages (édition par étape, manuel ou IA)
// ============================================================================
function StepMessages({
draft,
onChange,
clients,
}: {
draft: Draft;
onChange: (d: Draft) => void;
clients: Client[];
}) {
const [selectedIdx, setSelectedIdx] = useState(0);
const [aiOpen, setAiOpen] = useState(false);
const bodyRef = useRef<HTMLTextAreaElement | null>(null);
const selected = draft.steps[selectedIdx];
const updateSelected = (patch: Partial<DraftStep>) => {
onChange({
...draft,
steps: draft.steps.map((s, i) => (i === selectedIdx ? { ...s, ...patch } : s)),
});
};
// Détection des variables utilisées qui exigent un champ client → warning
// si certains clients n'ont pas le champ rempli.
const warnings = useMemo(
() => computeClientWarnings(draft.steps, clients),
[draft.steps, clients],
);
const insertVariable = (token: string) => {
if (!bodyRef.current) {
updateSelected({ body: (selected?.body ?? "") + token });
return;
}
const ta = bodyRef.current;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const next = (selected?.body ?? "").slice(0, start) + token + (selected?.body ?? "").slice(end);
updateSelected({ body: next });
requestAnimationFrame(() => {
ta.focus();
ta.setSelectionRange(start + token.length, start + token.length);
});
};
return (
<div className="flex flex-col gap-7">
<div>
<Eyebrow>Étape 3 sur 4</Eyebrow>
<h2 className="font-display text-[22px] font-bold tracking-[-0.02em] text-ink mt-2">
Rédigez vos messages
</h2>
<p className="mt-1.5 text-[14px] text-ink-3 leading-relaxed max-w-xl">
Pour chaque étape, écrivez un sujet et un corps. Vous pouvez aussi
demander à l'IA de proposer un brouillon.
</p>
</div>
{/* Sélecteur d'étape */}
<div className="flex flex-wrap gap-2">
{draft.steps.map((s, idx) => {
const filled = s.subject.trim() !== "" && s.body.trim() !== "";
return (
<button
key={idx}
type="button"
onClick={() => setSelectedIdx(idx)}
className={cn(
"inline-flex items-center gap-2 rounded-default border px-3 py-1.5",
"text-[12.5px] font-medium transition-all",
idx === selectedIdx
? "border-rubis bg-rubis-glow text-rubis-deep"
: "border-line bg-white text-ink-2 hover:border-ink-3",
)}
>
<span className="tabular-nums">
J{s.offsetDays >= 0 ? "+" : ""}
{s.offsetDays}
</span>
<span className="text-ink-3">·</span>
<span>{TONE_LABELS[s.tone]}</span>
{filled ? (
<Check size={11} className="text-rubis-deep" />
) : (
<Pencil size={11} className="text-ink-3" />
)}
</button>
);
})}
</div>
{selected && (
<div className="grid grid-cols-1 lg:grid-cols-[1fr,1fr] gap-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-3 font-semibold">
Étape {selectedIdx + 1} · J{selected.offsetDays >= 0 ? "+" : ""}
{selected.offsetDays}
</p>
<Button size="sm" variant="ghost" onClick={() => setAiOpen(true)}>
<Sparkles size={13} className="text-rubis" /> Générer avec l'IA
</Button>
</div>
<Field label="Sujet de l'email" htmlFor="step-subject">
<Input
id="step-subject"
placeholder="Ex. Petit rappel — facture {{numero}}"
value={selected.subject}
onChange={(e) => updateSelected({ subject: e.target.value })}
maxLength={200}
/>
</Field>
<Field label="Corps de l'email" htmlFor="step-body">
<Textarea
id="step-body"
ref={bodyRef}
rows={12}
value={selected.body}
onChange={(e) => updateSelected({ body: e.target.value })}
placeholder="Bonjour {{client.contactFirstName}}, ..."
maxLength={5000}
/>
</Field>
<div>
<p className="text-[11.5px] text-ink-3 mb-2">
Cliquez pour insérer une variable au curseur :
</p>
<div className="flex flex-wrap gap-1.5">
{TEMPLATE_VARIABLES.map((v) => (
<button
key={v.token}
type="button"
onClick={() => insertVariable(v.token)}
className="inline-flex items-center rounded-full border border-line bg-cream-2/50 px-2.5 py-1 text-[11.5px] text-ink-2 hover:border-rubis hover:text-rubis-deep transition-colors"
>
{v.label}
</button>
))}
</div>
</div>
{warnings.length > 0 && (
<div className="rounded-default border border-rubis-glow bg-rubis-glow/30 p-3 flex gap-2 text-[12.5px] leading-snug text-ink-2">
<AlertTriangle size={14} className="text-rubis-deep shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-rubis-deep mb-0.5">
Variables sensibles détectées
</p>
<ul className="list-disc pl-4 space-y-0.5">
{warnings.map((w) => (
<li key={w.field}>
<strong>{w.label}</strong> est utilisé mais{" "}
<strong>{w.missingCount}</strong> client
{w.missingCount > 1 ? "s n'ont" : " n'a"} pas ce champ
rempli. La variable sera remplacée par une chaîne vide
pour ces clients.
</li>
))}
</ul>
</div>
</div>
)}
</div>
{/* Preview live */}
<div className="lg:sticky lg:top-6 lg:self-start">
<EmailPreview subject={selected.subject} body={selected.body} />
</div>
</div>
)}
{selected && (
<AiGenerateModal
open={aiOpen}
onOpenChange={setAiOpen}
tone={selected.tone}
offsetDays={selected.offsetDays}
planContext={`Plan "${draft.name}", étape ${selectedIdx + 1}/${draft.steps.length}.`}
onAccept={({ subject, body }) => updateSelected({ subject, body })}
/>
)}
</div>
);
}
// ============================================================================
// Step 4 — Récap (preview de chaque email + submit)
// ============================================================================
function StepRecap({ draft }: { draft: Draft }) {
return (
<div className="flex flex-col gap-7">
<div>
<Eyebrow>Étape 4 sur 4</Eyebrow>
<h2 className="font-display text-[22px] font-bold tracking-[-0.02em] text-ink mt-2">
Vérifiez et créez
</h2>
<p className="mt-1.5 text-[14px] text-ink-3 leading-relaxed max-w-xl">
Aperçu de chaque email tel qu'il sera reçu par <em>{PREVIEW_VARS.client.contactFirstName} {PREVIEW_VARS.client.contactLastName}</em> ({PREVIEW_VARS.client.name}). Vos vrais clients verront leurs propres données.
</p>
</div>
<div className="rounded-card border border-line bg-cream-2/30 p-4 flex flex-wrap items-center gap-x-6 gap-y-2 text-[13px]">
<div>
<span className="text-ink-3">Nom : </span>
<strong className="text-ink">{draft.name || "(sans nom)"}</strong>
</div>
<div>
<span className="text-ink-3">Étapes : </span>
<strong className="text-ink tabular-nums">{draft.steps.length}</strong>
</div>
<div>
<span className="text-ink-3">Cadence : </span>
<strong className="text-ink tabular-nums">
{draft.steps
.map((s) => `J${s.offsetDays >= 0 ? "+" : ""}${s.offsetDays}`)
.join(" · ")}
</strong>
</div>
</div>
<div className="flex flex-col gap-5">
{draft.steps.map((s, idx) => (
<div key={idx}>
<div className="flex items-center gap-3 mb-2">
<span className="size-2 rotate-45 bg-rubis" aria-hidden="true" />
<p className="font-display text-[14px] font-semibold text-ink">
Étape {idx + 1} · J{s.offsetDays >= 0 ? "+" : ""}
{s.offsetDays} · {TONE_LABELS[s.tone]}
</p>
{s.requiresManualValidation && (
<span className="inline-flex items-center gap-1 rounded-full bg-rubis-glow px-2 py-0.5 text-[10.5px] font-semibold text-rubis-deep">
<AlertTriangle size={10} /> Validation manuelle
</span>
)}
</div>
<EmailPreview subject={s.subject} body={s.body} />
</div>
))}
</div>
</div>
);
}
// ============================================================================
// Helpers
// ============================================================================
function lastTone(steps: PlanStep[]): RelanceTone | null {
return steps[steps.length - 1]?.tone ?? null;
}
function stepCanProceed(step: WizardStep, draft: Draft): boolean {
switch (step) {
case 1:
return draft.name.trim().length > 0;
case 2:
return draft.steps.length >= 1 && draft.steps.length <= 8;
case 3:
return draft.steps.every(
(s) => s.subject.trim() !== "" && s.body.trim() !== "",
);
case 4:
return (
draft.name.trim().length > 0 &&
draft.steps.length >= 1 &&
draft.steps.every((s) => s.subject.trim() !== "" && s.body.trim() !== "")
);
}
}
/**
* Détecte les variables sensibles utilisées dans les templates et compte
* combien de clients existants n'ont pas le champ correspondant rempli.
*/
function computeClientWarnings(
steps: DraftStep[],
clients: Client[],
): Array<{ field: string; label: string; missingCount: number }> {
const warnings: Array<{ field: string; label: string; missingCount: number }> = [];
const allText = steps.map((s) => `${s.subject}\n${s.body}`).join("\n");
for (const v of TEMPLATE_VARIABLES) {
if (!v.requiresClientField) continue;
if (!allText.includes(v.token)) continue;
const missingCount = clients.filter((c) => {
const value = c[v.requiresClientField as keyof Client];
return !value || (typeof value === "string" && value.trim() === "");
}).length;
if (missingCount > 0) {
warnings.push({
field: v.requiresClientField,
label: v.label,
missingCount,
});
}
}
return warnings;
}

View File

@ -3,6 +3,8 @@ import { z } from "zod";
export const createClientSchema = z.object({
name: z.string().min(1, "Le nom du client est requis").max(120),
email: z.string().email("Email invalide").min(1, "Email requis"),
contactFirstName: z.string().min(1).max(80).nullable().optional(),
contactLastName: z.string().min(1).max(80).nullable().optional(),
phone: z.string().max(40).nullable().optional(),
address: z.string().max(500).nullable().optional(),
siret: z

View File

@ -8,6 +8,11 @@ export type Client = {
/** Email de contact requis : sans email, Rubis ne peut pas envoyer
* les relances automatiques. C'est le pivot du produit. */
email: string;
/** Prénom du contact dédié (optionnel). Utilisé comme variable
* `{{client.contactFirstName}}` dans les templates de relance custom. */
contactFirstName: string | null;
/** Nom du contact dédié (optionnel). */
contactLastName: string | null;
phone: string | null;
/** Adresse postale (LME : requise pour mise en demeure formelle). */
address: string | null;