Backend - Service dashboard.ts : computeTimeseries + computeClientTimeseries (helper fetchPaidByMonth DRY entre les deux). Buckets pré-créés sur N mois pour pas afficher de "trous" quand un mois n'a aucun paiement. - GET /dashboard/timeseries?range=3|6|12 (paidByMonth + pipelineByStatus) - GET /clients/:id/timeseries?range=3|6|12 (paidByMonth filtré) Frontend — Recharts (43 deps, ~50KB gzip) - components/charts/theme.ts : palette stricte (rubis + neutres chauds, pas de bleu/vert), couleurs statuts cohérentes avec les badges côté liste, format fr-FR pour les axes/tooltips - ChartTooltip themed : carte cream + bordure rubis-glow, font Inter, tabular-nums, série label override - EncaisseChart (area, dégradé rubis-glow → transparent) - DsoTrendChart (line ink + référence pointillée à 30j = norme LME) - PipelineChart (donut avec total au centre + PipelineLegend séparée) - ClientPaidChart (bar chart compact pour fiche client) Wiring - Dashboard / : encaissé + DSO côte à côte, pipeline + top retards en dessous - Fiche client /clients/:id : mini bar chart "encaissés sur 6 mois" entre les stats et la liste factures - Page /insights : version pleine largeur des 3 charts + range selector 3m/6m/12m + 3 cards récap (encaissé total, factures payées, DSO moyen). Lien "Insights" ajouté au sidebar desktop (icône TrendingUp). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
235 lines
7.2 KiB
TypeScript
235 lines
7.2 KiB
TypeScript
import Client from '#models/client'
|
|
import Invoice from '#models/invoice'
|
|
import ClientTransformer from '#transformers/client_transformer'
|
|
import InvoiceTransformer from '#transformers/invoice_transformer'
|
|
import { createClientValidator, updateClientValidator } from '#validators/client'
|
|
import { bulkComputeClientStats } from '#services/client_stats'
|
|
import { computeClientTimeseries, type RangeMonths } from '#services/dashboard'
|
|
import type { HttpContext } from '@adonisjs/core/http'
|
|
import { Exception } from '@adonisjs/core/exceptions'
|
|
import vine from '@vinejs/vine'
|
|
|
|
const timeseriesValidator = vine.create({
|
|
range: vine.number().in([3, 6, 12]).optional(),
|
|
})
|
|
|
|
// Priorité d'affichage : ce qui est actionnable en haut.
|
|
const INVOICE_STATUS_PRIORITY: Record<string, number> = {
|
|
awaiting_user_confirmation: 0,
|
|
in_relance: 1,
|
|
pending: 2,
|
|
litigation: 3,
|
|
paid: 4,
|
|
cancelled: 5,
|
|
}
|
|
|
|
/**
|
|
* 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)!
|
|
|
|
// Factures du client — actionnables en premier, puis échéance asc.
|
|
const invoices = await Invoice.query()
|
|
.where('organization_id', organizationId)
|
|
.where('client_id', client.id)
|
|
.preload('client')
|
|
.preload('plan')
|
|
.exec()
|
|
|
|
invoices.sort((a, b) => {
|
|
const dp =
|
|
(INVOICE_STATUS_PRIORITY[a.status] ?? 99) -
|
|
(INVOICE_STATUS_PRIORITY[b.status] ?? 99)
|
|
if (dp !== 0) return dp
|
|
return a.dueDate.toMillis() - b.dueDate.toMillis()
|
|
})
|
|
|
|
return response.json({
|
|
data: {
|
|
...serializeClient(client),
|
|
...stats,
|
|
invoices: invoices.map((inv) => new InvoiceTransformer(inv).toObject()),
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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) })
|
|
}
|
|
|
|
/**
|
|
* GET /clients/:id/timeseries?range=6 — encaissé mensuel pour ce client.
|
|
* Utilisé par le mini-chart sur la fiche client (santé du compte).
|
|
*/
|
|
async timeseries({ auth, request, params, response }: HttpContext) {
|
|
const organizationId = requireOrgId(auth)
|
|
|
|
const exists = await Client.query()
|
|
.where('organization_id', organizationId)
|
|
.where('id', params.id)
|
|
.first()
|
|
if (!exists) {
|
|
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
|
|
}
|
|
|
|
const { range } = await request.validateUsing(timeseriesValidator, {
|
|
data: {
|
|
range: request.input('range') ? Number(request.input('range')) : undefined,
|
|
},
|
|
})
|
|
const data = await computeClientTimeseries(
|
|
organizationId,
|
|
params.id,
|
|
(range ?? 6) as RangeMonths
|
|
)
|
|
return response.json({ data })
|
|
}
|
|
|
|
/**
|
|
* 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) })
|
|
}
|
|
}
|