feat(api): dashboard kpis + activity feed + top-late + ActivityEvent
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).
This commit is contained in:
parent
5d3408fafa
commit
704f472729
65
apps/api/app/controllers/dashboard_controller.ts
Normal file
65
apps/api/app/controllers/dashboard_controller.ts
Normal file
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from '#validators/import_batch'
|
} from '#validators/import_batch'
|
||||||
import { resolveClient } from '#services/resolve_client'
|
import { resolveClient } from '#services/resolve_client'
|
||||||
import { createImportBatchFromFilenames } from '#services/import_batch'
|
import { createImportBatchFromFilenames } from '#services/import_batch'
|
||||||
|
import { recordActivity } from '#services/activity_recorder'
|
||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
import { Exception } from '@adonisjs/core/exceptions'
|
import { Exception } from '@adonisjs/core/exceptions'
|
||||||
import db from '@adonisjs/lucid/services/db'
|
import db from '@adonisjs/lucid/services/db'
|
||||||
@ -151,6 +152,14 @@ export default class ImportBatchesController {
|
|||||||
draft.invoiceId = created.id
|
draft.invoiceId = created.id
|
||||||
await draft.save()
|
await draft.save()
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
organizationId,
|
||||||
|
kind: 'invoice_imported',
|
||||||
|
label: `Facture <b>${created.numero}</b> importée et validée`,
|
||||||
|
meta: { invoiceId: created.id, clientId: client.id },
|
||||||
|
trx,
|
||||||
|
})
|
||||||
|
|
||||||
return created
|
return created
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { Exception } from '@adonisjs/core/exceptions'
|
|||||||
import db from '@adonisjs/lucid/services/db'
|
import db from '@adonisjs/lucid/services/db'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { resolveClient } from '#services/resolve_client'
|
import { resolveClient } from '#services/resolve_client'
|
||||||
|
import { recordActivity } from '#services/activity_recorder'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
@ -333,6 +334,15 @@ export default class InvoicesController {
|
|||||||
.from('organizations')
|
.from('organizations')
|
||||||
.where('id', organizationId)
|
.where('id', organizationId)
|
||||||
.increment('rubis_count', 1)
|
.increment('rubis_count', 1)
|
||||||
|
|
||||||
|
// Journal d'activité (cf. dashboard activity feed).
|
||||||
|
await recordActivity({
|
||||||
|
organizationId,
|
||||||
|
kind: 'invoice_paid',
|
||||||
|
label: `Facture <b>${invoice.numero}</b> marquée encaissée`,
|
||||||
|
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
|
||||||
|
trx,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return response.json({ data: serializeInvoice(invoice) })
|
return response.json({ data: serializeInvoice(invoice) })
|
||||||
|
|||||||
9
apps/api/app/models/activity_event.ts
Normal file
9
apps/api/app/models/activity_event.ts
Normal file
@ -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<typeof Organization>
|
||||||
|
}
|
||||||
40
apps/api/app/services/activity_recorder.ts
Normal file
40
apps/api/app/services/activity_recorder.ts
Normal file
@ -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<string, unknown>
|
||||||
|
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 (<b>) 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<ActivityEvent> {
|
||||||
|
const { organizationId, kind, label, meta = {}, at, trx } = opts
|
||||||
|
return ActivityEvent.create(
|
||||||
|
{
|
||||||
|
organizationId,
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
meta,
|
||||||
|
at: at ?? DateTime.now(),
|
||||||
|
},
|
||||||
|
trx ? { client: trx } : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
150
apps/api/app/services/dashboard.ts
Normal file
150
apps/api/app/services/dashboard.ts
Normal file
@ -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<DashboardKpis> {
|
||||||
|
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<Array<{ clientId: string; name: string; lateInvoicesCount: number }>> {
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@ -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é (<b> 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,6 +7,27 @@
|
|||||||
import { BaseModel, column } from '@adonisjs/lucid/orm'
|
import { BaseModel, column } from '@adonisjs/lucid/orm'
|
||||||
import { DateTime } from 'luxon'
|
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 {
|
export class AuthAccessTokenSchema extends BaseModel {
|
||||||
static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
|
static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
|
||||||
$columns = AuthAccessTokenSchema.$columns
|
$columns = AuthAccessTokenSchema.$columns
|
||||||
|
|||||||
@ -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: {
|
import_drafts: {
|
||||||
columns: {
|
columns: {
|
||||||
status: {
|
status: {
|
||||||
|
|||||||
@ -85,6 +85,19 @@ router
|
|||||||
.as('plans')
|
.as('plans')
|
||||||
.use(middleware.auth())
|
.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
|
* Invoices — auth requise. Ordre IMPORTANT : les routes statiques
|
||||||
* (/upload, /counts, /import-batch/...) sont déclarées AVANT /:id
|
* (/upload, /counts, /import-batch/...) sont déclarées AVANT /:id
|
||||||
|
|||||||
48
bruno/07-Dashboard/01 KPIs.bru
Normal file
48
bruno/07-Dashboard/01 KPIs.bru
Normal file
@ -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
|
||||||
|
}
|
||||||
48
bruno/07-Dashboard/02 Activity.bru
Normal file
48
bruno/07-Dashboard/02 Activity.bru
Normal file
@ -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": "<uuid>",
|
||||||
|
"kind": "...",
|
||||||
|
"at": "ISO 8601",
|
||||||
|
"label": "Texte HTML léger (<b> 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.
|
||||||
|
}
|
||||||
37
bruno/07-Dashboard/03 Top late payers.bru
Normal file
37
bruno/07-Dashboard/03 Top late payers.bru
Normal file
@ -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.
|
||||||
|
}
|
||||||
21
bruno/07-Dashboard/folder.bru
Normal file
21
bruno/07-Dashboard/folder.bru
Normal file
@ -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.).
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user