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>
173 lines
5.3 KiB
TypeScript
173 lines
5.3 KiB
TypeScript
import Client from '#models/client'
|
|
import ClientTransformer from '#transformers/client_transformer'
|
|
import { createClientValidator, updateClientValidator } from '#validators/client'
|
|
import { bulkComputeClientStats } from '#services/client_stats'
|
|
import type { HttpContext } from '@adonisjs/core/http'
|
|
import { Exception } from '@adonisjs/core/exceptions'
|
|
|
|
/**
|
|
* Petite cohérence d'identification orgnisation : si l'utilisateur
|
|
* n'en a pas, on est dans un état illégal V1 — on bloque ferme.
|
|
*/
|
|
function requireOrgId(auth: HttpContext['auth']): string {
|
|
const user = auth.getUserOrFail()
|
|
if (!user.organizationId) {
|
|
throw new Exception('Aucune organisation rattachée', { status: 404, code: 'not_found' })
|
|
}
|
|
return user.organizationId
|
|
}
|
|
|
|
/**
|
|
* Sérialisation directe (instanciation manuelle du transformer pour
|
|
* éviter le wrapper Item — utile quand on doit fusionner des stats
|
|
* computed par-dessus chaque client dans une liste).
|
|
*/
|
|
function serializeClient(c: Client) {
|
|
return new ClientTransformer(c).toObject()
|
|
}
|
|
|
|
export default class ClientsController {
|
|
/**
|
|
* GET /clients?withStats=1&q=
|
|
*
|
|
* Sans `withStats`, retour à plat (utilisé par le combobox de saisie).
|
|
* Avec `withStats`, chaque client est enrichi des compteurs de factures
|
|
* et trié par actionnabilité (retards d'abord, puis activité récente).
|
|
*/
|
|
async index({ auth, request, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
const withStats = request.input('withStats') === '1'
|
|
const q = (request.input('q') ?? '').toString().trim().toLowerCase()
|
|
|
|
const query = Client.query().where('organization_id', organizationId)
|
|
|
|
if (q.length > 0) {
|
|
query.where((b) => {
|
|
b.whereILike('name', `%${q}%`).orWhereILike('email', `%${q}%`)
|
|
})
|
|
}
|
|
|
|
const clients = await query.exec()
|
|
|
|
if (!withStats) {
|
|
// Tri alphabétique par défaut pour le combobox.
|
|
clients.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
|
return response.json({ data: clients.map(serializeClient) })
|
|
}
|
|
|
|
const statsMap = await bulkComputeClientStats(
|
|
organizationId,
|
|
clients.map((c) => c.id)
|
|
)
|
|
|
|
const enriched = clients.map((c) => ({
|
|
...serializeClient(c),
|
|
...statsMap.get(c.id)!,
|
|
}))
|
|
|
|
// Tri actionnable : retards d'abord, puis activité récente.
|
|
enriched.sort((a, b) => {
|
|
if (a.lateInvoiceCount !== b.lateInvoiceCount) {
|
|
return b.lateInvoiceCount - a.lateInvoiceCount
|
|
}
|
|
const aLast = a.lastActivityAt ?? ''
|
|
const bLast = b.lastActivityAt ?? ''
|
|
return bLast.localeCompare(aLast)
|
|
})
|
|
|
|
return response.json({ data: enriched })
|
|
}
|
|
|
|
/**
|
|
* GET /clients/:id — détail enrichi (stats + invoices à venir).
|
|
*/
|
|
async show({ auth, params, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
|
|
const client = await Client.query()
|
|
.where('organization_id', organizationId)
|
|
.where('id', params.id)
|
|
.first()
|
|
|
|
if (!client) {
|
|
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
|
|
}
|
|
|
|
const statsMap = await bulkComputeClientStats(organizationId, [client.id])
|
|
const stats = statsMap.get(client.id)!
|
|
|
|
return response.json({
|
|
data: {
|
|
...serializeClient(client),
|
|
...stats,
|
|
invoices: [], // TODO: brancher quand le domaine Invoice arrive
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* POST /clients — création manuelle.
|
|
* Détecte les doublons de nom (case-insensitive) et renvoie 409 avec
|
|
* la fiche existante pour permettre au SPA de proposer "voir le client".
|
|
*/
|
|
async store({ auth, request, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
const payload = await request.validateUsing(createClientValidator)
|
|
|
|
// Doublon → 409 (cf. clients.ts MSW pour le contrat exact).
|
|
const existing = await Client.query()
|
|
.where('organization_id', organizationId)
|
|
.whereILike('name', payload.name)
|
|
.first()
|
|
|
|
if (existing) {
|
|
return response.status(409).json({
|
|
errors: [
|
|
{
|
|
code: 'duplicate_client',
|
|
message: `Un client nommé « ${existing.name} » existe déjà.`,
|
|
field: 'name',
|
|
},
|
|
],
|
|
existing: serializeClient(existing),
|
|
})
|
|
}
|
|
|
|
const created = await Client.create({
|
|
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,
|
|
notes: payload.notes ?? null,
|
|
})
|
|
|
|
return response.status(201).json({ data: serializeClient(created) })
|
|
}
|
|
|
|
/**
|
|
* PATCH /clients/:id — édition partielle.
|
|
*/
|
|
async update({ auth, request, params, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
const payload = await request.validateUsing(updateClientValidator)
|
|
|
|
const client = await Client.query()
|
|
.where('organization_id', organizationId)
|
|
.where('id', params.id)
|
|
.first()
|
|
|
|
if (!client) {
|
|
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
|
|
}
|
|
|
|
client.merge(payload)
|
|
await client.save()
|
|
|
|
return response.json({ data: serializeClient(client) })
|
|
}
|
|
}
|