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:
parent
8742cabebf
commit
9e531e32a9
50
apps/api/app/controllers/ai_controller.ts
Normal file
50
apps/api/app/controllers/ai_controller.ts
Normal 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' }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
142
apps/api/app/services/ai_relance_generator.ts
Normal file
142
apps/api/app/services/ai_relance_generator.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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),
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
162
apps/web/src/components/plans/wizard/AiGenerateModal.tsx
Normal file
162
apps/web/src/components/plans/wizard/AiGenerateModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
apps/web/src/components/plans/wizard/CadenceTimeline.tsx
Normal file
154
apps/web/src/components/plans/wizard/CadenceTimeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/web/src/components/plans/wizard/EmailPreview.tsx
Normal file
71
apps/web/src/components/plans/wizard/EmailPreview.tsx
Normal 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}{" "}
|
||||
<{fromAddress}>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
117
apps/web/src/mocks/handlers/ai.ts
Normal file
117
apps/web/src/mocks/handlers/ai.ts
Normal 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 });
|
||||
}),
|
||||
];
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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,
|
||||
|
||||
812
apps/web/src/routes/_app/plans_.nouveau.tsx
Normal file
812
apps/web/src/routes/_app/plans_.nouveau.tsx
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user