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.). +}