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,
|
organizationId,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
|
contactFirstName: payload.contactFirstName ?? null,
|
||||||
|
contactLastName: payload.contactLastName ?? null,
|
||||||
phone: payload.phone ?? null,
|
phone: payload.phone ?? null,
|
||||||
address: payload.address ?? null,
|
address: payload.address ?? null,
|
||||||
siret: payload.siret ?? null,
|
siret: payload.siret ?? null,
|
||||||
|
|||||||
@ -1,11 +1,44 @@
|
|||||||
import Plan from '#models/plan'
|
import Plan from '#models/plan'
|
||||||
import PlanStep from '#models/plan_step'
|
import PlanStep from '#models/plan_step'
|
||||||
import PlanTransformer from '#transformers/plan_transformer'
|
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 type { HttpContext } from '@adonisjs/core/http'
|
||||||
import { Exception } from '@adonisjs/core/exceptions'
|
import { Exception } from '@adonisjs/core/exceptions'
|
||||||
import db from '@adonisjs/lucid/services/db'
|
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')"
|
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
|
||||||
|
|
||||||
function requireOrgId(auth: HttpContext['auth']): string {
|
function requireOrgId(auth: HttpContext['auth']): string {
|
||||||
@ -146,4 +179,50 @@ export default class PlansController {
|
|||||||
await plan.load('steps')
|
await plan.load('steps')
|
||||||
return response.json({ data: serializePlan(plan) })
|
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()
|
const invoice = await Invoice.query()
|
||||||
.where('id', task.invoiceId)
|
.where('id', task.invoiceId)
|
||||||
.preload('client')
|
.preload('client')
|
||||||
|
.preload('organization')
|
||||||
.first()
|
.first()
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
task.status = 'cancelled'
|
task.status = 'cancelled'
|
||||||
@ -78,7 +79,13 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Envoi normal
|
// 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) => {
|
await db.transaction(async (trx) => {
|
||||||
task.useTransaction(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 mail from '@adonisjs/mail/services/main'
|
||||||
import env from '#start/env'
|
import env from '#start/env'
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
|
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
|
||||||
import type Invoice from '#models/invoice'
|
import type Invoice from '#models/invoice'
|
||||||
import type Client from '#models/client'
|
import type Client from '#models/client'
|
||||||
import type PlanStep from '#models/plan_step'
|
import type PlanStep from '#models/plan_step'
|
||||||
import type User from '#models/user'
|
import type User from '#models/user'
|
||||||
|
import type Organization from '#models/organization'
|
||||||
|
|
||||||
type RelancePayload = {
|
type RelancePayload = {
|
||||||
invoice: Invoice
|
invoice: Invoice
|
||||||
client: Client
|
client: Client
|
||||||
step: PlanStep
|
step: PlanStep
|
||||||
user: User | null
|
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.
|
* Envoie un email de relance à un client à partir d'un step.
|
||||||
* Le subject/body du step contiennent des placeholders Mustache-like
|
* Le subject/body du step contiennent des placeholders Mustache-like
|
||||||
* (`{{client.name}}`, `{{numero}}`, `{{amount}}`, `{{dueDate}}`,
|
* qu'on interpole avant l'envoi (cf. `buildRelanceVars`).
|
||||||
* `{{signature}}`) qu'on interpole avant l'envoi.
|
|
||||||
*
|
*
|
||||||
* Le mailer effectif est piloté par MAIL_DRIVER (`smtp` Mailpit en dev,
|
* Le mailer effectif est piloté par MAIL_DRIVER (`smtp` Mailpit en dev,
|
||||||
* `resend` en prod).
|
* `resend` en prod).
|
||||||
*/
|
*/
|
||||||
export async function sendRelanceEmail({ invoice, client, step, user }: RelancePayload) {
|
export async function sendRelanceEmail({
|
||||||
const vars = {
|
invoice,
|
||||||
client: { name: client.name, email: client.email },
|
client,
|
||||||
numero: invoice.numero,
|
step,
|
||||||
amount: formatAmountFr(invoice.amountTtcCents),
|
user,
|
||||||
dueDate: formatDateFr(invoice.dueDate.toJSDate()),
|
organization,
|
||||||
issueDate: formatDateFr(invoice.issueDate.toJSDate()),
|
}: RelancePayload) {
|
||||||
signature: user?.signature ?? user?.fullName ?? '',
|
const vars = buildRelanceVars({ invoice, client, user, organization })
|
||||||
}
|
|
||||||
|
|
||||||
const subject = renderTemplate(step.subject, vars)
|
const subject = renderTemplate(step.subject, vars)
|
||||||
const body = renderTemplate(step.body, vars)
|
const body = renderTemplate(step.body, vars)
|
||||||
|
|||||||
@ -9,6 +9,8 @@ export default class ClientTransformer extends BaseTransformer<Client> {
|
|||||||
organizationId: c.organizationId,
|
organizationId: c.organizationId,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
email: c.email,
|
email: c.email,
|
||||||
|
contactFirstName: c.contactFirstName,
|
||||||
|
contactLastName: c.contactLastName,
|
||||||
phone: c.phone,
|
phone: c.phone,
|
||||||
address: c.address,
|
address: c.address,
|
||||||
siret: c.siret,
|
siret: c.siret,
|
||||||
|
|||||||
@ -7,6 +7,9 @@ const siret = () => vine.string().regex(/^\d{14}$/)
|
|||||||
const phone = () => vine.string().maxLength(40)
|
const phone = () => vine.string().maxLength(40)
|
||||||
const address = () => vine.string().maxLength(500)
|
const address = () => vine.string().maxLength(500)
|
||||||
const notes = () => vine.string().maxLength(2000)
|
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
|
* 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({
|
export const createClientValidator = vine.create({
|
||||||
name: name(),
|
name: name(),
|
||||||
email: email(),
|
email: email(),
|
||||||
|
contactFirstName: contactName().nullable().optional(),
|
||||||
|
contactLastName: contactName().nullable().optional(),
|
||||||
phone: phone().nullable().optional(),
|
phone: phone().nullable().optional(),
|
||||||
address: address().nullable().optional(),
|
address: address().nullable().optional(),
|
||||||
siret: siret().nullable().optional(),
|
siret: siret().nullable().optional(),
|
||||||
@ -27,6 +32,8 @@ export const createClientValidator = vine.create({
|
|||||||
export const updateClientValidator = vine.create({
|
export const updateClientValidator = vine.create({
|
||||||
name: name().optional(),
|
name: name().optional(),
|
||||||
email: email().optional(),
|
email: email().optional(),
|
||||||
|
contactFirstName: contactName().nullable().optional(),
|
||||||
|
contactLastName: contactName().nullable().optional(),
|
||||||
phone: phone().nullable().optional(),
|
phone: phone().nullable().optional(),
|
||||||
address: address().nullable().optional(),
|
address: address().nullable().optional(),
|
||||||
siret: siret().nullable().optional(),
|
siret: siret().nullable().optional(),
|
||||||
|
|||||||
@ -24,3 +24,13 @@ export const updatePlanValidator = vine.create({
|
|||||||
description: vine.string().maxLength(500).optional(),
|
description: vine.string().maxLength(500).optional(),
|
||||||
steps: vine.array(planStep).minLength(1).maxLength(10).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 {
|
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
|
$columns = ClientSchema.$columns
|
||||||
@column()
|
@column()
|
||||||
declare address: string | null
|
declare address: string | null
|
||||||
|
@column()
|
||||||
|
declare contactFirstName: string | null
|
||||||
|
@column()
|
||||||
|
declare contactLastName: string | null
|
||||||
@column.dateTime({ autoCreate: true })
|
@column.dateTime({ autoCreate: true })
|
||||||
declare createdAt: DateTime
|
declare createdAt: DateTime
|
||||||
@column()
|
@column()
|
||||||
|
|||||||
@ -88,13 +88,27 @@ router
|
|||||||
.as('clients')
|
.as('clients')
|
||||||
.use(middleware.auth())
|
.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 :
|
* 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
|
router
|
||||||
.group(() => {
|
.group(() => {
|
||||||
router.get('', [controllers.Plans, 'index']).as('index')
|
router.get('', [controllers.Plans, 'index']).as('index')
|
||||||
|
router.post('', [controllers.Plans, 'store']).as('store')
|
||||||
router.get(':slug', [controllers.Plans, 'show']).as('show')
|
router.get(':slug', [controllers.Plans, 'show']).as('show')
|
||||||
router.patch(':slug', [controllers.Plans, 'update']).as('update')
|
router.patch(':slug', [controllers.Plans, 'update']).as('update')
|
||||||
})
|
})
|
||||||
|
|||||||
@ -42,6 +42,8 @@ import { Textarea } from "@/components/ui/Textarea";
|
|||||||
type FormValues = {
|
type FormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
contactFirstName: string;
|
||||||
|
contactLastName: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
address: string;
|
address: string;
|
||||||
siret: string;
|
siret: string;
|
||||||
@ -88,6 +90,10 @@ export function ClientCreateDialog({
|
|||||||
api.post<Client>("/api/v1/clients", {
|
api.post<Client>("/api/v1/clients", {
|
||||||
name: input.name.trim(),
|
name: input.name.trim(),
|
||||||
email: input.email.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(),
|
phone: input.phone.trim() === "" ? null : input.phone.trim(),
|
||||||
address: input.address.trim() === "" ? null : input.address.trim(),
|
address: input.address.trim() === "" ? null : input.address.trim(),
|
||||||
siret: input.siret.trim() === "" ? null : input.siret.trim(),
|
siret: input.siret.trim() === "" ? null : input.siret.trim(),
|
||||||
@ -112,6 +118,8 @@ export function ClientCreateDialog({
|
|||||||
const initialValues: FormValues = {
|
const initialValues: FormValues = {
|
||||||
name: defaultName,
|
name: defaultName,
|
||||||
email: "",
|
email: "",
|
||||||
|
contactFirstName: "",
|
||||||
|
contactLastName: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
address: "",
|
address: "",
|
||||||
siret: "",
|
siret: "",
|
||||||
@ -202,6 +210,39 @@ export function ClientCreateDialog({
|
|||||||
)}
|
)}
|
||||||
</form.Field>
|
</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">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<form.Field name="phone">
|
<form.Field name="phone">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
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 type { Plan } from "@rubis/shared";
|
||||||
import { Card } from "@/components/ui/Card";
|
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>
|
<span className="italic">Aucune facture active</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{plan.slug && (
|
<div className="flex items-center gap-3">
|
||||||
<Link
|
{plan.slug && (
|
||||||
to="/plans/$slug"
|
<Link
|
||||||
params={{ slug: plan.slug }}
|
to="/plans/nouveau"
|
||||||
className="inline-flex items-center gap-1 text-[12px] font-medium text-rubis hover:underline underline-offset-4"
|
search={{ from: plan.slug }}
|
||||||
>
|
className="inline-flex items-center gap-1 text-[11.5px] font-medium text-ink-3 hover:text-ink-2"
|
||||||
Modifier <ArrowRight size={12} aria-hidden="true" />
|
title="Créer un plan en partant de celui-ci"
|
||||||
</Link>
|
>
|
||||||
)}
|
<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>
|
</div>
|
||||||
</Card>
|
</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() {
|
export function CreatePlanCard() {
|
||||||
return (
|
return (
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to="/plans/nouveau"
|
||||||
disabled
|
|
||||||
className={cn(
|
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]",
|
"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",
|
"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" />
|
<Plus size={26} strokeWidth={1.5} aria-hidden="true" />
|
||||||
<span className="font-display text-[14.5px] font-semibold text-ink">
|
<span className="font-display text-[14.5px] font-semibold text-ink">
|
||||||
Créer un plan
|
Créer un plan
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11.5px] italic text-ink-3 max-w-[180px]">
|
<span className="text-[11.5px] italic text-ink-3 max-w-[200px] text-center leading-snug">
|
||||||
Bientôt — pour l'instant, dupliquez un des plans existants.
|
Wizard guidé en 4 étapes, avec assistance IA pour rédiger les emails.
|
||||||
</span>
|
</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";
|
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.
|
* Helpers de présentation des plans de relance.
|
||||||
* Garde la conversion tonalité → label public au même endroit.
|
* Garde la conversion tonalité → label public au même endroit.
|
||||||
@ -47,12 +87,41 @@ export type TemplateVariable = {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Aperçu utilisé dans l'éditeur (placeholder réaliste). */
|
/** Aperçu utilisé dans l'éditeur (placeholder réaliste). */
|
||||||
preview: string;
|
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[] = [
|
export const TEMPLATE_VARIABLES: TemplateVariable[] = [
|
||||||
{ token: "{{client.name}}", label: "Nom du client", preview: "Boulangerie Martin SARL" },
|
// Client
|
||||||
{ token: "{{numero}}", label: "Numéro", preview: "F-2026-0042" },
|
{ token: "{{client.name}}", label: "Raison sociale", preview: "Boulangerie Martin SARL" },
|
||||||
{ token: "{{amount}}", label: "Montant", preview: "1 240,00 €" },
|
{
|
||||||
{ token: "{{dueDate}}", label: "Échéance", preview: "15 mai 2026" },
|
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" },
|
{ 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[] {
|
listPlansForOrg(orgId: string): Plan[] {
|
||||||
return load().plans.filter((p) => p.organizationId === orgId);
|
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(
|
updatePlan(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
id: 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({
|
const updateClientSchema = z.object({
|
||||||
name: z.string().min(1).max(120).optional(),
|
name: z.string().min(1).max(120).optional(),
|
||||||
email: z.string().email("Email invalide").min(1).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(),
|
phone: z.string().max(40).nullable().optional(),
|
||||||
address: z.string().max(500).nullable().optional(),
|
address: z.string().max(500).nullable().optional(),
|
||||||
siret: z
|
siret: z
|
||||||
@ -90,6 +92,8 @@ const createClientSchema = z.object({
|
|||||||
// automatiques. C'est le pivot du produit, on n'accepte pas de fiche
|
// automatiques. C'est le pivot du produit, on n'accepte pas de fiche
|
||||||
// client sans canal de communication actif.
|
// client sans canal de communication actif.
|
||||||
email: z.string().min(1, "Email requis").email("Format d'email invalide"),
|
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),
|
phone: z.string().max(40).nullable().optional().default(null),
|
||||||
address: z.string().max(500).nullable().optional().default(null),
|
address: z.string().max(500).nullable().optional().default(null),
|
||||||
siret: z
|
siret: z
|
||||||
@ -192,6 +196,8 @@ export const clientHandlers = [
|
|||||||
const created = mockDb.createClient(orgId, {
|
const created = mockDb.createClient(orgId, {
|
||||||
name: parsed.data.name,
|
name: parsed.data.name,
|
||||||
email: parsed.data.email,
|
email: parsed.data.email,
|
||||||
|
contactFirstName: parsed.data.contactFirstName,
|
||||||
|
contactLastName: parsed.data.contactLastName,
|
||||||
phone: parsed.data.phone,
|
phone: parsed.data.phone,
|
||||||
address: parsed.data.address,
|
address: parsed.data.address,
|
||||||
siret: parsed.data.siret,
|
siret: parsed.data.siret,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { dashboardHandlers } from "./dashboard";
|
|||||||
import { invoiceHandlers } from "./invoices";
|
import { invoiceHandlers } from "./invoices";
|
||||||
import { planHandlers } from "./plans";
|
import { planHandlers } from "./plans";
|
||||||
import { clientHandlers } from "./clients";
|
import { clientHandlers } from "./clients";
|
||||||
|
import { aiHandlers } from "./ai";
|
||||||
|
|
||||||
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
|
/** Tous les handlers MSW concaténés. Les nouveaux domaines viennent ici. */
|
||||||
export const handlers = [
|
export const handlers = [
|
||||||
@ -13,4 +14,5 @@ export const handlers = [
|
|||||||
...invoiceHandlers,
|
...invoiceHandlers,
|
||||||
...planHandlers,
|
...planHandlers,
|
||||||
...clientHandlers,
|
...clientHandlers,
|
||||||
|
...aiHandlers,
|
||||||
];
|
];
|
||||||
|
|||||||
@ -43,6 +43,37 @@ const updatePlanSchema = z.object({
|
|||||||
steps: z.array(updatePlanStepSchema).min(1).max(10).optional(),
|
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 = [
|
export const planHandlers = [
|
||||||
// GET /api/v1/plans — liste enrichie avec compteurs d'usage
|
// GET /api/v1/plans — liste enrichie avec compteurs d'usage
|
||||||
http.get(`${apiBase}/plans`, ({ request }) => {
|
http.get(`${apiBase}/plans`, ({ request }) => {
|
||||||
@ -79,6 +110,41 @@ export const planHandlers = [
|
|||||||
return HttpResponse.json({ data: { ...plan, usageCount } });
|
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
|
// PATCH /api/v1/plans/:slug — sauvegarde nom/description/étapes
|
||||||
http.patch(`${apiBase}/plans/:slug`, async ({ request, params }) => {
|
http.patch(`${apiBase}/plans/:slug`, async ({ request, params }) => {
|
||||||
const orgId = authedOrgId(request.headers.get("authorization"));
|
const orgId = authedOrgId(request.headers.get("authorization"));
|
||||||
|
|||||||
@ -24,6 +24,8 @@ export const SEED_CLIENTS: Client[] = [
|
|||||||
organizationId: ORG,
|
organizationId: ORG,
|
||||||
name: "Boulangerie Martin SARL",
|
name: "Boulangerie Martin SARL",
|
||||||
email: "compta@boulangerie-martin.fr",
|
email: "compta@boulangerie-martin.fr",
|
||||||
|
contactFirstName: "Marie",
|
||||||
|
contactLastName: "Martin",
|
||||||
phone: "+33 1 23 45 67 89",
|
phone: "+33 1 23 45 67 89",
|
||||||
address: "12 rue du Pain, 75011 Paris",
|
address: "12 rue du Pain, 75011 Paris",
|
||||||
siret: "82345678900012",
|
siret: "82345678900012",
|
||||||
@ -36,6 +38,8 @@ export const SEED_CLIENTS: Client[] = [
|
|||||||
organizationId: ORG,
|
organizationId: ORG,
|
||||||
name: "Atelier Durand",
|
name: "Atelier Durand",
|
||||||
email: "contact@atelier-durand.fr",
|
email: "contact@atelier-durand.fr",
|
||||||
|
contactFirstName: null,
|
||||||
|
contactLastName: null,
|
||||||
phone: null,
|
phone: null,
|
||||||
address: null,
|
address: null,
|
||||||
siret: null,
|
siret: null,
|
||||||
@ -48,6 +52,8 @@ export const SEED_CLIENTS: Client[] = [
|
|||||||
organizationId: ORG,
|
organizationId: ORG,
|
||||||
name: "Cabinet Rousseau",
|
name: "Cabinet Rousseau",
|
||||||
email: "facturation@cabinet-rousseau.fr",
|
email: "facturation@cabinet-rousseau.fr",
|
||||||
|
contactFirstName: "Julien",
|
||||||
|
contactLastName: "Rousseau",
|
||||||
phone: "+33 4 56 78 90 12",
|
phone: "+33 4 56 78 90 12",
|
||||||
address: "8 place de la République, 69002 Lyon",
|
address: "8 place de la République, 69002 Lyon",
|
||||||
siret: "53412987600028",
|
siret: "53412987600028",
|
||||||
@ -60,6 +66,8 @@ export const SEED_CLIENTS: Client[] = [
|
|||||||
organizationId: ORG,
|
organizationId: ORG,
|
||||||
name: "Garage Lemoine",
|
name: "Garage Lemoine",
|
||||||
email: "admin@garage-lemoine.fr",
|
email: "admin@garage-lemoine.fr",
|
||||||
|
contactFirstName: null,
|
||||||
|
contactLastName: null,
|
||||||
phone: null,
|
phone: null,
|
||||||
address: null,
|
address: null,
|
||||||
siret: null,
|
siret: null,
|
||||||
@ -72,6 +80,8 @@ export const SEED_CLIENTS: Client[] = [
|
|||||||
organizationId: ORG,
|
organizationId: ORG,
|
||||||
name: "Studio Lefèvre",
|
name: "Studio Lefèvre",
|
||||||
email: "hello@studio-lefevre.com",
|
email: "hello@studio-lefevre.com",
|
||||||
|
contactFirstName: "Camille",
|
||||||
|
contactLastName: "Lefèvre",
|
||||||
phone: null,
|
phone: null,
|
||||||
address: null,
|
address: null,
|
||||||
siret: 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({
|
export const createClientSchema = z.object({
|
||||||
name: z.string().min(1, "Le nom du client est requis").max(120),
|
name: z.string().min(1, "Le nom du client est requis").max(120),
|
||||||
email: z.string().email("Email invalide").min(1, "Email requis"),
|
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(),
|
phone: z.string().max(40).nullable().optional(),
|
||||||
address: z.string().max(500).nullable().optional(),
|
address: z.string().max(500).nullable().optional(),
|
||||||
siret: z
|
siret: z
|
||||||
|
|||||||
@ -8,6 +8,11 @@ export type Client = {
|
|||||||
/** Email de contact — requis : sans email, Rubis ne peut pas envoyer
|
/** Email de contact — requis : sans email, Rubis ne peut pas envoyer
|
||||||
* les relances automatiques. C'est le pivot du produit. */
|
* les relances automatiques. C'est le pivot du produit. */
|
||||||
email: string;
|
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;
|
phone: string | null;
|
||||||
/** Adresse postale (LME : requise pour mise en demeure formelle). */
|
/** Adresse postale (LME : requise pour mise en demeure formelle). */
|
||||||
address: string | null;
|
address: string | null;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user