rubis/apps/api/commands/seed_demo.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

152 lines
5.2 KiB
TypeScript

import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import db from '@adonisjs/lucid/services/db'
import User from '#models/user'
import Organization from '#models/organization'
import Plan from '#models/plan'
import Client from '#models/client'
import Invoice from '#models/invoice'
import ActivityEvent from '#models/activity_event'
import RelanceTask from '#models/relance_task'
import CheckinTask from '#models/checkin_task'
import { provisionDefaultPlans } from '#services/default_plans'
import { seedDemoOrg } from '#database/factories'
/**
* Peuple l'org d'un user existant avec des données de démo réalistes —
* pour visualiser dashboard, factures, plans en conditions réelles.
*
* node ace seed:demo --email arthurbarre.js@gmail.com
* node ace seed:demo --email ... --reset # wipe avant
*/
export default class SeedDemo extends BaseCommand {
static commandName = 'seed:demo'
static description = "Peuple l'organisation d'un user existant avec des données de démo (clients, factures, activité)"
static options: CommandOptions = {
startApp: true,
}
@flags.string({
description: "Email du user dont on peuple l'org",
required: true,
})
declare email: string
@flags.boolean({
description:
"Supprime les clients/factures/activité existants de l'org avant le seed",
default: false,
})
declare reset: boolean
@flags.string({
description:
"Nom à donner à l'organisation (ex. 'Arthur Barré'). Si vide, on garde le nom existant ou fallback.",
})
declare orgName?: string
async run() {
const email = this.email
if (!email) {
this.logger.error('Argument requis : --email <user-email>')
this.exitCode = 1
return
}
const user = await User.findBy('email', String(email).toLowerCase())
if (!user) {
this.logger.error(`User introuvable : ${email}`)
this.exitCode = 1
return
}
this.logger.info(`User trouvé : ${user.fullName ?? user.email} (${user.id})`)
if (!user.organizationId) {
this.logger.error('Le user n\'a pas d\'organization rattachée — flow signup pas terminé ?')
this.exitCode = 1
return
}
await db.transaction(async (trx) => {
const org = await Organization.findOrFail(user.organizationId!, { client: trx })
if (this.reset) {
this.logger.warning('--reset : suppression des clients/factures/activity existants…')
// Ordre : enfants d'abord pour respecter les FK
await CheckinTask.query({ client: trx })
.whereIn(
'invoice_id',
db.from('invoices').where('organization_id', org.id).select('id')
)
.delete()
await RelanceTask.query({ client: trx })
.whereIn(
'invoice_id',
db.from('invoices').where('organization_id', org.id).select('id')
)
.delete()
await ActivityEvent.query({ client: trx }).where('organization_id', org.id).delete()
await Invoice.query({ client: trx }).where('organization_id', org.id).delete()
await Client.query({ client: trx }).where('organization_id', org.id).delete()
}
// Configure l'org : nom (si fourni) + bucket volume mensuel
const targetName =
this.orgName ??
(org.name && org.name.length > 0 ? org.name : `Maison ${user.fullName?.split(' ')[1] ?? 'Démo'}`)
org.useTransaction(trx)
org.name = targetName
if (!org.monthlyVolumeBucket) {
org.monthlyVolumeBucket = '20-50'
}
// Reset rubisCount, on le rechargera en fonction des factures seedées.
org.rubisCount = 0
await org.save()
this.logger.info(`Org configurée : "${org.name}"`)
// Plans : provision si manquants (idempotent)
await provisionDefaultPlans(org.id, trx)
const plans = await Plan.query({ client: trx }).where('organization_id', org.id)
this.logger.info(`Plans disponibles : ${plans.length} (${plans.map((p) => p.name).join(', ')})`)
// Seed la data
const result = await seedDemoOrg({
organizationId: org.id,
plans,
trx,
})
// Met à jour le compteur rubis de l'org en fonction du seed
await trx
.from('organizations')
.where('id', org.id)
.update({ rubis_count: result.rubisEarned })
this.logger.success(
`Seed terminé : ${result.clients.length} clients · ${result.invoices.length} factures · ${result.rubisEarned} rubis`
)
// Petit récap par statut
const byStatus: Record<string, number> = {}
for (const inv of result.invoices) {
byStatus[inv.status] = (byStatus[inv.status] ?? 0) + 1
}
for (const [status, count] of Object.entries(byStatus)) {
this.logger.info(` · ${status} : ${count}`)
}
// User : signature par défaut si vide (utile pour les previews/tests email)
if (!user.signature) {
user.useTransaction(trx)
user.signature = `Cordialement,\n${user.fullName ?? 'L\'équipe'}\n${org.name}`
await user.save()
this.logger.info('Signature email par défaut posée sur le user.')
}
})
this.logger.success('Done.')
}
}