rubis/apps/web/src/lib/plans.ts
ordinarthur 9e531e32a9 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>
2026-05-06 22:55:00 +02:00

128 lines
4.3 KiB
TypeScript

import type { Plan, RelanceTone } from "@rubis/shared";
/**
* Mini interpolateur Mustache-like, miroir de
* `apps/api/app/services/template.ts:renderTemplate`. Utilisé pour la
* preview live dans le wizard de création de plan custom.
*/
export function renderTemplate(template: string, vars: Record<string, unknown>): string {
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path: string) => {
const parts = path.split(".");
let val: unknown = vars;
for (const p of parts) {
if (val == null || typeof val !== "object") return "";
val = (val as Record<string, unknown>)[p];
}
return val == null ? "" : String(val);
});
}
/**
* Vars de preview : utilisées par le wizard pour montrer l'email tel
* qu'il sera reçu, avec un client/facture fictifs.
*/
export const PREVIEW_VARS = {
client: {
name: "Boulangerie Martin SARL",
email: "compta@boulangerie-martin.fr",
contactFirstName: "Marie",
contactLastName: "Martin",
},
user: {
fullName: "Arthur Barré",
companyName: "Maçonnerie Dupont",
},
numero: "F-2026-0042",
amount: "1 240,00 €",
dueDate: "15/04/2026",
issueDate: "15/03/2026",
daysLate: "12",
signature: "Cordialement,\nArthur Barré\nMaçonnerie Dupont",
} as const;
/**
* Helpers de présentation des plans de relance.
* Garde la conversion tonalité → label public au même endroit.
*/
/** Label visible utilisateur pour chaque ton (cf. wireframe 3.1, chips). */
export const TONE_LABELS: Record<RelanceTone, string> = {
amical: "Amical",
courtois: "Standard",
ferme: "Ferme",
mise_en_demeure: "Mise en demeure",
};
/** Tonalité globale d'un plan = la dernière étape (la plus stricte). */
export function planOverallTone(plan: Pick<Plan, "steps">): RelanceTone {
const last = plan.steps[plan.steps.length - 1];
return last?.tone ?? "courtois";
}
/** Label court d'humeur d'un plan ("Doux" / "Standard" / "Ferme" / "Strict"). */
export function planMoodLabel(plan: Pick<Plan, "steps">): string {
const tone = planOverallTone(plan);
switch (tone) {
case "amical":
return "Doux";
case "courtois":
return "Standard";
case "ferme":
return "Ferme";
case "mise_en_demeure":
return "Strict";
default:
return "Standard";
}
}
/**
* Variables de template disponibles dans les emails de relance.
* Les chips dans l'éditeur viennent piocher ici.
*/
export type TemplateVariable = {
/** Token inséré tel quel dans le body (ex. "{{numero}}"). */
token: string;
/** Label affiché sur le chip cliquable. */
label: string;
/** Aperçu utilisé dans l'éditeur (placeholder réaliste). */
preview: string;
/** 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[] = [
// Client
{ token: "{{client.name}}", label: "Raison sociale", preview: "Boulangerie Martin SARL" },
{
token: "{{client.contactFirstName}}",
label: "Prénom contact",
preview: "Marie",
requiresClientField: "contactFirstName",
},
{
token: "{{client.contactLastName}}",
label: "Nom contact",
preview: "Martin",
requiresClientField: "contactLastName",
},
// Facture
{ token: "{{numero}}", label: "Numéro facture", preview: "F-2026-0042" },
{ token: "{{amount}}", label: "Montant TTC", preview: "1 240,00 €" },
{ token: "{{dueDate}}", label: "Échéance", preview: "15/04/2026" },
{ token: "{{issueDate}}", label: "Date émission", preview: "15/03/2026" },
{ token: "{{daysLate}}", label: "Jours de retard", preview: "12" },
// Expéditeur (la TPE qui envoie)
{ token: "{{user.fullName}}", label: "Votre nom", preview: "Arthur Barré" },
{ token: "{{user.companyName}}", label: "Votre entreprise", preview: "Maçonnerie Dupont" },
{ token: "{{signature}}", label: "Signature", preview: "Cordialement,\nArthur" },
];
/**
* Variables qui exigent qu'un champ correspondant soit rempli sur la fiche
* client. Pour chaque token utilisé dans un template, on peut détecter
* combien de clients existants n'ont pas le champ requis (warning UX).
*/
export type ClientRequiredField = "contactFirstName" | "contactLastName";