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>
93 lines
2.6 KiB
TypeScript
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 })
|
|
}
|
|
}
|