diff --git a/apps/api/app/controllers/ai_controller.ts b/apps/api/app/controllers/ai_controller.ts new file mode 100644 index 0000000..4096164 --- /dev/null +++ b/apps/api/app/controllers/ai_controller.ts @@ -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' } + ) + } + } +} diff --git a/apps/api/app/controllers/clients_controller.ts b/apps/api/app/controllers/clients_controller.ts index b1dc7e5..78b7759 100644 --- a/apps/api/app/controllers/clients_controller.ts +++ b/apps/api/app/controllers/clients_controller.ts @@ -137,6 +137,8 @@ export default class ClientsController { organizationId, name: payload.name, email: payload.email, + contactFirstName: payload.contactFirstName ?? null, + contactLastName: payload.contactLastName ?? null, phone: payload.phone ?? null, address: payload.address ?? null, siret: payload.siret ?? null, diff --git a/apps/api/app/controllers/plans_controller.ts b/apps/api/app/controllers/plans_controller.ts index 03d08ea..1dc582b 100644 --- a/apps/api/app/controllers/plans_controller.ts +++ b/apps/api/app/controllers/plans_controller.ts @@ -1,11 +1,44 @@ import Plan from '#models/plan' import PlanStep from '#models/plan_step' import PlanTransformer from '#transformers/plan_transformer' -import { updatePlanValidator } from '#validators/plan' +import { createPlanValidator, updatePlanValidator } from '#validators/plan' import type { HttpContext } from '@adonisjs/core/http' import { Exception } from '@adonisjs/core/exceptions' import db from '@adonisjs/lucid/services/db' +/** + * Slug à partir d'un nom de plan : minuscules, ASCII safe, tirets. + * On garantit l'unicité par org en suffixant un compteur si collision. + */ +function slugify(input: string): string { + return input + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) || 'plan' +} + +// Slugs réservés côté front (routes statiques type /plans/nouveau). +// Si l'utilisateur nomme son plan "nouveau", on suffixe d'office. +const RESERVED_SLUGS = new Set(['nouveau', 'new', 'create']) + +async function nextAvailableSlug(organizationId: string, base: string): Promise { + const start = RESERVED_SLUGS.has(base) ? `${base}-1` : base + const existing = await Plan.query() + .where('organization_id', organizationId) + .whereILike('slug', `${base}%`) + .select('slug') + const taken = new Set(existing.map((p) => p.slug)) + if (!taken.has(start) && !RESERVED_SLUGS.has(start)) return start + for (let i = 2; i < 100; i++) { + const candidate = `${base}-${i}` + if (!taken.has(candidate) && !RESERVED_SLUGS.has(candidate)) return candidate + } + return `${base}-${Date.now()}` +} + const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')" function requireOrgId(auth: HttpContext['auth']): string { @@ -146,4 +179,50 @@ export default class PlansController { await plan.load('steps') return response.json({ data: serializePlan(plan) }) } + + /** + * POST /plans — création d'un plan custom. + * + * Slug auto-généré depuis `name`, suffixé en cas de collision dans l'org. + * Le plan custom n'est pas marqué `isDefault` — il peut être supprimé + * (V2) sans toucher à la bibliothèque. + */ + async store({ auth, request, response }: HttpContext) { + const organizationId = requireOrgId(auth) + const payload = await request.validateUsing(createPlanValidator) + + const baseSlug = slugify(payload.name) + const slug = await nextAvailableSlug(organizationId, baseSlug) + + const plan = await db.transaction(async (trx) => { + const created = await Plan.create( + { + organizationId, + slug, + name: payload.name, + description: payload.description ?? '', + isDefault: false, + }, + { client: trx } + ) + + await PlanStep.createMany( + payload.steps.map((s) => ({ + planId: created.id, + order: s.order, + offsetDays: s.offsetDays, + tone: s.tone, + subject: s.subject, + body: s.body, + requiresManualValidation: s.requiresManualValidation, + })), + { client: trx } + ) + + return created + }) + + await plan.load('steps') + return response.status(201).json({ data: serializePlan(plan) }) + } } diff --git a/apps/api/app/jobs/send_relance_job.ts b/apps/api/app/jobs/send_relance_job.ts index af4d861..5261d6c 100644 --- a/apps/api/app/jobs/send_relance_job.ts +++ b/apps/api/app/jobs/send_relance_job.ts @@ -36,6 +36,7 @@ export async function sendRelanceJob(jobData: { taskId: string }) { const invoice = await Invoice.query() .where('id', task.invoiceId) .preload('client') + .preload('organization') .first() if (!invoice) { task.status = 'cancelled' @@ -78,7 +79,13 @@ export async function sendRelanceJob(jobData: { taskId: string }) { } // Envoi normal - await sendRelanceEmail({ invoice, client: invoice.client, step, user }) + await sendRelanceEmail({ + invoice, + client: invoice.client, + step, + user, + organization: invoice.organization, + }) await db.transaction(async (trx) => { task.useTransaction(trx) diff --git a/apps/api/app/services/ai_relance_generator.ts b/apps/api/app/services/ai_relance_generator.ts new file mode 100644 index 0000000..fba2fea --- /dev/null +++ b/apps/api/app/services/ai_relance_generator.ts @@ -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 = { + 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 { + 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), + } +} diff --git a/apps/api/app/services/mail_dispatcher.ts b/apps/api/app/services/mail_dispatcher.ts index 519a58a..c6552a9 100644 --- a/apps/api/app/services/mail_dispatcher.ts +++ b/apps/api/app/services/mail_dispatcher.ts @@ -1,36 +1,86 @@ import mail from '@adonisjs/mail/services/main' import env from '#start/env' +import { DateTime } from 'luxon' import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template' import type Invoice from '#models/invoice' import type Client from '#models/client' import type PlanStep from '#models/plan_step' import type User from '#models/user' +import type Organization from '#models/organization' type RelancePayload = { invoice: Invoice client: Client step: PlanStep user: User | null + organization?: Organization | null +} + +/** + * Construit l'objet `vars` interpolé dans subject/body. Exposé pour + * permettre la preview côté contrôleur (wizard de création de plan) + * avec les mêmes variables que ce qui sera réellement envoyé. + * + * Variables disponibles : + * - {{client.name}}, {{client.email}} + * - {{client.contactFirstName}}, {{client.contactLastName}} (peuvent être vides) + * - {{numero}}, {{amount}}, {{dueDate}}, {{issueDate}} + * - {{daysLate}} (jours de retard depuis dueDate, négatif = avant échéance) + * - {{user.fullName}}, {{user.companyName}} + * - {{signature}} + */ +export function buildRelanceVars({ + invoice, + client, + user, + organization, +}: { + invoice: Pick + client: Pick + user: Pick | null + organization?: Pick | null +}) { + const dueDate = invoice.dueDate.toJSDate() + // Jours de retard arrondis à l'entier (UTC pour cohérence). + const daysLate = Math.floor( + DateTime.utc().startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days + ) + return { + client: { + name: client.name, + email: client.email, + contactFirstName: client.contactFirstName ?? '', + contactLastName: client.contactLastName ?? '', + }, + user: { + fullName: user?.fullName ?? '', + companyName: organization?.name ?? '', + }, + numero: invoice.numero, + amount: formatAmountFr(invoice.amountTtcCents), + dueDate: formatDateFr(dueDate), + issueDate: formatDateFr(invoice.issueDate.toJSDate()), + daysLate: String(daysLate), + signature: user?.signature ?? user?.fullName ?? '', + } } /** * Envoie un email de relance à un client à partir d'un step. * Le subject/body du step contiennent des placeholders Mustache-like - * (`{{client.name}}`, `{{numero}}`, `{{amount}}`, `{{dueDate}}`, - * `{{signature}}`) qu'on interpole avant l'envoi. + * qu'on interpole avant l'envoi (cf. `buildRelanceVars`). * * Le mailer effectif est piloté par MAIL_DRIVER (`smtp` Mailpit en dev, * `resend` en prod). */ -export async function sendRelanceEmail({ invoice, client, step, user }: RelancePayload) { - const vars = { - client: { name: client.name, email: client.email }, - numero: invoice.numero, - amount: formatAmountFr(invoice.amountTtcCents), - dueDate: formatDateFr(invoice.dueDate.toJSDate()), - issueDate: formatDateFr(invoice.issueDate.toJSDate()), - signature: user?.signature ?? user?.fullName ?? '', - } +export async function sendRelanceEmail({ + invoice, + client, + step, + user, + organization, +}: RelancePayload) { + const vars = buildRelanceVars({ invoice, client, user, organization }) const subject = renderTemplate(step.subject, vars) const body = renderTemplate(step.body, vars) diff --git a/apps/api/app/transformers/client_transformer.ts b/apps/api/app/transformers/client_transformer.ts index e573ab8..e08d9d2 100644 --- a/apps/api/app/transformers/client_transformer.ts +++ b/apps/api/app/transformers/client_transformer.ts @@ -9,6 +9,8 @@ export default class ClientTransformer extends BaseTransformer { organizationId: c.organizationId, name: c.name, email: c.email, + contactFirstName: c.contactFirstName, + contactLastName: c.contactLastName, phone: c.phone, address: c.address, siret: c.siret, diff --git a/apps/api/app/validators/client.ts b/apps/api/app/validators/client.ts index d294536..aab37a6 100644 --- a/apps/api/app/validators/client.ts +++ b/apps/api/app/validators/client.ts @@ -7,6 +7,9 @@ const siret = () => vine.string().regex(/^\d{14}$/) const phone = () => vine.string().maxLength(40) const address = () => vine.string().maxLength(500) const notes = () => vine.string().maxLength(2000) +// Prénom/nom du contact dédié — utilisés comme variables dans les templates +// custom ({{client.contactFirstName}}). Optionnels. +const contactName = () => vine.string().minLength(1).maxLength(80) /** * Validator pour POST /clients. Email **requis** : sans email, Rubis ne @@ -15,6 +18,8 @@ const notes = () => vine.string().maxLength(2000) export const createClientValidator = vine.create({ name: name(), email: email(), + contactFirstName: contactName().nullable().optional(), + contactLastName: contactName().nullable().optional(), phone: phone().nullable().optional(), address: address().nullable().optional(), siret: siret().nullable().optional(), @@ -27,6 +32,8 @@ export const createClientValidator = vine.create({ export const updateClientValidator = vine.create({ name: name().optional(), email: email().optional(), + contactFirstName: contactName().nullable().optional(), + contactLastName: contactName().nullable().optional(), phone: phone().nullable().optional(), address: address().nullable().optional(), siret: siret().nullable().optional(), diff --git a/apps/api/app/validators/plan.ts b/apps/api/app/validators/plan.ts index 7a3e0e0..1946415 100644 --- a/apps/api/app/validators/plan.ts +++ b/apps/api/app/validators/plan.ts @@ -24,3 +24,13 @@ export const updatePlanValidator = vine.create({ description: vine.string().maxLength(500).optional(), steps: vine.array(planStep).minLength(1).maxLength(10).optional(), }) + +/** + * Validator pour POST /plans — création d'un plan custom. + * Le slug est généré côté contrôleur depuis le name. + */ +export const createPlanValidator = vine.create({ + name: vine.string().minLength(1).maxLength(80), + description: vine.string().maxLength(500).optional(), + steps: vine.array(planStep).minLength(1).maxLength(10), +}) diff --git a/apps/api/database/migrations/1778080001200_add_contact_name_to_clients_table.ts b/apps/api/database/migrations/1778080001200_add_contact_name_to_clients_table.ts new file mode 100644 index 0000000..005738b --- /dev/null +++ b/apps/api/database/migrations/1778080001200_add_contact_name_to_clients_table.ts @@ -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') + }) + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index 897a2db..f6945a0 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -81,10 +81,14 @@ export class CheckinTaskSchema extends BaseModel { } export class ClientSchema extends BaseModel { - static $columns = ['address', 'createdAt', 'email', 'id', 'name', 'notes', 'organizationId', 'phone', 'siret', 'updatedAt'] as const + static $columns = ['address', 'contactFirstName', 'contactLastName', 'createdAt', 'email', 'id', 'name', 'notes', 'organizationId', 'phone', 'siret', 'updatedAt'] as const $columns = ClientSchema.$columns @column() declare address: string | null + @column() + declare contactFirstName: string | null + @column() + declare contactLastName: string | null @column.dateTime({ autoCreate: true }) declare createdAt: DateTime @column() diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index 178a419..9273bf9 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -88,13 +88,27 @@ router .as('clients') .use(middleware.auth()) + /** + * IA — auth requise. Génération de templates de relance avec Mistral + * pour le wizard de création de plan custom. + */ + router + .group(() => { + router.post('generate-relance', [controllers.Ai, 'generateRelance']).as('generate-relance') + }) + .prefix('ai') + .as('ai') + .use(middleware.auth()) + /** * Plans — auth requise. Lookup par slug (stable et lisible : - * /parametres/plans/standard-30j). POST plan custom = V2. + * /parametres/plans/standard-30j). POST = création d'un plan custom, + * slug auto-généré depuis le name. */ router .group(() => { router.get('', [controllers.Plans, 'index']).as('index') + router.post('', [controllers.Plans, 'store']).as('store') router.get(':slug', [controllers.Plans, 'show']).as('show') router.patch(':slug', [controllers.Plans, 'update']).as('update') }) diff --git a/apps/web/src/components/clients/ClientCreateDialog.tsx b/apps/web/src/components/clients/ClientCreateDialog.tsx index 9a2aa72..f363ba3 100644 --- a/apps/web/src/components/clients/ClientCreateDialog.tsx +++ b/apps/web/src/components/clients/ClientCreateDialog.tsx @@ -42,6 +42,8 @@ import { Textarea } from "@/components/ui/Textarea"; type FormValues = { name: string; email: string; + contactFirstName: string; + contactLastName: string; phone: string; address: string; siret: string; @@ -88,6 +90,10 @@ export function ClientCreateDialog({ api.post("/api/v1/clients", { name: input.name.trim(), email: input.email.trim(), + contactFirstName: + input.contactFirstName.trim() === "" ? null : input.contactFirstName.trim(), + contactLastName: + input.contactLastName.trim() === "" ? null : input.contactLastName.trim(), phone: input.phone.trim() === "" ? null : input.phone.trim(), address: input.address.trim() === "" ? null : input.address.trim(), siret: input.siret.trim() === "" ? null : input.siret.trim(), @@ -112,6 +118,8 @@ export function ClientCreateDialog({ const initialValues: FormValues = { name: defaultName, email: "", + contactFirstName: "", + contactLastName: "", phone: "", address: "", siret: "", @@ -202,6 +210,39 @@ export function ClientCreateDialog({ )} +
+ + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + +
+
{(field) => ( diff --git a/apps/web/src/components/plans/PlanCard.tsx b/apps/web/src/components/plans/PlanCard.tsx index 36e7d11..0366b88 100644 --- a/apps/web/src/components/plans/PlanCard.tsx +++ b/apps/web/src/components/plans/PlanCard.tsx @@ -1,5 +1,5 @@ import { Link } from "@tanstack/react-router"; -import { Plus, ArrowRight, Sparkles } from "lucide-react"; +import { Plus, ArrowRight, Sparkles, Copy as CopyIcon } from "lucide-react"; import type { Plan } from "@rubis/shared"; import { Card } from "@/components/ui/Card"; @@ -117,15 +117,27 @@ export function PlanCard({ plan, isMostUsed = false, className }: PlanCardProps) Aucune facture active )}

- {plan.slug && ( - - Modifier
); @@ -146,27 +158,29 @@ function labelForTone(tone: string): string { } } -/** Card "+ Créer un plan" — placeholder pour la création de plans custom. */ +/** + * Card "+ Créer un plan" — entrée du wizard 4 étapes. + * Lien direct vers `/plans/nouveau`. Pour partir d'un plan existant, + * passer par "Dupliquer" sur la card du plan source. + */ export function CreatePlanCard() { return ( - + ); } diff --git a/apps/web/src/components/plans/wizard/AiGenerateModal.tsx b/apps/web/src/components/plans/wizard/AiGenerateModal.tsx new file mode 100644 index 0000000..9696a14 --- /dev/null +++ b/apps/web/src/components/plans/wizard/AiGenerateModal.tsx @@ -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 = { + 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(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("/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 ( + + + + + + Générer avec l'IA + + + + L'IA va rédiger un email de relance avec une tonalité{" "} + {TONE_LABELS[tone].toLowerCase()}, programmé J + {offsetDays >= 0 ? "+" : ""} + {offsetDays}. Affinez le brief si besoin. + + + +
+ +