From 704f472729996b1d6330b3e5d31b0d5a15087d66 Mon Sep 17 00:00:00 2001
From: ordinarthur <@arthurbarre.js@gmail.com>
Date: Wed, 6 May 2026 15:10:58 +0200
Subject: [PATCH] feat(api): dashboard kpis + activity feed + top-late +
ActivityEvent
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Migration activity_events (uuid id, organization_id FK CASCADE, kind ENUM PG natif relance_sent/invoice_paid/invoice_imported/warning_drafted, at, label HTML léger, meta jsonb). Append-only — pas de mutation. Index (org, at).
Schema rules : kind typé en union + meta typé { invoiceId?, clientId?, planStepOrder? }.
Service activity_recorder.ts : recordActivity({orgId, kind, label, meta, trx?}). Branché dans :
- InvoicesController.markPaid → invoice_paid
- ImportBatchesController.validateDraft → invoice_imported
À venir : SendRelanceJob (relance_sent + warning_drafted) quand BullMQ sera là.
Service dashboard.ts :
- computeKpis(orgId) : 1 requête FILTER pour les counts par status + 1 requête pour les sommes paid this month / prev month / DSO. miseEnDemeurePending=0 et percentile=undefined V1 (placeholders honnêtes plutôt que faux chiffres).
- topLatePayers(orgId, 5) : INNER JOIN clients + agrégation count() par client_id, due_date < today + status actif.
Controller DashboardController :
- GET /dashboard/kpis : computeKpis
- GET /dashboard/activity : 20 derniers events de l'org, plus récent en tête
- GET /dashboard/top-late : top 5
Routes /api/v1/dashboard/* (auth requise).
Bruno : nouveau dossier 07-Dashboard avec 3 requêtes documentées.
Pour générer du contenu activity feed : encaisser une facture (Invoices → Mark paid) ou valider un draft (Imports → Validate). KPIs : créer des factures puis les marquer payées (paidAt rentre dans les sommes).
---
.../app/controllers/dashboard_controller.ts | 65 ++++++++
.../controllers/import_batches_controller.ts | 9 ++
.../app/controllers/invoices_controller.ts | 10 ++
apps/api/app/models/activity_event.ts | 9 ++
apps/api/app/services/activity_recorder.ts | 40 +++++
apps/api/app/services/dashboard.ts | 150 ++++++++++++++++++
...8080000900_create_activity_events_table.ts | 44 +++++
apps/api/database/schema.ts | 21 +++
apps/api/database/schema_rules.ts | 12 ++
apps/api/start/routes.ts | 13 ++
bruno/07-Dashboard/01 KPIs.bru | 48 ++++++
bruno/07-Dashboard/02 Activity.bru | 48 ++++++
bruno/07-Dashboard/03 Top late payers.bru | 37 +++++
bruno/07-Dashboard/folder.bru | 21 +++
14 files changed, 527 insertions(+)
create mode 100644 apps/api/app/controllers/dashboard_controller.ts
create mode 100644 apps/api/app/models/activity_event.ts
create mode 100644 apps/api/app/services/activity_recorder.ts
create mode 100644 apps/api/app/services/dashboard.ts
create mode 100644 apps/api/database/migrations/1778080000900_create_activity_events_table.ts
create mode 100644 bruno/07-Dashboard/01 KPIs.bru
create mode 100644 bruno/07-Dashboard/02 Activity.bru
create mode 100644 bruno/07-Dashboard/03 Top late payers.bru
create mode 100644 bruno/07-Dashboard/folder.bru
diff --git a/apps/api/app/controllers/dashboard_controller.ts b/apps/api/app/controllers/dashboard_controller.ts
new file mode 100644
index 0000000..e535bfb
--- /dev/null
+++ b/apps/api/app/controllers/dashboard_controller.ts
@@ -0,0 +1,65 @@
+import ActivityEvent from '#models/activity_event'
+import { computeKpis, topLatePayers } from '#services/dashboard'
+import type { HttpContext } from '@adonisjs/core/http'
+import { Exception } from '@adonisjs/core/exceptions'
+
+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 })
+ }
+}
diff --git a/apps/api/app/controllers/import_batches_controller.ts b/apps/api/app/controllers/import_batches_controller.ts
index 0e53891..f0515a6 100644
--- a/apps/api/app/controllers/import_batches_controller.ts
+++ b/apps/api/app/controllers/import_batches_controller.ts
@@ -11,6 +11,7 @@ import {
} from '#validators/import_batch'
import { resolveClient } from '#services/resolve_client'
import { createImportBatchFromFilenames } from '#services/import_batch'
+import { recordActivity } from '#services/activity_recorder'
import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
@@ -151,6 +152,14 @@ export default class ImportBatchesController {
draft.invoiceId = created.id
await draft.save()
+ await recordActivity({
+ organizationId,
+ kind: 'invoice_imported',
+ label: `Facture ${created.numero} importée et validée`,
+ meta: { invoiceId: created.id, clientId: client.id },
+ trx,
+ })
+
return created
})
diff --git a/apps/api/app/controllers/invoices_controller.ts b/apps/api/app/controllers/invoices_controller.ts
index f9f7abc..8e58481 100644
--- a/apps/api/app/controllers/invoices_controller.ts
+++ b/apps/api/app/controllers/invoices_controller.ts
@@ -10,6 +10,7 @@ import { Exception } from '@adonisjs/core/exceptions'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
import { resolveClient } from '#services/resolve_client'
+import { recordActivity } from '#services/activity_recorder'
const PAGE_SIZE = 50
@@ -333,6 +334,15 @@ export default class InvoicesController {
.from('organizations')
.where('id', organizationId)
.increment('rubis_count', 1)
+
+ // Journal d'activité (cf. dashboard activity feed).
+ await recordActivity({
+ organizationId,
+ kind: 'invoice_paid',
+ label: `Facture ${invoice.numero} marquée encaissée`,
+ meta: { invoiceId: invoice.id, clientId: invoice.clientId },
+ trx,
+ })
})
return response.json({ data: serializeInvoice(invoice) })
diff --git a/apps/api/app/models/activity_event.ts b/apps/api/app/models/activity_event.ts
new file mode 100644
index 0000000..5606b8e
--- /dev/null
+++ b/apps/api/app/models/activity_event.ts
@@ -0,0 +1,9 @@
+import { ActivityEventSchema } from '#database/schema'
+import { belongsTo } from '@adonisjs/lucid/orm'
+import type { BelongsTo } from '@adonisjs/lucid/types/relations'
+import Organization from '#models/organization'
+
+export default class ActivityEvent extends ActivityEventSchema {
+ @belongsTo(() => Organization)
+ declare organization: BelongsTo
+}
diff --git a/apps/api/app/services/activity_recorder.ts b/apps/api/app/services/activity_recorder.ts
new file mode 100644
index 0000000..cb395d4
--- /dev/null
+++ b/apps/api/app/services/activity_recorder.ts
@@ -0,0 +1,40 @@
+import { DateTime } from 'luxon'
+import ActivityEvent from '#models/activity_event'
+import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
+
+type EventKind = 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
+
+type RecordOpts = {
+ organizationId: string
+ kind: EventKind
+ label: string
+ meta?: Record
+ at?: DateTime
+ trx?: TransactionClientContract
+}
+
+/**
+ * Enregistre un événement dans le journal d'activité (append-only).
+ * Appelé depuis :
+ * - SendRelanceJob (relance_sent)
+ * - InvoicesController.markPaid (invoice_paid)
+ * - ImportBatchesController.validateDraft (invoice_imported)
+ * - SendRelanceJob quand step.requires_manual_validation (warning_drafted)
+ *
+ * Les labels acceptent un HTML léger () pour permettre au SPA de
+ * mettre en gras les noms d'entité — toujours composé côté serveur,
+ * jamais d'input utilisateur brut.
+ */
+export async function recordActivity(opts: RecordOpts): Promise {
+ const { organizationId, kind, label, meta = {}, at, trx } = opts
+ return ActivityEvent.create(
+ {
+ organizationId,
+ kind,
+ label,
+ meta,
+ at: at ?? DateTime.now(),
+ },
+ trx ? { client: trx } : undefined
+ )
+}
diff --git a/apps/api/app/services/dashboard.ts b/apps/api/app/services/dashboard.ts
new file mode 100644
index 0000000..30b79e1
--- /dev/null
+++ b/apps/api/app/services/dashboard.ts
@@ -0,0 +1,150 @@
+import db from '@adonisjs/lucid/services/db'
+import Organization from '#models/organization'
+import { DateTime } from 'luxon'
+
+const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
+
+export type DashboardKpis = {
+ rubisCount: number
+ rubisThisMonth: number
+ // 1 rubis = 10 minutes libérées (cf. CLAUDE.md → glossaire)
+ hoursLiberatedThisMonth: number
+ encaisseCents: number
+ encaisseDeltaCents: number
+ dsoDays: number
+ dsoDeltaDays: number
+ factureToRelance: number
+ factureInRelance: number
+ factureNewToday: number
+ miseEnDemeurePending: number
+ monthlyGoalProgress: number
+ // Rang relatif à la cohorte (placeholder V1, calculé en V2 avec assez de data)
+ percentile?: number
+}
+
+function startOfMonth(d: DateTime): Date {
+ return d.startOf('month').toJSDate()
+}
+
+function startOfDay(d: DateTime): Date {
+ return d.startOf('day').toJSDate()
+}
+
+/**
+ * Calcule les KPIs dashboard pour une organisation.
+ *
+ * V1 — implémentation simple sans cache. Quelques metrics avancés
+ * (DSO, percentile) sont à 0 ou null tant qu'on a pas assez d'historique.
+ * Le contrat reste stable côté SPA.
+ */
+export async function computeKpis(organizationId: string): Promise {
+ const now = DateTime.now()
+ const monthStart = startOfMonth(now)
+ const todayStart = startOfDay(now)
+ const prevMonthStart = startOfMonth(now.minus({ months: 1 }))
+
+ const org = await Organization.findOrFail(organizationId)
+
+ // Counts par statut + factures récentes
+ const counts = (await db
+ .from('invoices')
+ .where('organization_id', organizationId)
+ .select(
+ db.raw(`count(*) filter (where status = 'pending')::int as to_relance`),
+ db.raw(`count(*) filter (where status = 'in_relance')::int as in_relance`),
+ db.raw(`count(*) filter (where created_at >= ?)::int as new_today`, [todayStart])
+ )
+ .first()) as { to_relance: number; in_relance: number; new_today: number } | undefined
+
+ // Sommes d'encaissement (paid_at) ce mois et le précédent
+ const paidStats = (await db
+ .from('invoices')
+ .where('organization_id', organizationId)
+ .where('status', 'paid')
+ .select(
+ db.raw(
+ `coalesce(sum(amount_ttc_cents) filter (where paid_at >= ?), 0)::int as this_month`,
+ [monthStart]
+ ),
+ db.raw(
+ `coalesce(sum(amount_ttc_cents) filter (where paid_at >= ? and paid_at < ?), 0)::int as prev_month`,
+ [prevMonthStart, monthStart]
+ ),
+ db.raw(
+ `coalesce(sum(rubis_earned) filter (where paid_at >= ?), 0)::int as rubis_this_month`,
+ [monthStart]
+ ),
+ db.raw(
+ `coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ?), 0)::int as dso_this_month`,
+ [monthStart]
+ ),
+ db.raw(
+ `coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400) filter (where paid_at >= ? and paid_at < ?), 0)::int as dso_prev_month`,
+ [prevMonthStart, monthStart]
+ )
+ )
+ .first()) as
+ | {
+ this_month: number
+ prev_month: number
+ rubis_this_month: number
+ dso_this_month: number
+ dso_prev_month: number
+ }
+ | undefined
+
+ const encaisseCents = paidStats?.this_month ?? 0
+ const encaisseDeltaCents = encaisseCents - (paidStats?.prev_month ?? 0)
+ const rubisThisMonth = paidStats?.rubis_this_month ?? 0
+ const dsoDays = paidStats?.dso_this_month ?? 0
+ const dsoDeltaDays = dsoDays - (paidStats?.dso_prev_month ?? 0)
+
+ return {
+ rubisCount: org.rubisCount,
+ rubisThisMonth,
+ hoursLiberatedThisMonth: rubisThisMonth * 10,
+ encaisseCents,
+ encaisseDeltaCents,
+ dsoDays,
+ dsoDeltaDays,
+ factureToRelance: counts?.to_relance ?? 0,
+ factureInRelance: counts?.in_relance ?? 0,
+ factureNewToday: counts?.new_today ?? 0,
+ // Mise en demeure pending — sera calculé quand RelanceTask est branché
+ // (count des steps requires_manual_validation programmées). Pour V1 : 0.
+ miseEnDemeurePending: 0,
+ // Goal progress (V1 placeholder) : ratio rubis_count / 250 (objectif
+ // mensuel arbitraire). À paramétrer plus tard.
+ monthlyGoalProgress: Math.min(100, Math.round((rubisThisMonth / 25) * 100)),
+ percentile: undefined,
+ }
+}
+
+/**
+ * Top des clients en retard (top 5 par défaut).
+ * Compte les factures actives dont due_date est dépassée, agrégées par client.
+ */
+export async function topLatePayers(
+ organizationId: string,
+ limit = 5
+): Promise> {
+ const today = startOfDay(DateTime.now())
+
+ const rows = await db
+ .from('invoices')
+ .innerJoin('clients', 'clients.id', 'invoices.client_id')
+ .where('invoices.organization_id', organizationId)
+ .whereRaw(`invoices.status::text in ${ACTIVE_INVOICE_STATUSES}`)
+ .where('invoices.due_date', '<', today)
+ .groupBy('clients.id', 'clients.name')
+ .select('clients.id as client_id', 'clients.name as name')
+ .select(db.raw('count(*)::int as late_invoices_count'))
+ .orderBy('late_invoices_count', 'desc')
+ .limit(limit)
+
+ return rows.map((r) => ({
+ clientId: r.client_id,
+ name: r.name,
+ lateInvoicesCount: r.late_invoices_count,
+ }))
+}
diff --git a/apps/api/database/migrations/1778080000900_create_activity_events_table.ts b/apps/api/database/migrations/1778080000900_create_activity_events_table.ts
new file mode 100644
index 0000000..d3465ae
--- /dev/null
+++ b/apps/api/database/migrations/1778080000900_create_activity_events_table.ts
@@ -0,0 +1,44 @@
+import { BaseSchema } from '@adonisjs/lucid/schema'
+
+export default class extends BaseSchema {
+ protected tableName = 'activity_events'
+
+ async up() {
+ this.schema.createTable(this.tableName, (table) => {
+ table.uuid('id').primary().notNullable().defaultTo(this.raw('gen_random_uuid()'))
+ table
+ .uuid('organization_id')
+ .notNullable()
+ .references('id')
+ .inTable('organizations')
+ .onDelete('CASCADE')
+
+ // Append-only — pas d'update sur cette table. Les events sont des
+ // snapshots historiques, pas un état mutable.
+ table
+ .enum(
+ 'kind',
+ ['relance_sent', 'invoice_paid', 'invoice_imported', 'warning_drafted'],
+ { useNative: true, enumName: 'activity_event_kind' }
+ )
+ .notNullable()
+ table.timestamp('at').notNullable()
+ // Label HTML léger autorisé ( pour le nom client/numero) — sanitisé
+ // côté serveur quand on construit le label, pas de user input direct.
+ table.text('label').notNullable()
+ // Méta jsonb : { invoiceId?, clientId?, ... } pour permettre au SPA
+ // de cliquer sur l'event vers la facture/le client concerné.
+ table.jsonb('meta').notNullable().defaultTo('{}')
+
+ table.timestamp('created_at').notNullable()
+ table.timestamp('updated_at').nullable()
+
+ table.index(['organization_id', 'at'])
+ })
+ }
+
+ async down() {
+ this.schema.dropTable(this.tableName)
+ this.schema.raw('DROP TYPE IF EXISTS activity_event_kind')
+ }
+}
diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts
index 1ce4c39..89896c8 100644
--- a/apps/api/database/schema.ts
+++ b/apps/api/database/schema.ts
@@ -7,6 +7,27 @@
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
+export class ActivityEventSchema extends BaseModel {
+ static $columns = ['at', 'createdAt', 'id', 'kind', 'label', 'meta', 'organizationId', 'updatedAt'] as const
+ $columns = ActivityEventSchema.$columns
+ @column.dateTime()
+ declare at: DateTime
+ @column.dateTime({ autoCreate: true })
+ declare createdAt: DateTime
+ @column({ isPrimary: true })
+ declare id: string
+ @column()
+ declare kind: 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
+ @column()
+ declare label: string
+ @column()
+ declare meta: { invoiceId?: string; clientId?: string; planStepOrder?: number; [k: string]: unknown }
+ @column()
+ declare organizationId: string
+ @column.dateTime({ autoCreate: true, autoUpdate: true })
+ declare updatedAt: DateTime | null
+}
+
export class AuthAccessTokenSchema extends BaseModel {
static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
$columns = AuthAccessTokenSchema.$columns
diff --git a/apps/api/database/schema_rules.ts b/apps/api/database/schema_rules.ts
index ac9ba5d..4355a2b 100644
--- a/apps/api/database/schema_rules.ts
+++ b/apps/api/database/schema_rules.ts
@@ -21,6 +21,18 @@ export default {
},
},
},
+ activity_events: {
+ columns: {
+ kind: {
+ tsType:
+ "'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'",
+ },
+ meta: {
+ tsType:
+ "{ invoiceId?: string; clientId?: string; planStepOrder?: number; [k: string]: unknown }",
+ },
+ },
+ },
import_drafts: {
columns: {
status: {
diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts
index 45d2267..6ecf6bb 100644
--- a/apps/api/start/routes.ts
+++ b/apps/api/start/routes.ts
@@ -85,6 +85,19 @@ router
.as('plans')
.use(middleware.auth())
+ /**
+ * Dashboard — auth requise. Calculs agrégés on-the-fly (pas de cache V1).
+ */
+ router
+ .group(() => {
+ router.get('kpis', [controllers.Dashboard, 'kpis']).as('kpis')
+ router.get('activity', [controllers.Dashboard, 'activity']).as('activity')
+ router.get('top-late', [controllers.Dashboard, 'topLate']).as('top-late')
+ })
+ .prefix('dashboard')
+ .as('dashboard')
+ .use(middleware.auth())
+
/**
* Invoices — auth requise. Ordre IMPORTANT : les routes statiques
* (/upload, /counts, /import-batch/...) sont déclarées AVANT /:id
diff --git a/bruno/07-Dashboard/01 KPIs.bru b/bruno/07-Dashboard/01 KPIs.bru
new file mode 100644
index 0000000..6c25e34
--- /dev/null
+++ b/bruno/07-Dashboard/01 KPIs.bru
@@ -0,0 +1,48 @@
+meta {
+ name: 01 KPIs
+ type: http
+ seq: 1
+}
+
+get {
+ url: {{baseUrl}}/api/v1/dashboard/kpis
+ body: none
+ auth: inherit
+}
+
+tests {
+ test("200 OK", function () {
+ expect(res.getStatus()).to.equal(200);
+ });
+ test("KPIs shape", function () {
+ const k = res.getBody().data;
+ for (const key of [
+ "rubisCount", "rubisThisMonth", "hoursLiberatedThisMonth",
+ "encaisseCents", "encaisseDeltaCents",
+ "dsoDays", "dsoDeltaDays",
+ "factureToRelance", "factureInRelance", "factureNewToday",
+ "miseEnDemeurePending", "monthlyGoalProgress"
+ ]) {
+ expect(k).to.have.property(key);
+ }
+ });
+}
+
+docs {
+ GET /api/v1/dashboard/kpis
+
+ Calculs agrégés sur les invoices de l'org :
+ - `rubisCount` lu sur Organization (compteur cumulé)
+ - `rubisThisMonth` = sum(rubis_earned where paid_at >= startOfMonth)
+ - `hoursLiberatedThisMonth` = rubisThisMonth × 10 (1 rubis = 10 min)
+ - `encaisseCents` = sum(amount_ttc_cents where paid this month)
+ - `encaisseDeltaCents` = ce mois − mois précédent
+ - `dsoDays` = avg(paid_at − issue_date) en jours, sur factures payées ce mois
+ - `dsoDeltaDays` = idem delta vs mois précédent
+ - `factureToRelance` = count(status='pending')
+ - `factureInRelance` = count(status='in_relance')
+ - `factureNewToday` = count(created_at >= today)
+ - `miseEnDemeurePending` = 0 (à brancher quand RelanceTask sera là)
+ - `monthlyGoalProgress` = clamp(rubisThisMonth/25*100, 0, 100) — placeholder
+ - `percentile` = undefined V1
+}
diff --git a/bruno/07-Dashboard/02 Activity.bru b/bruno/07-Dashboard/02 Activity.bru
new file mode 100644
index 0000000..b8b6b69
--- /dev/null
+++ b/bruno/07-Dashboard/02 Activity.bru
@@ -0,0 +1,48 @@
+meta {
+ name: 02 Activity
+ type: http
+ seq: 2
+}
+
+get {
+ url: {{baseUrl}}/api/v1/dashboard/activity
+ body: none
+ auth: inherit
+}
+
+tests {
+ test("200 OK", function () {
+ expect(res.getStatus()).to.equal(200);
+ });
+ test("data is array", function () {
+ expect(res.getBody().data).to.be.an("array");
+ });
+}
+
+docs {
+ GET /api/v1/dashboard/activity
+
+ Journal d'activité — append-only, 20 derniers events de l'org, plus
+ récent en tête.
+
+ Kinds émis :
+ - `invoice_paid` (Invoices → Mark paid)
+ - `invoice_imported` (Imports → Validate draft)
+ - `relance_sent` (à venir, SendRelanceJob)
+ - `warning_drafted` (à venir, mise en demeure)
+
+ Chaque event a :
+ ```json
+ {
+ "id": "",
+ "kind": "...",
+ "at": "ISO 8601",
+ "label": "Texte HTML léger ( autorisé pour les noms d'entité)",
+ "meta": { "invoiceId": "...", "clientId": "..." }
+ }
+ ```
+
+ Pour générer des events : encaisse une facture (Invoices → 06 Mark paid)
+ ou valide un draft d'import (Imports → 03 Validate draft) puis re-call
+ cette route.
+}
diff --git a/bruno/07-Dashboard/03 Top late payers.bru b/bruno/07-Dashboard/03 Top late payers.bru
new file mode 100644
index 0000000..5d99089
--- /dev/null
+++ b/bruno/07-Dashboard/03 Top late payers.bru
@@ -0,0 +1,37 @@
+meta {
+ name: 03 Top late payers
+ type: http
+ seq: 3
+}
+
+get {
+ url: {{baseUrl}}/api/v1/dashboard/top-late
+ body: none
+ auth: inherit
+}
+
+tests {
+ test("200 OK", function () {
+ expect(res.getStatus()).to.equal(200);
+ });
+ test("data shape", function () {
+ const list = res.getBody().data;
+ expect(list).to.be.an("array");
+ if (list.length > 0) {
+ expect(list[0]).to.have.property("clientId");
+ expect(list[0]).to.have.property("name");
+ expect(list[0]).to.have.property("lateInvoicesCount");
+ }
+ });
+}
+
+docs {
+ GET /api/v1/dashboard/top-late
+
+ Top 5 des clients ayant le plus de factures en retard. "En retard" =
+ status actif (pending / in_relance / awaiting_user_confirmation) ET
+ due_date < today.
+
+ Pour générer des données : crée des factures avec une `dueDate` dans
+ le passé via Invoices → 04 Create.
+}
diff --git a/bruno/07-Dashboard/folder.bru b/bruno/07-Dashboard/folder.bru
new file mode 100644
index 0000000..24ee3ae
--- /dev/null
+++ b/bruno/07-Dashboard/folder.bru
@@ -0,0 +1,21 @@
+meta {
+ name: Dashboard
+ seq: 8
+}
+
+auth {
+ mode: bearer
+}
+
+auth:bearer {
+ token: {{token}}
+}
+
+docs {
+ ## Dashboard — KPIs, journal d'activité, top clients en retard
+
+ Tout est calculé on-the-fly côté SQL (pas de cache V1). Le journal
+ d'activité est append-only et alimenté automatiquement par les
+ controllers métier (mark-paid → invoice_paid, validate-draft →
+ invoice_imported, etc.).
+}