rubis/apps/api/app/controllers/dashboard_controller.ts
ordinarthur 2d3766cc3d feat(dashboard): dataviz cohérente DA Rubis (3 charts + page Insights)
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>
2026-05-07 10:11:45 +02:00

93 lines
2.6 KiB
TypeScript

import ActivityEvent from '#models/activity_event'
import {
computeKpis,
computeTimeseries,
topLatePayers,
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(),
})
const ACTIVITY_DEFAULT_LIMIT = 20
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
}
export default class DashboardController {
/**
* GET /dashboard/kpis
*
* Cf. service dashboard.ts — quelques metrics V1 sont placeholder
* (miseEnDemeurePending=0 tant que RelanceTask pas branché, percentile
* undefined tant que cohorte trop petite).
*/
async kpis({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const data = await computeKpis(organizationId)
return response.json({ data })
}
/**
* GET /dashboard/activity
*
* Journal append-only. Limit 20 par défaut, plus récent en tête.
*/
async activity({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const events = await ActivityEvent.query()
.where('organization_id', organizationId)
.orderBy('at', 'desc')
.limit(ACTIVITY_DEFAULT_LIMIT)
return response.json({
data: events.map((e) => ({
id: e.id,
kind: e.kind,
at: e.at.toISO()!,
label: e.label,
meta: e.meta,
})),
})
}
/**
* GET /dashboard/top-late
*
* Top 5 clients avec le plus de factures en retard (status actif +
* due_date dépassée).
*/
async topLate({ auth, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const data = await topLatePayers(organizationId)
return response.json({ data })
}
/**
* GET /dashboard/timeseries?range=6
*
* Séries temporelles pour les graphes (encaissé mensuel + DSO mensuel
* + pipeline par statut). Range : 3, 6 ou 12 mois (défaut 6).
*/
async timeseries({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const { range } = await request.validateUsing(timeseriesValidator, {
data: {
range: request.input('range') ? Number(request.input('range')) : undefined,
},
})
const data = await computeTimeseries(organizationId, (range ?? 6) as RangeMonths)
return response.json({ data })
}
}