Compare commits

..

No commits in common. "1633fb9bf0047a1afd1a14a1d3f68abe18c29703" and "ea539cd1d48fa7dabe30fa8968c3d0093ccc4917" have entirely different histories.

69 changed files with 235 additions and 5365 deletions

View File

@ -111,30 +111,6 @@ peut se connecter), il faut passer en "In production" (vérification Google
si scopes sensibles ; les nôtres `userinfo.email` + `userinfo.profile` sont
non-sensibles, validation auto).
### Microsoft SSO — setup Azure / Entra ID
1. https://portal.azure.com → **Microsoft Entra ID** → **App registrations**
→ **New registration**
2. **Name** : `Rubis Sur l'Ongle` ; **Supported account types** :
- "Accounts in any organizational directory and personal Microsoft accounts"
(tenant=common, recommandé)
- ou "Accounts in any organizational directory" (tenant=organizations,
M365 strict)
3. **Redirect URI** type **Web** :
- `https://app.rubis.arthurbarre.fr/api/v1/auth/microsoft/callback`
4. Après création : ajouter en plus le redirect dev via **Authentication →
Add a platform → Web** :
- `http://localhost:3333/api/v1/auth/microsoft/callback`
5. **Certificates & secrets → New client secret** : créer, copier la
*Value* (visible une seule fois) → `MICROSOFT_CLIENT_SECRET`
6. Page **Overview** → copier *Application (client) ID*`MICROSOFT_CLIENT_ID`
7. **API permissions** : `User.Read` (déjà délégué par défaut), pas besoin
d'admin consent pour les comptes individuels
8. Mettre les valeurs dans `apps/api/.env` (dev) et `rubis-app-secrets` (prod).
Le client secret expire (Azure force 6 ou 12 mois max) — penser à le
renouveler avant échéance ; sinon les nouvelles connexions échoueront
en silence après expiration.
### Mise à jour
Push git → un (ou les deux) workflow(s) CI se déclenchent selon les paths
modifiés. Build+rollout indépendants.

View File

@ -2,10 +2,7 @@
"permissions": {
"allow": [
"Bash(pnpm -F api typecheck)",
"Bash(pnpm -F @rubis/web typecheck)",
"Bash(rtk grep *)",
"Bash(rtk node *)",
"Bash(rtk pnpm *)"
"Bash(pnpm -F @rubis/web typecheck)"
]
}
}

View File

@ -87,18 +87,4 @@ COOKIE_SECURE=false
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CALLBACK_URL=http://localhost:3333/api/v1/auth/google/callback
#--------------------------------------------------------------------
# Microsoft SSO (Ally) — App registration sur https://portal.azure.com
# (Microsoft Entra ID → App registrations → New registration → Web),
# redirect URIs à enregistrer :
# - http://localhost:3333/api/v1/auth/microsoft/callback (dev)
# - https://app.rubis.arthurbarre.fr/api/v1/auth/microsoft/callback (prod)
# Tenant : 'common' (work + perso), 'organizations' (M365 only) ou un GUID.
#--------------------------------------------------------------------
MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET=
MICROSOFT_TENANT=common
MICROSOFT_CALLBACK_URL=http://localhost:3333/api/v1/auth/microsoft/callback
LIMITER_STORE=redis

View File

@ -1,11 +1,12 @@
import {
emitSsoSessionAndRedirect,
findOrCreateUserFromSso,
nextRouteAfterSso,
} from '#services/sso_session'
import User from '#models/user'
import Organization from '#models/organization'
import { provisionDefaultPlans } from '#services/default_plans'
import { issueRefreshToken } from '#services/refresh_token'
import env from '#start/env'
import db from '@adonisjs/lucid/services/db'
import logger from '@adonisjs/core/services/logger'
import type { HttpContext } from '@adonisjs/core/http'
import crypto from 'node:crypto'
/**
* Google SSO via @adonisjs/ally.
@ -14,20 +15,36 @@ import type { HttpContext } from '@adonisjs/core/http'
* 1. Le SPA navigate vers GET /api/v1/auth/google/redirect
* 2. Ally redirige vers Google avec state + scopes
* 3. Google redirige vers GET /api/v1/auth/google/callback?code=...
* 4. Backend matche/crée l'user (cf. findOrCreateUserFromSso),
* pose le refresh cookie, redirige vers /auth/sso/complete?next=...
* 4. Backend matche/crée l'user, pose le refresh cookie (httpOnly)
* 5. Redirige le browser vers le SPA sur /auth/google/complete?next=...
* 6. Le SPA appelle POST /api/v1/auth/refresh (cookie auto-envoyé) reçoit
* un access token, navigue vers `next`.
*
* On NE retourne PAS d'access token en JSON ici car la callback est un
* redirect server-side : pas de body lisible par le SPA.
*/
export default class AuthGoogleController {
/** GET /api/v1/auth/google/redirect — entrée du flow OAuth. */
/**
* GET /api/v1/auth/google/redirect entrée du flow OAuth.
* Le bouton "Continuer avec Google" pointe directement ici (pas un fetch).
*/
async redirect(ctx: HttpContext) {
return ctx.ally.use('google').redirect()
}
/** GET /api/v1/auth/google/callback — retour de Google. */
/**
* GET /api/v1/auth/google/callback retour de Google.
*
* Stratégie de matching :
* 1. Existing user with `google_id` ? log in
* 2. Existing user with same `email` ? link `google_id` + log in
* 3. New user crée org + plans par défaut + user (password null)
*/
async callback(ctx: HttpContext) {
const google = ctx.ally.use('google')
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
// Erreurs côté Google (canceled, mismatch, etc.)
if (google.accessDenied()) {
return ctx.response.redirect(`${webUrl}/login?google=denied`)
}
@ -45,14 +62,67 @@ export default class AuthGoogleController {
return ctx.response.redirect(`${webUrl}/login?google=no_email`)
}
const { user, isNewUser } = await findOrCreateUserFromSso({
provider: 'google',
providerId: googleUser.id,
email: googleUser.email,
fullName: googleUser.name ?? null,
})
// 1. Lookup par google_id (canonique)
let user = await User.findBy('googleId', googleUser.id)
let isNewUser = false
const next = await nextRouteAfterSso(user, isNewUser)
return emitSsoSessionAndRedirect(ctx, user, next, webUrl)
// 2. Fallback email — première connexion d'un user email/password
// via Google, on lie son google_id à son compte existant.
if (!user) {
user = await User.findBy('email', googleUser.email.toLowerCase())
if (user) {
user.googleId = googleUser.id
if (!user.fullName && googleUser.name) {
user.fullName = googleUser.name
}
await user.save()
}
}
// 3. Création
if (!user) {
isNewUser = true
user = await db.transaction(async (trx) => {
const org = await Organization.create({ name: '' }, { client: trx })
await provisionDefaultPlans(org.id, trx)
return User.create(
{
email: googleUser.email!.toLowerCase(),
fullName: googleUser.name ?? null,
// password requis dans le schéma Lucid (string), mais nullable
// en DB. On stocke un random unguessable que personne ne peut
// utiliser pour login email/password (User.verifyCredentials
// hash-comparera et échouera). Le user pourra plus tard
// activer email/password via "mot de passe oublié".
password: crypto.randomBytes(48).toString('base64url'),
googleId: googleUser.id,
organizationId: org.id,
},
{ client: trx }
)
})
}
// Pose le refresh cookie httpOnly (path /api/v1/auth)
await issueRefreshToken(user, ctx)
// Décide où renvoyer l'utilisateur.
// Org name vide = onboarding entreprise pas terminé → on saute "compte"
// (Google nous a déjà donné nom + email) et on enchaîne sur entreprise.
let next = '/'
if (isNewUser) {
next = '/onboarding/entreprise'
} else {
const org = user.organizationId
? await Organization.find(user.organizationId)
: null
if (!org || !org.name) {
next = '/onboarding/entreprise'
} else if (!user.signature) {
next = '/onboarding/signature'
}
}
return ctx.response.redirect(`${webUrl}/auth/google/complete?next=${encodeURIComponent(next)}`)
}
}

View File

@ -1,55 +0,0 @@
import {
emitSsoSessionAndRedirect,
findOrCreateUserFromSso,
nextRouteAfterSso,
} from '#services/sso_session'
import env from '#start/env'
import logger from '@adonisjs/core/services/logger'
import type { HttpContext } from '@adonisjs/core/http'
/**
* Microsoft 365 SSO via le custom Ally driver (services/ally/microsoft_driver.ts).
*
* Flow identique à Google (cf. AuthGoogleController). Le tenant utilisé
* est défini dans config/ally.ts (par défaut `common` : couvre les comptes
* work/school + comptes personnels Microsoft).
*/
export default class AuthMicrosoftController {
/** GET /api/v1/auth/microsoft/redirect — entrée OAuth. */
async redirect(ctx: HttpContext) {
return ctx.ally.use('microsoft').redirect()
}
/** GET /api/v1/auth/microsoft/callback — retour de Microsoft. */
async callback(ctx: HttpContext) {
const microsoft = ctx.ally.use('microsoft')
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
if (microsoft.accessDenied()) {
return ctx.response.redirect(`${webUrl}/login?microsoft=denied`)
}
if (microsoft.stateMisMatch()) {
return ctx.response.redirect(`${webUrl}/login?microsoft=state_mismatch`)
}
if (microsoft.hasError()) {
logger.warn({ err: microsoft.getError() }, 'microsoft sso error')
return ctx.response.redirect(`${webUrl}/login?microsoft=error`)
}
const msUser = await microsoft.user()
if (!msUser.email) {
logger.warn({ id: msUser.id }, 'microsoft sso : email manquant')
return ctx.response.redirect(`${webUrl}/login?microsoft=no_email`)
}
const { user, isNewUser } = await findOrCreateUserFromSso({
provider: 'microsoft',
providerId: msUser.id,
email: msUser.email,
fullName: msUser.name ?? null,
})
const next = await nextRouteAfterSso(user, isNewUser)
return emitSsoSessionAndRedirect(ctx, user, next, webUrl)
}
}

View File

@ -3,9 +3,9 @@ import Invoice from '#models/invoice'
import { hashCheckinToken } from '#services/checkin_token'
import { recordActivity } from '#services/activity_recorder'
import { cancelFutureRelances, scheduleRelancesForInvoice } from '#services/relance_scheduler'
import * as clock from '#services/clock'
import db from '@adonisjs/lucid/services/db'
import env from '#start/env'
import { DateTime } from 'luxon'
import type { HttpContext } from '@adonisjs/core/http'
const CHECKIN_TTL_HOURS = 24
@ -45,7 +45,7 @@ async function resolveCheckin(token: string): Promise<ResolvedTask> {
// Expiration : 24h après l'envoi (sentAt). Tant qu'elle n'a pas été
// envoyée, le link n'est pas censé exister côté user — sécurité belt.
if (task.sentAt && task.sentAt.plus({ hours: CHECKIN_TTL_HOURS }) < (await clock.now(task.organizationId))) {
if (task.sentAt && task.sentAt.plus({ hours: CHECKIN_TTL_HOURS }) < DateTime.now()) {
task.status = 'expired'
await task.save()
return { redirect: spaRedirectUrl('expired') }
@ -77,18 +77,17 @@ export default class CheckinController {
const { task, invoice } = result
await db.transaction(async (trx) => {
const nowOrg = await clock.now(invoice.organizationId)
task.useTransaction(trx)
task.status = 'answered'
task.answer = 'paid'
task.answeredAt = nowOrg
task.answeredAt = DateTime.now()
await task.save()
// Mark paid (mêmes effets que POST /invoices/:id/mark-paid).
if (invoice.status !== 'paid') {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = nowOrg
invoice.paidAt = DateTime.now()
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()
@ -100,7 +99,7 @@ export default class CheckinController {
await recordActivity({
organizationId: invoice.organizationId,
kind: 'invoice_paid',
label: `Facture <b>${invoice.numero}</b> marquée encaissée via confirmation`,
label: `Facture <b>${invoice.numero}</b> marquée encaissée via check-in`,
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
trx,
})
@ -134,7 +133,7 @@ export default class CheckinController {
task.useTransaction(trx)
task.status = 'answered'
task.answer = 'still_pending'
task.answeredAt = await clock.now(invoice.organizationId)
task.answeredAt = DateTime.now()
await task.save()
})

View File

@ -1,27 +1,9 @@
import Client from '#models/client'
import Invoice from '#models/invoice'
import ClientTransformer from '#transformers/client_transformer'
import InvoiceTransformer from '#transformers/invoice_transformer'
import { createClientValidator, updateClientValidator } from '#validators/client'
import { bulkComputeClientStats } from '#services/client_stats'
import { computeClientTimeseries, 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(),
})
// Priorité d'affichage : ce qui est actionnable en haut.
const INVOICE_STATUS_PRIORITY: Record<string, number> = {
awaiting_user_confirmation: 0,
in_relance: 1,
pending: 2,
litigation: 3,
paid: 4,
cancelled: 5,
}
/**
* Petite cohérence d'identification orgnisation : si l'utilisateur
@ -114,27 +96,11 @@ export default class ClientsController {
const statsMap = await bulkComputeClientStats(organizationId, [client.id])
const stats = statsMap.get(client.id)!
// Factures du client — actionnables en premier, puis échéance asc.
const invoices = await Invoice.query()
.where('organization_id', organizationId)
.where('client_id', client.id)
.preload('client')
.preload('plan')
.exec()
invoices.sort((a, b) => {
const dp =
(INVOICE_STATUS_PRIORITY[a.status] ?? 99) -
(INVOICE_STATUS_PRIORITY[b.status] ?? 99)
if (dp !== 0) return dp
return a.dueDate.toMillis() - b.dueDate.toMillis()
})
return response.json({
data: {
...serializeClient(client),
...stats,
invoices: invoices.map((inv) => new InvoiceTransformer(inv).toObject()),
invoices: [], // TODO: brancher quand le domaine Invoice arrive
},
})
}
@ -182,34 +148,6 @@ export default class ClientsController {
return response.status(201).json({ data: serializeClient(created) })
}
/**
* GET /clients/:id/timeseries?range=6 encaissé mensuel pour ce client.
* Utilisé par le mini-chart sur la fiche client (santé du compte).
*/
async timeseries({ auth, request, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const exists = await Client.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!exists) {
throw new Exception('Client introuvable', { status: 404, code: 'not_found' })
}
const { range } = await request.validateUsing(timeseriesValidator, {
data: {
range: request.input('range') ? Number(request.input('range')) : undefined,
},
})
const data = await computeClientTimeseries(
organizationId,
params.id,
(range ?? 6) as RangeMonths
)
return response.json({ data })
}
/**
* PATCH /clients/:id édition partielle.
*/

View File

@ -1,17 +1,7 @@
import ActivityEvent from '#models/activity_event'
import {
computeKpis,
computeTimeseries,
topLatePayers,
type RangeMonths,
} from '#services/dashboard'
import { computeKpis, topLatePayers } 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
@ -72,21 +62,4 @@ export default class DashboardController {
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 })
}
}

View File

@ -1,204 +0,0 @@
import { DateTime } from 'luxon'
import vine from '@vinejs/vine'
import { Exception } from '@adonisjs/core/exceptions'
import type { HttpContext } from '@adonisjs/core/http'
import db from '@adonisjs/lucid/services/db'
import Organization from '#models/organization'
import DemoCapturedEmail from '#models/demo_captured_email'
import RelanceTask from '#models/relance_task'
import CheckinTask from '#models/checkin_task'
import * as clock from '#services/clock'
import { tickAndDispatch } from '#services/demo/dispatch'
const tickValidator = vine.create({
/** ISO 8601 — la cible vers laquelle avancer. */
virtualNow: vine.string(),
})
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
}
async function requireDemoOrg(orgId: string): Promise<Organization> {
const org = await Organization.findOrFail(orgId)
if (!org.demoMode) {
throw new Exception('Mode démo désactivé pour cette organisation', {
status: 403,
code: 'demo_disabled',
})
}
return org
}
/**
* Endpoints du mode démo.
*
* Garde-fou de sécurité : tous les endpoints sauf `/start` et `/end`
* vérifient `org.demoMode = true` avant d'opérer. La prod ne peut donc
* jamais se retrouver à exécuter du code démo par accident, même si
* un endpoint était appelé par erreur.
*/
export default class DemoController {
/**
* POST /api/v1/demo/start
*
* Active le mode démo sur l'org du user. virtual_now = maintenant.
* Wipe les emails capturés précédents (reset propre).
*/
async start({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
await db.transaction(async (trx) => {
const org = await Organization.findOrFail(orgId, { client: trx })
org.useTransaction(trx)
org.demoMode = true
org.virtualNow = DateTime.utc()
await org.save()
// Reset l'inbox démo précédente
await DemoCapturedEmail.query({ client: trx }).where('organization_id', orgId).delete()
})
// Force un reload du cache clock
await clock.setVirtualNow(orgId, DateTime.utc())
const org = await Organization.findOrFail(orgId)
return response.json({
data: {
demoMode: org.demoMode,
virtualNow: org.virtualNow?.toISO(),
speedFactor: org.demoSpeedFactor,
},
})
}
/**
* POST /api/v1/demo/end
*
* Désactive le mode démo. virtual_now=null clock.now() retombe sur
* Date.now() à la prochaine lecture.
*/
async end({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
await clock.setDemoMode(orgId, false)
return response.json({ data: { demoMode: false } })
}
/**
* POST /api/v1/demo/tick { virtualNow: ISO }
*
* Avance virtual_now à la cible et fire les tasks dues entre l'ancien
* et le nouveau virtual_now. Retourne les events déclenchés.
*
* Le client appelle cet endpoint depuis sa boucle rAF (typiquement
* toutes les 250ms quand il joue, avec virtualNow incrémenté de
* `speed * elapsed`).
*/
async tick({ auth, request, response }: HttpContext) {
const orgId = requireOrgId(auth)
await requireDemoOrg(orgId)
const { virtualNow } = await request.validateUsing(tickValidator)
const target = DateTime.fromISO(virtualNow, { zone: 'utc' })
if (!target.isValid) {
throw new Exception('virtualNow ISO invalide', { status: 422, code: 'invalid_iso' })
}
const fired = await tickAndDispatch(orgId, target)
return response.json({
data: {
virtualNow: target.toISO(),
firedEvents: fired,
},
})
}
/**
* GET /api/v1/demo/inbox
*
* Liste des emails capturés pour l'org démo, plus récent en tête.
* Le SPA lit cet endpoint pour la slide-over "boîte de réception".
*/
async inbox({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
await requireDemoOrg(orgId)
const emails = await DemoCapturedEmail.query()
.where('organization_id', orgId)
.orderBy('sent_at', 'desc')
.limit(50)
return response.json({
data: emails.map((e) => ({
id: e.id,
kind: e.kind,
from: { email: e.fromEmail, name: e.fromName },
to: { email: e.toEmail, name: e.toName },
replyTo: e.replyTo,
subject: e.subject,
body: e.body,
meta: e.meta,
sentAt: e.sentAt.toISO(),
})),
})
}
/**
* GET /api/v1/demo/state
*
* État courant : virtualNow, prochaine task à fire (pour que le SPA
* sache quand auto-pause), nombre d'emails en inbox.
* Lu au mount du SPA pour reprendre une démo en cours.
*/
async state({ auth, response }: HttpContext) {
const orgId = requireOrgId(auth)
const org = await Organization.findOrFail(orgId)
if (!org.demoMode) {
return response.json({ data: { demoMode: false } })
}
// Prochaine task scheduled — relance ou checkin, la plus proche.
const nextRelance = await RelanceTask.query()
.where('organization_id', orgId)
.where('status', 'scheduled')
.orderBy('send_at', 'asc')
.first()
const nextCheckin = await CheckinTask.query()
.where('organization_id', orgId)
.where('status', 'scheduled')
.orderBy('send_at', 'asc')
.first()
let nextEventAt: string | null = null
if (nextRelance && nextCheckin) {
nextEventAt =
nextRelance.sendAt < nextCheckin.sendAt
? nextRelance.sendAt.toISO()
: nextCheckin.sendAt.toISO()
} else if (nextRelance) {
nextEventAt = nextRelance.sendAt.toISO()
} else if (nextCheckin) {
nextEventAt = nextCheckin.sendAt.toISO()
}
const inboxCount = await DemoCapturedEmail.query()
.where('organization_id', orgId)
.count('* as total')
.first()
return response.json({
data: {
demoMode: true,
virtualNow: org.virtualNow?.toISO(),
speedFactor: org.demoSpeedFactor,
nextEventAt,
inboxCount: Number((inboxCount as unknown as { $extras: { total: string } })?.$extras?.total ?? 0),
},
})
}
}

View File

@ -120,48 +120,6 @@ export default class ImportBatchesController {
return response.json({ data: serializeBatch(batch) })
}
/**
* GET /invoices/import-batch/:id/drafts/:draftId/pdf
*
* Stream le PDF stocké dans MinIO. Auth via Bearer (le SPA fetch via
* api.ts puis crée un objectURL on ne peut pas mettre Authorization
* sur un <iframe src>). On vérifie l'ownership de l'org en chargeant
* le batch (loadBatchOrFail) puis on cherche le draft dedans.
*/
async draftPdf({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const batch = await loadBatchOrFail(organizationId, params.id)
const draft = batch.drafts.find((d) => d.id === params.draftId)
if (!draft) {
throw new Exception('Draft introuvable', { status: 404, code: 'not_found' })
}
if (!draft.pdfStorageKey) {
throw new Exception('Aucun PDF stocké pour ce draft', {
status: 404,
code: 'pdf_not_available',
})
}
const ext = (draft.filename.split('.').pop() ?? '').toLowerCase()
const contentType =
ext === 'pdf'
? 'application/pdf'
: ext === 'png'
? 'image/png'
: ext === 'jpg' || ext === 'jpeg'
? 'image/jpeg'
: 'application/octet-stream'
const buffer = Buffer.from(await drive.use().getArrayBuffer(draft.pdfStorageKey))
response.header('Content-Type', contentType)
response.header('Cache-Control', 'private, max-age=300')
// Inline = visualisable dans un <iframe>/<embed> sans télécharger.
response.header(
'Content-Disposition',
`inline; filename="${encodeURIComponent(draft.filename)}"`
)
return response.send(buffer)
}
/**
* POST /invoices/import-batch/:id/drafts/:draftId/validate
*

View File

@ -12,8 +12,6 @@ import { recordActivity } from '#services/activity_recorder'
import { cancelFutureRelances } from '#services/relance_scheduler'
import { scheduleCheckinForInvoice, cancelCheckinForInvoice } from '#services/checkin_scheduler'
import logger from '@adonisjs/core/services/logger'
import * as clock from '#services/clock'
import drive from '@adonisjs/drive/services/main'
const PAGE_SIZE = 50
@ -49,9 +47,7 @@ function serializeInvoice(i: Invoice) {
*/
function buildTimeline(
invoice: Invoice,
relanceTasks: RelanceTask[] = [],
// `now` injecté par le caller — orgs en mode démo lisent depuis virtualNow.
now: DateTime = DateTime.utc()
relanceTasks: RelanceTask[] = []
): Array<{
id: string
state: 'past' | 'current' | 'future'
@ -74,7 +70,7 @@ function buildTimeline(
if (invoice.plan?.steps?.length && invoice.status !== 'paid' && invoice.status !== 'cancelled') {
const dueMs = invoice.dueDate.toMillis()
const nowMs = now.toMillis()
const nowMs = DateTime.now().toMillis()
const taskByStepId = new Map(relanceTasks.map((task) => [task.planStepId, task]))
let currentSet = false
@ -96,16 +92,13 @@ function buildTimeline(
} else state = 'future'
const subject = step.subject.replace('{{numero}}', invoice.numero)
// Wording uniforme et rassurant : aucune relance ne part sans que l'user
// confirme l'impayé. On évite "programmé" tout court qui sonne comme
// "ça va partir tout seul".
const what = task
? task.status === 'sent'
? `Envoyée après votre confirmation · "${subject}"`
: task.status === 'cancelled'
? `Annulée — facture encaissée · "${subject}"`
: `Confirmation avant envoi · "${subject}"`
: `Confirmation avant envoi · "${subject}"`
? `Email envoyé · "${subject}"`
: `Email programmé · "${subject}"`
: invoice.status === 'pending'
? `À programmer après check-in · "${subject}"`
: `Relance non programmée · "${subject}"`
events.push({
id: `${invoice.id}__step_${step.order}`,
@ -264,7 +257,7 @@ export default class InvoicesController {
requiresManualValidation: s.requiresManualValidation,
})),
},
timeline: buildTimeline(invoice, relanceTasks, await clock.now(invoice.organizationId)),
timeline: buildTimeline(invoice, relanceTasks),
},
})
}
@ -329,51 +322,6 @@ export default class InvoicesController {
return response.status(201).json({ data: serializeInvoice(invoice) })
}
/**
* GET /invoices/:id/pdf stream le PDF/image originel de la facture.
*
* Source : `pdfStorageKey` propagé depuis le draft d'import lors de la
* validation. 404 si la facture n'a pas de fichier (saisie manuelle).
* Auth : Bearer (vérifié sur l'org). Le SPA fetch via api.fetchBlob
* puis affiche dans un <iframe>/<img> via objectURL.
*/
async pdf({ auth, params, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const invoice = await Invoice.query()
.where('organization_id', organizationId)
.where('id', params.id)
.first()
if (!invoice) {
throw new Exception('Facture introuvable', { status: 404, code: 'not_found' })
}
if (!invoice.pdfStorageKey) {
throw new Exception('Aucun PDF stocké pour cette facture', {
status: 404,
code: 'pdf_not_available',
})
}
const ext = (invoice.pdfStorageKey.split('.').pop() ?? '').toLowerCase()
const contentType =
ext === 'pdf'
? 'application/pdf'
: ext === 'png'
? 'image/png'
: ext === 'jpg' || ext === 'jpeg'
? 'image/jpeg'
: 'application/octet-stream'
const buffer = Buffer.from(await drive.use().getArrayBuffer(invoice.pdfStorageKey))
response.header('Content-Type', contentType)
response.header('Cache-Control', 'private, max-age=300')
response.header(
'Content-Disposition',
`inline; filename="${invoice.numero}.${ext || 'pdf'}"`
)
return response.send(buffer)
}
/**
* POST /invoices/:id/mark-paid
* Marque encaissée + bonus +1 rubis (à la fois sur invoice.rubisEarned
@ -400,7 +348,7 @@ export default class InvoicesController {
await db.transaction(async (trx) => {
invoice.useTransaction(trx)
invoice.status = 'paid'
invoice.paidAt = await clock.now(invoice.organizationId)
invoice.paidAt = DateTime.now()
invoice.rubisEarned = invoice.rubisEarned + 1
await invoice.save()

View File

@ -2,8 +2,8 @@ import CheckinTask from '#models/checkin_task'
import Invoice from '#models/invoice'
import User from '#models/user'
import { sendCheckinEmail } from '#services/mail_dispatcher'
import * as clock from '#services/clock'
import env from '#start/env'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
/**
@ -64,6 +64,6 @@ export async function sendCheckinJob(jobData: { taskId: string; plain: string })
})
task.status = 'sent'
task.sentAt = await clock.now(invoice.organizationId)
task.sentAt = DateTime.now()
await task.save()
}

View File

@ -3,8 +3,8 @@ import Invoice from '#models/invoice'
import User from '#models/user'
import { sendRelanceEmail } from '#services/mail_dispatcher'
import { recordActivity } from '#services/activity_recorder'
import * as clock from '#services/clock'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
/**
@ -60,7 +60,7 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
await db.transaction(async (trx) => {
task.useTransaction(trx)
task.status = 'sent' // On considère la task "traitée" — le brouillon est l'output
task.sentAt = await clock.now(invoice.organizationId)
task.sentAt = DateTime.now()
await task.save()
await recordActivity({
@ -87,11 +87,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
organization: invoice.organization,
})
const sentAt = await clock.now(invoice.organizationId)
await db.transaction(async (trx) => {
task.useTransaction(trx)
task.status = 'sent'
task.sentAt = sentAt
task.sentAt = DateTime.now()
await task.save()
invoice.useTransaction(trx)

View File

@ -1,9 +0,0 @@
import { DemoCapturedEmailSchema } 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 DemoCapturedEmail extends DemoCapturedEmailSchema {
@belongsTo(() => Organization)
declare organization: BelongsTo<typeof Organization>
}

View File

@ -1,6 +1,5 @@
import { DateTime } from 'luxon'
import ActivityEvent from '#models/activity_event'
import * as clock from '#services/clock'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
type EventKind = 'relance_sent' | 'invoice_paid' | 'invoice_imported' | 'warning_drafted'
@ -34,7 +33,7 @@ export async function recordActivity(opts: RecordOpts): Promise<ActivityEvent> {
kind,
label,
meta,
at: at ?? (await clock.now(organizationId)),
at: at ?? DateTime.now(),
},
trx ? { client: trx } : undefined
)

View File

@ -1,163 +0,0 @@
import { Oauth2Driver } from '@adonisjs/ally'
import type { HttpContext } from '@adonisjs/core/http'
import type {
AllyDriverContract,
AllyUserContract,
ApiRequestContract,
Oauth2DriverConfig,
RedirectRequestContract,
} from '@adonisjs/ally/types'
/**
* Driver Ally Microsoft Identity (v2.0). Couvre Microsoft 365 (work/school)
* et les comptes personnels (Outlook, Hotmail, Live) choix du tenant
* via la prop `tenant` (défaut: `common`).
*
* Endpoints :
* - Authorize : https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize
* - Token : https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
* - User info : https://graph.microsoft.com/v1.0/me (retourne id, displayName,
* mail, userPrincipalName, givenName, surname)
*
* Tenants possibles :
* - `common` : tout (work/school + personal). Notre défaut.
* - `organizations` : work/school uniquement (Microsoft 365 strict)
* - `consumers` : personal uniquement
* - `<tenant-id>` : un tenant Azure AD spécifique
*/
export type MicrosoftDriverAccessToken = {
token: string
type: 'bearer'
refreshToken?: string
expiresIn?: number
expiresAt?: Date
idToken?: string
}
export type MicrosoftDriverScopes =
| 'openid'
| 'profile'
| 'email'
| 'offline_access'
| 'User.Read'
| 'User.ReadBasic.All'
export type MicrosoftDriverConfig = Oauth2DriverConfig & {
scopes?: MicrosoftDriverScopes[]
/** common | organizations | consumers | <tenant-id>. Défaut: common. */
tenant?: string
/** Force l'écran de sélection de compte. Pratique en dev. */
prompt?: 'login' | 'none' | 'consent' | 'select_account'
}
type MicrosoftGraphMeResponse = {
id: string
displayName: string | null
givenName: string | null
surname: string | null
mail: string | null
userPrincipalName: string | null
}
export class MicrosoftDriver
extends Oauth2Driver<MicrosoftDriverAccessToken, MicrosoftDriverScopes>
implements AllyDriverContract<MicrosoftDriverAccessToken, MicrosoftDriverScopes>
{
protected accessTokenUrl: string
protected authorizeUrl: string
protected userInfoUrl = 'https://graph.microsoft.com/v1.0/me'
protected codeParamName = 'code'
protected errorParamName = 'error'
protected stateCookieName = 'microsoft_oauth_state'
protected stateParamName = 'state'
protected scopeParamName = 'scope'
protected scopesSeparator = ' '
constructor(
ctx: HttpContext,
public config: MicrosoftDriverConfig
) {
super(ctx, config)
const tenant = config.tenant || 'common'
this.accessTokenUrl = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`
this.authorizeUrl = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`
this.loadState()
}
protected configureRedirectRequest(request: RedirectRequestContract<MicrosoftDriverScopes>) {
request.scopes(this.config.scopes || ['openid', 'profile', 'email', 'User.Read'])
request.param('response_type', 'code')
request.param('response_mode', 'query')
if (this.config.prompt) {
request.param('prompt', this.config.prompt)
}
}
/** access_denied / consent_required / interaction_required → l'user a annulé. */
accessDenied(): boolean {
const error = this.getError()
if (!error) return false
return ['access_denied', 'consent_required', 'interaction_required'].includes(error)
}
protected getAuthenticatedRequest(url: string, token: string) {
const request = this.httpClient(url)
request.header('Authorization', `Bearer ${token}`)
request.header('Accept', 'application/json')
request.parseAs('json')
return request
}
/**
* GET /me sur Microsoft Graph. `mail` peut être null pour des comptes
* perso fallback sur `userPrincipalName` (qui est toujours rempli et
* ressemble à un email).
*/
protected async getUserInfo(
token: string,
callback?: (request: ApiRequestContract) => void
): Promise<Omit<AllyUserContract<MicrosoftDriverAccessToken>, 'token'>> {
const request = this.getAuthenticatedRequest(this.userInfoUrl, token)
if (typeof callback === 'function') callback(request)
const body = (await request.get()) as MicrosoftGraphMeResponse
const email = body.mail ?? body.userPrincipalName
return {
id: body.id,
nickName: body.displayName ?? body.givenName ?? email ?? body.id,
name: body.displayName ?? [body.givenName, body.surname].filter(Boolean).join(' ') ?? '',
email: email,
// Microsoft Graph ne renvoie pas explicitement un état de vérification.
// On considère l'email vérifié (Microsoft contrôle ses propres domaines).
emailVerificationState: 'verified' as const,
avatarUrl: null,
original: body,
}
}
async user(
callback?: (request: ApiRequestContract) => void
): Promise<AllyUserContract<MicrosoftDriverAccessToken>> {
const token = await this.accessToken(callback)
const user = await this.getUserInfo(token.token, callback)
return { ...user, token }
}
async userFromToken(
token: string,
callback?: (request: ApiRequestContract) => void
): Promise<AllyUserContract<{ token: string; type: 'bearer' }>> {
const user = await this.getUserInfo(token, callback)
return { ...user, token: { token, type: 'bearer' as const } }
}
}
/**
* Helper de config (équivalent de `services.google()` mais pour Microsoft).
* On l'enregistre dans `config/ally.ts`.
*/
export function microsoftService(config: MicrosoftDriverConfig) {
return (ctx: HttpContext) => new MicrosoftDriver(ctx, config)
}

View File

@ -1,8 +1,8 @@
import { DateTime } from 'luxon'
import CheckinTask from '#models/checkin_task'
import Invoice from '#models/invoice'
import { getQueue } from '#services/queue'
import { generateCheckinToken } from '#services/checkin_token'
import * as clock from '#services/clock'
import app from '@adonisjs/core/services/app'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
@ -47,7 +47,7 @@ export async function scheduleCheckinForInvoice(
await t.save()
}
const now = await clock.now(invoice.organizationId)
const now = DateTime.now()
const sendAtRaw = invoice.dueDate
const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw

View File

@ -1,111 +0,0 @@
import { DateTime } from 'luxon'
import Organization from '#models/organization'
/**
* Clock abstraction time-sensitive de l'app.
*
* En prod (`org.demoMode = false`, ou pas d'orgId fourni), retourne
* `DateTime.utc()`. C'est l'API à utiliser **par défaut** dans tous les
* services qui font des comparaisons de dates (relance scheduler,
* checkin scheduler, dashboard KPIs, jobs BullMQ).
*
* En mode démo (`org.demoMode = true` ET `org.virtualNow != null`),
* retourne le `virtualNow` stocké sur l'org. C'est ce qui permet
* "d'avancer dans le temps" pendant une démo sans toucher au système.
*
* **Garde-fou prod** : si `orgId` est absent, on retourne toujours
* l'horloge système on ne fait JAMAIS un side-effect demoMode si
* on n'a pas explicitement le contexte d'une org.
*
* Cette abstraction sert aussi aux tests (on pourra mock plus tard
* via un singleton injectable, V2). En V1, on lit la DB à chaque
* appel : c'est fonctionnel, et le cache n'a pas de sens vu que la
* valeur peut bouger pendant une démo.
*
* Cache : on prend un cache mémoire très court (250ms) pour ne pas
* spammer la DB quand un même handler appelle clock.now() plusieurs
* fois. Invalidé à chaque écriture (cf. setVirtualNow).
*/
type CachedClock = {
demoMode: boolean
virtualNow: DateTime | null
fetchedAt: number
}
const CACHE_TTL_MS = 250
const cache = new Map<string, CachedClock>()
async function loadOrg(orgId: string): Promise<CachedClock> {
const cached = cache.get(orgId)
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) return cached
const org = await Organization.find(orgId)
const entry: CachedClock = {
demoMode: org?.demoMode ?? false,
virtualNow: org?.virtualNow ?? null,
fetchedAt: Date.now(),
}
cache.set(orgId, entry)
return entry
}
/**
* Heure courante. Async pour permettre la lecture DB en mode démo.
*
* const now = await clock.now(invoice.organizationId)
*
* Si `orgId` est `null` / `undefined`, retourne `DateTime.utc()`
* synchronement-compatible (pas de side-effect démo).
*/
export async function now(orgId?: string | null): Promise<DateTime> {
if (!orgId) return DateTime.utc()
const c = await loadOrg(orgId)
if (c.demoMode && c.virtualNow) return c.virtualNow
return DateTime.utc()
}
/**
* Variante sync utile dans des chemins on n'a pas d'orgId
* (création de tokens auth par exemple). Comportement strictement
* identique à `DateTime.utc()`. Existe pour faciliter une migration
* incrémentale : remplacer `DateTime.utc()` ou `DateTime.now()` par
* `clockSync()` rend explicite que c'est une horloge système.
*/
export function nowSync(): DateTime {
return DateTime.utc()
}
/**
* Met à jour `virtual_now` sur une org (utilisé par /demo/tick).
* Invalide le cache pour que le prochain `now(orgId)` renvoie la
* nouvelle valeur sans attendre le TTL.
*/
export async function setVirtualNow(orgId: string, virtualNow: DateTime): Promise<void> {
const org = await Organization.findOrFail(orgId)
org.virtualNow = virtualNow
await org.save()
cache.delete(orgId)
}
/**
* Active/désactive le mode démo. Côté `start`, on initialise
* `virtualNow` à maintenant (UTC) si pas déjà set.
*/
export async function setDemoMode(orgId: string, enabled: boolean): Promise<void> {
const org = await Organization.findOrFail(orgId)
org.demoMode = enabled
if (enabled && !org.virtualNow) {
org.virtualNow = DateTime.utc()
}
if (!enabled) {
org.virtualNow = null
}
await org.save()
cache.delete(orgId)
}
/** Invalidation manuelle (tests). */
export function clearClockCache(): void {
cache.clear()
}

View File

@ -1,7 +1,6 @@
import db from '@adonisjs/lucid/services/db'
import Organization from '#models/organization'
import { DateTime } from 'luxon'
import * as clock from '#services/clock'
const ACTIVE_INVOICE_STATUSES = "('pending','in_relance','awaiting_user_confirmation')"
@ -39,7 +38,7 @@ function startOfDay(d: DateTime): Date {
* Le contrat reste stable côté SPA.
*/
export async function computeKpis(organizationId: string): Promise<DashboardKpis> {
const now = await clock.now(organizationId)
const now = DateTime.now()
const monthStart = startOfMonth(now)
const todayStart = startOfDay(now)
const prevMonthStart = startOfMonth(now.minus({ months: 1 }))
@ -129,7 +128,7 @@ export async function topLatePayers(
organizationId: string,
limit = 5
): Promise<Array<{ clientId: string; name: string; lateInvoicesCount: number }>> {
const today = startOfDay(await clock.now(organizationId))
const today = startOfDay(DateTime.now())
const rows = await db
.from('invoices')
@ -149,141 +148,3 @@ export async function topLatePayers(
lateInvoicesCount: r.late_invoices_count,
}))
}
// ===========================================================================
// Time series — pour les graphes du dashboard et de /insights
// ===========================================================================
export type RangeMonths = 3 | 6 | 12
export type PaidByMonthPoint = {
/** Premier jour du mois en ISO date "YYYY-MM-01". */
month: string
/** Total encaissé sur le mois (centimes). */
encaisseCents: number
/** Nombre de factures payées sur le mois. */
paidCount: number
/** DSO moyen sur le mois (jours, 0 si aucun paiement). */
dsoDays: number
}
export type PipelineSlice = {
status: 'pending' | 'awaiting_user_confirmation' | 'in_relance' | 'litigation' | 'paid'
count: number
amountCents: number
}
export type DashboardTimeseries = {
range: RangeMonths
paidByMonth: PaidByMonthPoint[]
pipelineByStatus: PipelineSlice[]
}
/**
* Calcule les séries temporelles pour le dashboard / insights.
*
* `paidByMonth` : N derniers mois (range), 1 ligne par mois même si vide
* (sinon les charts affichent des "trous").
*
* `pipelineByStatus` : breakdown du portefeuille (count + montant) pour
* donut/stacked-bar. Cancelled exclus pour réduire le bruit.
*/
export async function computeTimeseries(
organizationId: string,
range: RangeMonths = 6
): Promise<DashboardTimeseries> {
const paidByMonth = await fetchPaidByMonth({ organizationId, range })
const pipelineRows = (await db
.from('invoices')
.where('organization_id', organizationId)
.select('status')
.select(db.raw('count(*)::int as count'))
.select(db.raw('coalesce(sum(amount_ttc_cents), 0)::int as amount_cents'))
.groupBy('status')) as Array<{
status: PipelineSlice['status'] | 'cancelled'
count: number
amount_cents: number
}>
const pipelineOrder: PipelineSlice['status'][] = [
'pending',
'awaiting_user_confirmation',
'in_relance',
'litigation',
'paid',
]
const pipelineMap = new Map(pipelineRows.map((r) => [r.status, r]))
const pipelineByStatus: PipelineSlice[] = pipelineOrder.map((status) => {
const r = pipelineMap.get(status)
return { status, count: r?.count ?? 0, amountCents: r?.amount_cents ?? 0 }
})
return { range, paidByMonth, pipelineByStatus }
}
/** Variante par client — on filtre paidByMonth sur un client_id. */
export async function computeClientTimeseries(
organizationId: string,
clientId: string,
range: RangeMonths = 6
): Promise<{ range: RangeMonths; paidByMonth: PaidByMonthPoint[] }> {
const paidByMonth = await fetchPaidByMonth({ organizationId, clientId, range })
return { range, paidByMonth }
}
/**
* Helper interne DRY entre computeTimeseries et computeClientTimeseries.
* Renvoie N buckets mensuels (range derniers mois inclus) avec encaisse/count/dso.
*/
async function fetchPaidByMonth(params: {
organizationId: string
clientId?: string
range: RangeMonths
}): Promise<PaidByMonthPoint[]> {
const now = await clock.now(params.organizationId)
const firstBucket = now.minus({ months: params.range - 1 }).startOf('month')
const buckets = new Map<string, PaidByMonthPoint>()
for (let i = 0; i < params.range; i++) {
const m = firstBucket.plus({ months: i }).toFormat('yyyy-LL-01')
buckets.set(m, { month: m, encaisseCents: 0, paidCount: 0, dsoDays: 0 })
}
const query = db
.from('invoices')
.where('organization_id', params.organizationId)
.where('status', 'paid')
.where('paid_at', '>=', firstBucket.toJSDate())
if (params.clientId) query.where('client_id', params.clientId)
const rows = (await query
.select(
db.raw(`to_char(date_trunc('month', paid_at), 'YYYY-MM-01') as month`),
db.raw(`coalesce(sum(amount_ttc_cents), 0)::int as encaisse_cents`),
db.raw(`count(*)::int as paid_count`),
db.raw(
`coalesce(avg(extract(epoch from (paid_at - issue_date)) / 86400)::int, 0) as dso_days`
)
)
.groupByRaw(`date_trunc('month', paid_at)`)
.orderByRaw(`date_trunc('month', paid_at)`)) as Array<{
month: string
encaisse_cents: number
paid_count: number
dso_days: number
}>
for (const r of rows) {
if (!buckets.has(r.month)) continue
buckets.set(r.month, {
month: r.month,
encaisseCents: r.encaisse_cents,
paidCount: r.paid_count,
dsoDays: r.dso_days,
})
}
return Array.from(buckets.values())
}

View File

@ -1,48 +0,0 @@
import DemoCapturedEmail from '#models/demo_captured_email'
import * as clock from '#services/clock'
import Organization from '#models/organization'
/**
* Service de capture d'emails en mode démo.
*
* Tout le code "démo" vit dans `services/demo/*` la prod ne référence
* qu'une seule fonction (`captureEmailIfDemo`) depuis `mail_dispatcher`,
* pour minimiser le couplage.
*
* Si l'org est en mode démo : crée une `DemoCapturedEmail` avec le
* timestamp virtualNow et retourne `true` caller doit ne PAS envoyer
* via Resend.
*
* Sinon : retourne `false` comportement prod inchangé.
*/
export type CaptureInput = {
organizationId: string
kind: 'relance' | 'checkin'
to: { email: string; name?: string | null }
from: { email: string; name?: string | null }
replyTo?: string | null
subject: string
body: string
meta?: Record<string, unknown>
}
export async function captureEmailIfDemo(input: CaptureInput): Promise<boolean> {
const org = await Organization.find(input.organizationId)
if (!org || !org.demoMode) return false
const sentAt = await clock.now(input.organizationId)
await DemoCapturedEmail.create({
organizationId: input.organizationId,
kind: input.kind,
toEmail: input.to.email,
toName: input.to.name ?? null,
fromEmail: input.from.email,
fromName: input.from.name ?? null,
replyTo: input.replyTo ?? null,
subject: input.subject,
body: input.body,
meta: input.meta ?? {},
sentAt,
})
return true
}

View File

@ -1,140 +0,0 @@
import { DateTime } from 'luxon'
import db from '@adonisjs/lucid/services/db'
import RelanceTask from '#models/relance_task'
import CheckinTask from '#models/checkin_task'
import * as clock from '#services/clock'
import { sendRelanceJob } from '#jobs/send_relance_job'
import { sendCheckinJob } from '#jobs/send_checkin_job'
/**
* Démo dispatch quand le SPA tick l'horloge virtuelle, on cherche
* les tasks dont la date d'envoi virtuelle est dépassée et on les
* exécute synchronement (les handlers BullMQ sont appelés directement,
* skippant Redis propre, aucune dépendance externe en démo).
*
* Les emails sont capturés (cf. captureEmailIfDemo dans mail_dispatcher),
* pas envoyés réellement.
*
* Idempotent : seules les tasks `scheduled` sont prises ; les déjà
* traitées sont ignorées. Si on tick deux fois avec la même `targetVirtualNow`,
* la deuxième tick ne fait rien.
*/
export type FiredEvent = {
kind: 'relance' | 'checkin'
taskId: string
invoiceId: string
/** Numéro facture pour le toast UI ("Relance F-2026-0042 envoyée"). */
invoiceNumero: string
/** ISO du DemoCapturedEmail créé — permet de l'afficher direct. */
capturedEmailId: string | null
/** Le moment virtuel où l'event s'est produit. */
firedAt: string
}
/**
* Avance virtual_now et fire les tasks dues. Retourne les events
* déclenchés (à afficher en UI + auto-pause de l'horloge).
*
* Note : on récupère les tasks AVANT de bumper virtual_now, puis on
* ajuste leur `sendAt` virtuel pour comparer correctement. Concrètement :
* - relance task.sendAt et checkin task.sendAt sont déjà des dates
* absolues (basées sur invoice.dueDate qui ne bouge pas)
* - on les compare donc à la cible (target virtualNow)
*
* Ordre d'exécution : par sendAt croissant pour que les events
* soient présentés au user dans l'ordre temporel.
*/
export async function tickAndDispatch(
organizationId: string,
targetVirtualNow: DateTime
): Promise<FiredEvent[]> {
// Met à jour virtual_now AVANT de chercher les tasks → les handlers
// qui appellent clock.now() à l'intérieur lisent la valeur cible.
await clock.setVirtualNow(organizationId, targetVirtualNow)
// Tasks "à envoyer" : status scheduled + sendAt <= cible.
const dueRelances = await RelanceTask.query()
.where('organization_id', organizationId)
.where('status', 'scheduled')
.where('send_at', '<=', targetVirtualNow.toJSDate())
.preload('planStep')
.orderBy('send_at', 'asc')
const dueCheckins = await CheckinTask.query()
.where('organization_id', organizationId)
.where('status', 'scheduled')
.where('send_at', '<=', targetVirtualNow.toJSDate())
.orderBy('send_at', 'asc')
// Merge ordered par sendAt pour un récit chronologique.
type Pending =
| { type: 'relance'; task: RelanceTask }
| { type: 'checkin'; task: CheckinTask; plain: string | null }
const pending: Pending[] = []
for (const t of dueRelances) pending.push({ type: 'relance', task: t })
for (const t of dueCheckins) pending.push({ type: 'checkin', task: t, plain: null })
pending.sort((a, b) => a.task.sendAt.toMillis() - b.task.sendAt.toMillis())
const fired: FiredEvent[] = []
for (const p of pending) {
if (p.type === 'relance') {
// Le job a déjà la logique complète (mark sent, bump rubis, activity).
await sendRelanceJob({ taskId: p.task.id })
const invoice = await db
.from('invoices')
.where('id', p.task.invoiceId)
.select('id', 'numero')
.first()
const captured = await db
.from('demo_captured_emails')
.where('organization_id', organizationId)
.whereRaw(`(meta->>'invoiceId') = ?`, [p.task.invoiceId])
.orderBy('sent_at', 'desc')
.first()
fired.push({
kind: 'relance',
taskId: p.task.id,
invoiceId: p.task.invoiceId,
invoiceNumero: invoice?.numero ?? '',
capturedEmailId: captured?.id ?? null,
firedAt: targetVirtualNow.toISO()!,
})
} else {
// Pour le checkin job, on a besoin du plain token — pas stocké
// (on a juste le hash). En démo on regénère un "plain" volatile
// dérivé de l'ID — les liens paid/pending n'ont pas vraiment de
// sens ici (l'utilisateur ne va pas cliquer dessus en démo).
// On délègue quand même à sendCheckinJob qui appellera l'envoi
// (capturé), puis update task.status.
await sendCheckinJob({ taskId: p.task.id, plain: 'demo-' + p.task.id })
const invoice = await db
.from('invoices')
.where('id', p.task.invoiceId)
.select('id', 'numero')
.first()
const captured = await db
.from('demo_captured_emails')
.where('organization_id', organizationId)
.whereRaw(`(meta->>'invoiceId') = ?`, [p.task.invoiceId])
.orderBy('sent_at', 'desc')
.first()
fired.push({
kind: 'checkin',
taskId: p.task.id,
invoiceId: p.task.invoiceId,
invoiceNumero: invoice?.numero ?? '',
capturedEmailId: captured?.id ?? null,
firedAt: targetVirtualNow.toISO()!,
})
}
}
return fired
}

View File

@ -2,8 +2,6 @@ import mail from '@adonisjs/mail/services/main'
import env from '#start/env'
import { DateTime } from 'luxon'
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
import * as clock from '#services/clock'
import { captureEmailIfDemo } from '#services/demo/capture'
import type Invoice from '#models/invoice'
import type Client from '#models/client'
import type PlanStep from '#models/plan_step'
@ -36,19 +34,16 @@ export function buildRelanceVars({
client,
user,
organization,
now = DateTime.utc(),
}: {
invoice: Pick<Invoice, 'numero' | 'amountTtcCents' | 'dueDate' | 'issueDate'>
client: Pick<Client, 'name' | 'email' | 'contactFirstName' | 'contactLastName'>
user: Pick<User, 'fullName' | 'signature' | 'email'> | null
organization?: Pick<Organization, 'name'> | null
/** `now` injecté pour respecter virtualNow en mode démo. */
now?: DateTime
}) {
const dueDate = invoice.dueDate.toJSDate()
// Jours de retard arrondis à l'entier — démo-aware via `now` injecté.
// Jours de retard arrondis à l'entier (UTC pour cohérence).
const daysLate = Math.floor(
now.startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days
DateTime.utc().startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days
)
return {
client: {
@ -85,38 +80,14 @@ export async function sendRelanceEmail({
user,
organization,
}: RelancePayload) {
const vars = buildRelanceVars({
invoice,
client,
user,
organization,
now: await clock.now(invoice.organizationId),
})
const vars = buildRelanceVars({ invoice, client, user, organization })
const subject = renderTemplate(step.subject, vars)
const body = renderTemplate(step.body, vars)
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr')
const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")
// FORK DÉMO — unique point où l'app dévie de la prod. Si l'org est
// en mode démo, on capture l'email dans demo_captured_emails au lieu
// de l'envoyer via Resend. Tout le reste du pipeline (idempotence,
// status update, rubis bump) tourne identique.
const captured = await captureEmailIfDemo({
organizationId: invoice.organizationId,
kind: 'relance',
to: { email: client.email, name: client.name },
from: { email: fromAddress, name: fromName },
replyTo: user?.email ?? null,
subject,
body,
meta: { invoiceId: invoice.id, clientId: client.id, stepOrder: step.order },
})
if (captured) return // demo : ne pas envoyer pour de vrai
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(fromAddress, fromName)
m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
.to(client.email, client.name)
.subject(subject)
// Texte brut pour V1 — on ajoutera un template HTML quand on aura
@ -173,25 +144,9 @@ Ces liens expirent dans 24h.
Merci,
L'équipe Rubis`
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr')
const fromName = env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle")
// FORK DÉMO — capture si demoMode (cf. sendRelanceEmail).
const captured = await captureEmailIfDemo({
organizationId: invoice.organizationId,
kind: 'checkin',
to: { email: user.email, name: user.fullName ?? user.email },
from: { email: fromAddress, name: fromName },
replyTo: null,
subject,
body,
meta: { invoiceId: invoice.id, clientId: client.id, paidUrl, pendingUrl },
})
if (captured) return
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
await mailer.send((m) => {
m.from(fromAddress, fromName)
m.from(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
.to(user.email, user.fullName ?? user.email)
.subject(subject)
.text(body)

View File

@ -1,8 +1,8 @@
import { DateTime } from 'luxon'
import RelanceTask from '#models/relance_task'
import Plan from '#models/plan'
import type Invoice from '#models/invoice'
import { getQueue } from '#services/queue'
import * as clock from '#services/clock'
import app from '@adonisjs/core/services/app'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
@ -70,7 +70,7 @@ export async function scheduleRelancesForInvoice(
await t.save()
}
const now = await clock.now(invoice.organizationId)
const now = DateTime.now()
const created: RelanceTask[] = []
const steps = plan.steps.slice().sort((a, b) => a.order - b.order)
const firstOverdueStep = steps.find(

View File

@ -1,108 +0,0 @@
import User from '#models/user'
import Organization from '#models/organization'
import { provisionDefaultPlans } from '#services/default_plans'
import { issueRefreshToken } from '#services/refresh_token'
import db from '@adonisjs/lucid/services/db'
import crypto from 'node:crypto'
import type { HttpContext } from '@adonisjs/core/http'
export type SsoProvider = 'google' | 'microsoft'
/**
* Identité minimale extraite d'un provider OAuth (cf. AllyUserContract).
* Une seule shape pour tous les providers on normalise au boundary du
* driver Ally vers ce type.
*/
export type SsoIdentity = {
provider: SsoProvider
/** sub stable du provider (google_id ou microsoft_id en DB). */
providerId: string
/** Email peut être null pour certains comptes Microsoft, dans ce cas
* on le rejette en amont. */
email: string
/** Nom complet si dispo. */
fullName: string | null
}
/**
* Trouve ou crée l'utilisateur pour une identité SSO :
* 1. Match par `<provider>_id` (canonique)
* 2. Sinon match par email lie l'ID provider au compte existant
* 3. Sinon crée un nouvel user + org + plans par défaut
*
* Retourne `{ user, isNewUser }`. Aux callers de poser le refresh cookie
* et de décider la redirection.
*/
export async function findOrCreateUserFromSso(
identity: SsoIdentity
): Promise<{ user: User; isNewUser: boolean }> {
const idColumn = identity.provider === 'google' ? 'googleId' : 'microsoftId'
// 1. Match canonique provider_id
let user = await User.findBy(idColumn, identity.providerId)
if (user) return { user, isNewUser: false }
// 2. Match email — première connexion via ce provider d'un user existant.
user = await User.findBy('email', identity.email.toLowerCase())
if (user) {
;(user as unknown as Record<string, unknown>)[idColumn] = identity.providerId
if (!user.fullName && identity.fullName) {
user.fullName = identity.fullName
}
await user.save()
return { user, isNewUser: false }
}
// 3. Création : nouvelle org + plans par défaut + user (mdp aléatoire
// inutilisable, le user pourra activer email/password via "mdp oublié").
user = await db.transaction(async (trx) => {
const org = await Organization.create({ name: '' }, { client: trx })
await provisionDefaultPlans(org.id, trx)
return User.create(
{
email: identity.email.toLowerCase(),
fullName: identity.fullName,
password: crypto.randomBytes(48).toString('base64url'),
[idColumn]: identity.providerId,
organizationId: org.id,
} as unknown as Partial<User>,
{ client: trx }
)
})
return { user, isNewUser: true }
}
/**
* Calcule la prochaine étape d'onboarding pour un user qui vient de se
* connecter via SSO. La route SPA cible cette URL (passée via ?next=...).
*
* - new user /onboarding/entreprise (compte déjà rempli
* par les infos SSO, pas besoin de cette étape)
* - org name vide /onboarding/entreprise
* - signature manquante /onboarding/signature
* - sinon / (dashboard)
*/
export async function nextRouteAfterSso(
user: User,
isNewUser: boolean
): Promise<string> {
if (isNewUser) return '/onboarding/entreprise'
const org = user.organizationId ? await Organization.find(user.organizationId) : null
if (!org || !org.name) return '/onboarding/entreprise'
if (!user.signature) return '/onboarding/signature'
return '/'
}
/**
* Pose le refresh cookie httpOnly puis redirige vers le SPA en passant
* la route cible. Utilisé en fin de callback SSO.
*/
export async function emitSsoSessionAndRedirect(
ctx: HttpContext,
user: User,
next: string,
webUrl: string
): Promise<void> {
await issueRefreshToken(user, ctx)
ctx.response.redirect(`${webUrl}/auth/sso/complete?next=${encodeURIComponent(next)}`)
}

View File

@ -1,134 +0,0 @@
import { BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { DateTime } from 'luxon'
import User from '#models/user'
import Invoice from '#models/invoice'
import RelanceTask from '#models/relance_task'
/**
* Programme une RelanceTask à une date arbitraire pour la démo.
*
* node ace demo:schedule-relance --email arthurbarre.js@gmail.com --date 2026-05-09
* node ace demo:schedule-relance --email ... --date 2026-05-09 --hour 14
* node ace demo:schedule-relance --email ... --date 2026-05-09 --invoice F-2026-0042
*
* Logique :
* - Trouve une facture active (pending / in_relance / awaiting) avec un plan
* - Pioche le premier step du plan (amical en général) sinon `--step-order N`
* - Crée une RelanceTask `scheduled` à la date demandée
*
* Utilise l'horloge virtuelle si la démo est active (compare-toi à la date
* que tu vois sur l'horloge top-right).
*/
export default class DemoScheduleRelance extends BaseCommand {
static commandName = 'demo:schedule-relance'
static description = 'Programme une RelanceTask à une date arbitraire (démo)'
static options: CommandOptions = {
startApp: true,
}
@flags.string({ description: 'Email du user', required: true })
declare email: string
@flags.string({ description: 'Date YYYY-MM-DD', required: true })
declare date: string
@flags.number({ description: 'Heure d\'envoi (0-23)', default: 9 })
declare hour: number
@flags.string({
description: 'Numéro de facture spécifique (sinon : 1re active trouvée)',
})
declare invoice?: string
@flags.number({
description: 'Order du step plan à utiliser (0 = premier)',
default: 0,
})
declare stepOrder: number
async run() {
const user = await User.findBy('email', this.email.toLowerCase())
if (!user || !user.organizationId) {
this.logger.error(`User introuvable ou sans org : ${this.email}`)
this.exitCode = 1
return
}
let invoiceQuery = Invoice.query()
.where('organization_id', user.organizationId)
.preload('plan', (q) => q.preload('steps'))
.preload('client')
.whereIn('status', ['pending', 'in_relance', 'awaiting_user_confirmation'])
.orderBy('due_date', 'asc')
if (this.invoice) {
invoiceQuery = invoiceQuery.where('numero', this.invoice)
}
const invoice = await invoiceQuery.first()
if (!invoice) {
this.logger.error(
this.invoice
? `Facture ${this.invoice} non trouvée ou inactive.`
: 'Aucune facture active (pending/in_relance/awaiting) dans cette org.'
)
this.exitCode = 1
return
}
if (!invoice.plan?.steps?.length) {
this.logger.error(
`Facture ${invoice.numero} sans plan ou plan sans étapes — assignez un plan d'abord.`
)
this.exitCode = 1
return
}
const sortedSteps = invoice.plan.steps.slice().sort((a, b) => a.order - b.order)
const step = sortedSteps[this.stepOrder]
if (!step) {
this.logger.error(
`Step order ${this.stepOrder} introuvable. Plan a ${sortedSteps.length} étape(s).`
)
this.exitCode = 1
return
}
const sendAt = DateTime.fromISO(
`${this.date}T${String(this.hour).padStart(2, '0')}:00:00.000`,
{ zone: 'utc' }
)
if (!sendAt.isValid) {
this.logger.error(
`Date invalide : "${this.date}" (attendu YYYY-MM-DD), heure ${this.hour}.`
)
this.exitCode = 1
return
}
const task = await RelanceTask.create({
organizationId: user.organizationId,
invoiceId: invoice.id,
planStepId: step.id,
sendAt,
status: 'scheduled',
sentAt: null,
queueJobId: null,
})
this.logger.success('Relance programmée pour la démo :')
this.logger.info(` · facture : ${invoice.numero}${invoice.client.name}`)
this.logger.info(
` · step : J${step.offsetDays >= 0 ? '+' : ''}${step.offsetDays} (${step.tone}) — "${step.subject}"`
)
this.logger.info(` · sendAt : ${sendAt.toFormat('cccc dd LLLL yyyy HH:mm')} UTC`)
this.logger.info(` · task id : ${task.id}`)
this.logger.info('')
this.logger.info(
"→ En mode démo, l'horloge déclenchera l'envoi quand virtualNow ≥ sendAt."
)
}
}

View File

@ -1,151 +0,0 @@
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.')
}
}

View File

@ -1,14 +1,15 @@
import env from '#start/env'
import { defineConfig, services } from '@adonisjs/ally'
import { microsoftService } from '#services/ally/microsoft_driver'
/**
* Configuration des providers OAuth (Ally).
*
* V1 : Google + Microsoft 365 (cf. CLAUDE.md Auth).
* Le callback URL pointe vers l'API (/api/v1/auth/{provider}/callback).
* V1 : Google uniquement (cf. CLAUDE.md Auth). Les autres viendront
* plus tard si pertinent (Microsoft pour les TPE qui utilisent O365 ?).
*
* Le callback URL pointe vers l'API en interne (/api/v1/auth/google/callback).
* En prod, le reverse proxy nginx (rubis-web) achemine /api/* vers ce
* service, donc la même URL fonctionne browser et provider.
* service, donc la même URL fonctionne pour le browser et pour Google.
*/
const allyConfig = defineConfig({
google: services.google({
@ -24,19 +25,6 @@ const allyConfig = defineConfig({
scopes: ['userinfo.email', 'userinfo.profile'],
prompt: 'select_account',
}),
microsoft: microsoftService({
clientId: env.get('MICROSOFT_CLIENT_ID', ''),
clientSecret: env.get('MICROSOFT_CLIENT_SECRET', ''),
callbackUrl: env.get(
'MICROSOFT_CALLBACK_URL',
'http://localhost:3333/api/v1/auth/microsoft/callback'
),
// tenant=common : accepte work/school (Microsoft 365) ET comptes personnels
// (Outlook, Hotmail). Pour limiter à M365 strict, mettre 'organizations'.
tenant: env.get('MICROSOFT_TENANT', 'common'),
scopes: ['openid', 'profile', 'email', 'User.Read'],
prompt: 'select_account',
}),
})
export default allyConfig

View File

@ -1,808 +0,0 @@
/**
* Factories créent des entités réalistes (FR, format Rubis) pour
* peupler une org de démo ou alimenter des tests.
*
* Pas de framework lourd type @adonisjs/lucid factories : des fonctions
* pures, idempotentes, qu'on compose dans une commande Ace ou un test.
*/
import { DateTime } from 'luxon'
import { randomUUID } from 'node:crypto'
import { readdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { existsSync } from 'node:fs'
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
import drive from '@adonisjs/drive/services/main'
import Client from '#models/client'
import Invoice from '#models/invoice'
import ActivityEvent from '#models/activity_event'
import RelanceTask from '#models/relance_task'
import Plan from '#models/plan'
// ---------------------------------------------------------------------------
// Sources de données déterministes (pas de Faker — moins de deps, plus stable)
// ---------------------------------------------------------------------------
const CLIENT_TEMPLATES: Array<{
name: string
firstName: string | null
lastName: string | null
emailDomain: string
phone: string | null
address: string | null
siret: string | null
}> = [
{
name: 'Boulangerie Martin SARL',
firstName: 'Marie',
lastName: 'Martin',
emailDomain: 'boulangerie-martin.fr',
phone: '+33 1 23 45 67 89',
address: '12 rue du Pain, 75011 Paris',
siret: '82345678900012',
},
{
name: 'Maçonnerie Dupont & Fils',
firstName: 'Jean',
lastName: 'Dupont',
emailDomain: 'maconnerie-dupont.fr',
phone: '+33 4 78 56 12 34',
address: '45 chemin des Carrières, 69100 Villeurbanne',
siret: '53412987600028',
},
{
name: 'Atelier Durand',
firstName: null,
lastName: null,
emailDomain: 'atelier-durand.fr',
phone: null,
address: null,
siret: null,
},
{
name: 'Cabinet Rousseau Conseil',
firstName: 'Julien',
lastName: 'Rousseau',
emailDomain: 'cabinet-rousseau.fr',
phone: '+33 4 56 78 90 12',
address: '8 place de la République, 69002 Lyon',
siret: '53412987600101',
},
{
name: 'Garage Lemoine',
firstName: 'Pierre',
lastName: 'Lemoine',
emailDomain: 'garage-lemoine.fr',
phone: '+33 2 99 87 65 43',
address: '23 boulevard de la Liberté, 35000 Rennes',
siret: '78912345600054',
},
{
name: 'Studio Lefèvre',
firstName: 'Camille',
lastName: 'Lefèvre',
emailDomain: 'studio-lefevre.com',
phone: null,
address: null,
siret: null,
},
{
name: 'Restaurant Le Beauvoir',
firstName: 'Sophie',
lastName: 'Beauvoir',
emailDomain: 'le-beauvoir.fr',
phone: '+33 1 45 22 33 44',
address: '15 rue Mouffetard, 75005 Paris',
siret: '12345678900078',
},
{
name: 'Imprimerie Henri & Fils',
firstName: 'Henri',
lastName: 'Petit',
emailDomain: 'imprimerie-henri.fr',
phone: '+33 5 61 78 90 23',
address: '7 avenue Jean Jaurès, 31000 Toulouse',
siret: '45123789600041',
},
]
const INVOICE_NOTES_POOL = [
'Prestation conseil — janvier',
'Travaux de rénovation second œuvre',
'Photographe événementiel — mariage',
'Maintenance trimestrielle',
'Livraison matières premières',
'Audit comptable annuel',
null,
null,
]
// ---------------------------------------------------------------------------
// Factories
// ---------------------------------------------------------------------------
export type ClientFactoryInput = {
organizationId: string
trx?: TransactionClientContract
/** Index 0..N dans CLIENT_TEMPLATES, sinon valeurs random. */
index?: number
}
export async function makeClient(input: ClientFactoryInput): Promise<Client> {
const tpl =
input.index !== undefined
? CLIENT_TEMPLATES[input.index % CLIENT_TEMPLATES.length]!
: CLIENT_TEMPLATES[Math.floor(Math.random() * CLIENT_TEMPLATES.length)]!
const inboxName = tpl.firstName
? `${tpl.firstName.toLowerCase()}@${tpl.emailDomain}`
: `compta@${tpl.emailDomain}`
return Client.create(
{
organizationId: input.organizationId,
name: tpl.name,
email: inboxName,
contactFirstName: tpl.firstName,
contactLastName: tpl.lastName,
phone: tpl.phone,
address: tpl.address,
siret: tpl.siret,
notes: null,
},
{ client: input.trx }
)
}
export type InvoiceStatus = Invoice['status']
export type InvoiceFactoryInput = {
organizationId: string
clientId: string
planId: string | null
/** Status cible — drive aussi les dates (pending = future, paid = passé). */
status: InvoiceStatus
/** Numéro override, sinon F-YYYY-XXXX random. */
numero?: string
/** Montant en centimes. Sinon random 250€-8000€. */
amountTtcCents?: number
/** Décalage en jours par rapport à aujourd'hui pour issueDate.
* Négatif = passé. */
issueOffsetDays?: number
/** Délai de paiement en jours (par défaut 30, conforme LME). */
paymentTermDays?: number
trx?: TransactionClientContract
}
export async function makeInvoice(input: InvoiceFactoryInput): Promise<Invoice> {
const issueOffset = input.issueOffsetDays ?? -randomInt(7, 90)
const paymentTerm = input.paymentTermDays ?? 30
const issueDate = DateTime.utc().plus({ days: issueOffset }).startOf('day')
const dueDate = issueDate.plus({ days: paymentTerm })
const amount = input.amountTtcCents ?? randomInt(25_000, 800_000)
const numero =
input.numero ?? `F-${issueDate.year}-${String(randomInt(1, 9999)).padStart(4, '0')}`
// paidAt : pour les statuts paid, on simule un paiement après l'échéance
// (parfois en avance, parfois en retard — réaliste).
let paidAt: DateTime | null = null
if (input.status === 'paid') {
const paidOffset = randomInt(-5, 25) // -5 = payé en avance, +25 = en retard
paidAt = dueDate.plus({ days: paidOffset })
}
// rubisEarned : 1 par relance envoyée + 1 si payée
let rubisEarned = 0
if (input.status === 'in_relance' || input.status === 'awaiting_user_confirmation') {
rubisEarned = randomInt(1, 3)
} else if (input.status === 'paid') {
rubisEarned = randomInt(0, 4) // certaines payées sans relance
}
return Invoice.create(
{
organizationId: input.organizationId,
clientId: input.clientId,
planId: input.planId,
numero,
amountTtcCents: amount,
issueDate,
dueDate,
paidAt,
status: input.status,
pdfStorageKey: null,
rubisEarned,
notes: pickRandom(INVOICE_NOTES_POOL),
},
{ client: input.trx }
)
}
export type ActivityFactoryInput = {
organizationId: string
invoice: Invoice
client: Client
trx?: TransactionClientContract
}
/**
* Pour chaque facture, génère les events réalistes :
* - invoice_imported (toujours, à issueDate)
* - relance_sent N fois si status in_relance/awaiting (entre issueDate et now)
* - invoice_paid si status paid
*/
export async function makeActivityForInvoice(
input: ActivityFactoryInput
): Promise<void> {
const { invoice, client, trx } = input
// Import — toujours
await ActivityEvent.create(
{
organizationId: input.organizationId,
kind: 'invoice_imported',
at: invoice.issueDate,
label: `Facture <b>${invoice.numero}</b> importée`,
meta: { invoiceId: invoice.id, clientId: client.id },
},
{ client: trx }
)
if (invoice.status === 'in_relance' || invoice.status === 'awaiting_user_confirmation') {
const relanceCount = randomInt(1, 3)
for (let i = 0; i < relanceCount; i++) {
const sentAt = invoice.dueDate.plus({ days: 3 + i * 7 })
if (sentAt > DateTime.utc()) break
await ActivityEvent.create(
{
organizationId: input.organizationId,
kind: 'relance_sent',
at: sentAt,
label: `Relance J+${3 + i * 7} envoyée à <b>${client.name}</b>`,
meta: {
invoiceId: invoice.id,
clientId: client.id,
planStepOrder: i,
},
},
{ client: trx }
)
}
}
if (invoice.status === 'paid' && invoice.paidAt) {
await ActivityEvent.create(
{
organizationId: input.organizationId,
kind: 'invoice_paid',
at: invoice.paidAt,
label: `<b>${client.name}</b> a réglé ${invoice.numero}`,
meta: { invoiceId: invoice.id, clientId: client.id },
},
{ client: trx }
)
}
}
// ---------------------------------------------------------------------------
// Recettes — combine les factories pour produire une org démo cohérente
// ---------------------------------------------------------------------------
export type DemoSeedConfig = {
organizationId: string
/** Plans déjà provisionnés dans l'org (pour piocher des planId). */
plans: Plan[]
trx: TransactionClientContract
/** Combien de clients (1..N templates dispo). Défaut 8. */
clientCount?: number
}
export type DemoSeedResult = {
clients: Client[]
invoices: Invoice[]
rubisEarned: number
}
/**
* Mix de statuts représentatif d'une TPE active :
* - 5 paid (DSO calc + encaissé total)
* - 4 in_relance (à voir dans le funnel "en relance")
* - 2 awaiting_user_confirmation (check-in en attente)
* - 3 pending (récentes, pas encore relancées)
* - 1 litigation (cas tendu)
*/
const INVOICE_RECIPE: Array<{ status: InvoiceStatus; issueOffsetDays: number; planIdx?: number }> = [
// Paid — réparties sur 6 mois pour faire vivre le DSO
{ status: 'paid', issueOffsetDays: -180 },
{ status: 'paid', issueOffsetDays: -135 },
{ status: 'paid', issueOffsetDays: -95 },
{ status: 'paid', issueOffsetDays: -65 },
{ status: 'paid', issueOffsetDays: -40 },
// En relance — échéances passées récentes
{ status: 'in_relance', issueOffsetDays: -55 },
{ status: 'in_relance', issueOffsetDays: -50 },
{ status: 'in_relance', issueOffsetDays: -42 },
{ status: 'in_relance', issueOffsetDays: -38 },
// Awaiting check-in — échéance toute fraîche
{ status: 'awaiting_user_confirmation', issueOffsetDays: -32 },
{ status: 'awaiting_user_confirmation', issueOffsetDays: -30 },
// Pending — récentes, pas encore arrivées à échéance
{ status: 'pending', issueOffsetDays: -10 },
{ status: 'pending', issueOffsetDays: -5 },
{ status: 'pending', issueOffsetDays: -2 },
// Litigation — ancienne, contestée
{ status: 'litigation', issueOffsetDays: -90 },
]
/** CA cible sur 12 mois pour le seed démo (en centimes). */
const TARGET_ANNUAL_REVENUE_CENTS = 40_000_000 // 400 000 €
/** Nombre de factures historiques à générer (paid sur les 12 derniers mois). */
const HISTORICAL_INVOICE_COUNT = 200
export async function seedDemoOrg(config: DemoSeedConfig): Promise<DemoSeedResult> {
const { organizationId, plans, trx } = config
// Phase 1 — Actionnables : si on a des PDFs réels dans assets/test-invoices,
// on les utilise (mix pending/in_relance/paid/litigation, avec preview PDF).
// Sinon on tombe sur la recipe synthétique.
const assetsDir = resolveTestInvoicesDir()
const actionable: DemoSeedResult = assetsDir
? await seedFromAssetPdfs({ ...config, assetsDir })
: await seedSyntheticActionable(config)
// Phase 2 — Historique : ~200 factures `paid` réparties sur 12 mois pour
// alimenter les graphes (encaissé mensuel, DSO, etc.) et donner un CA
// annuel d'environ 400 K€.
const paidInActionable = actionable.invoices
.filter((inv) => inv.status === 'paid')
.reduce((s, inv) => s + inv.amountTtcCents, 0)
const historicalTarget = Math.max(
TARGET_ANNUAL_REVENUE_CENTS - paidInActionable,
20_000_000 // garde-fou : au moins 200 K€ pour avoir des graphes lisibles
)
const historical = await seedHistoricalInvoices({
organizationId,
clients: actionable.clients,
plans,
trx,
targetRevenueCents: historicalTarget,
invoiceCount: HISTORICAL_INVOICE_COUNT,
monthsBack: 12,
})
return {
clients: actionable.clients,
invoices: [...actionable.invoices, ...historical.invoices],
rubisEarned: actionable.rubisEarned + historical.rubisEarned,
}
}
/** Recipe synthétique fallback — utilisée si pas de PDFs dans assets/. */
async function seedSyntheticActionable(
config: DemoSeedConfig
): Promise<DemoSeedResult> {
const { organizationId, plans, trx } = config
const clientCount = Math.min(config.clientCount ?? 8, CLIENT_TEMPLATES.length)
const clients: Client[] = []
for (let i = 0; i < clientCount; i++) {
clients.push(await makeClient({ organizationId, index: i, trx }))
}
const invoices: Invoice[] = []
for (const [i, recipe] of INVOICE_RECIPE.entries()) {
const client = clients[i % clients.length]!
const plan = plans[i % plans.length] ?? null
const invoice = await makeInvoice({
organizationId,
clientId: client.id,
planId: plan?.id ?? null,
status: recipe.status,
issueOffsetDays: recipe.issueOffsetDays,
trx,
})
invoices.push(invoice)
await makeActivityForInvoice({ organizationId, invoice, client, trx })
}
const rubisEarned = invoices.reduce((s, inv) => s + inv.rubisEarned, 0)
return { clients, invoices, rubisEarned }
}
// ---------------------------------------------------------------------------
// Recette "PDFs réels" — utilise assets/test-invoices/*.pdf
// ---------------------------------------------------------------------------
/**
* Localise le dossier `assets/test-invoices` à la racine du repo. Le command
* tourne depuis `apps/api/`, donc on remonte de 2 niveaux.
*/
function resolveTestInvoicesDir(): string | null {
const candidates = [
join(process.cwd(), '..', '..', 'assets', 'test-invoices'),
join(process.cwd(), 'assets', 'test-invoices'),
]
for (const c of candidates) {
if (existsSync(c)) return c
}
return null
}
/**
* Mappe le suffixe descriptif d'un nom de fichier vers (status, dueOffset).
* Filenames :
* - facture-pas-en-retard-echeance-{N}j-XXX.pdf due dans +N jours
* - facture-echue-aujourdhui-XXX.pdf due aujourd'hui
* - facture-en-retard-{N}j-XXX.pdf due il y a N jours
*
* Pour certaines factures très en retard, on simule un règlement tardif
* (paid) ou une mise en demeure (litigation) ça donne un mix réaliste.
*/
type AssetSpec = {
filename: string
/** Décalage de la dueDate par rapport à aujourd'hui (en jours). */
dueOffsetDays: number
status: InvoiceStatus
}
/** Quelques numéros qu'on bascule en paid / litigation pour le mix démo. */
const PAID_OVERRIDES = new Set([
'facture-en-retard-30j-017.pdf',
'facture-en-retard-45j-019.pdf',
'facture-en-retard-60j-022.pdf',
'facture-en-retard-90j-024.pdf',
'facture-en-retard-120j-026.pdf',
])
const LITIGATION_OVERRIDES = new Set([
'facture-en-retard-120j-025.pdf',
'facture-en-retard-180j-027.pdf',
])
function parseAssetFilename(filename: string): AssetSpec | null {
// Pattern 1 : pas-en-retard-echeance-{N}j → due in +N
const futureMatch = filename.match(/pas-en-retard-echeance-(\d+)j/)
if (futureMatch) {
return {
filename,
dueOffsetDays: Number(futureMatch[1]),
status: 'pending',
}
}
// Pattern 2 : echue-aujourdhui
if (/echue-aujourdhui/.test(filename)) {
return {
filename,
dueOffsetDays: 0,
status: 'awaiting_user_confirmation',
}
}
// Pattern 3 : en-retard-{N}j → due -N
const lateMatch = filename.match(/en-retard-(\d+)j/)
if (lateMatch) {
const days = Number(lateMatch[1])
let status: InvoiceStatus = 'in_relance'
if (days <= 3) status = 'awaiting_user_confirmation'
if (PAID_OVERRIDES.has(filename)) status = 'paid'
else if (LITIGATION_OVERRIDES.has(filename)) status = 'litigation'
return {
filename,
dueOffsetDays: -days,
status,
}
}
return null
}
/** Extrait le numéro depuis le filename (ex. `...-007.pdf` → `F2026-0007`). */
function deriveInvoiceNumero(filename: string, fallbackYear: number): string {
const m = filename.match(/-(\d{3,})\.pdf$/)
if (!m) return `F-${fallbackYear}-${String(randomInt(1, 9999)).padStart(4, '0')}`
return `F${fallbackYear}-${m[1]!.padStart(4, '0')}`
}
/**
* Crée les RelanceTask pour une invoice uniquement pour les statuts qui ont
* effectivement passé un check-in et donc déclenché des relances. Les statuts
* `pending` et `awaiting_user_confirmation` n'en ont volontairement pas : tant
* que l'user n'a pas répondu à un check-in, AUCUNE relance n'est programmée.
*
* - pending 0 task (en attente du tout premier check-in)
* - awaiting_user_confirmation 0 task (check-in en cours, en attente)
* - in_relance / litigation sent si passée, scheduled sinon
* - paid sent jusqu'à paidAt, cancelled au-delà
*
* Pas d'enqueue BullMQ sinon les jobs orphelins polluent Redis.
*/
async function seedRelanceTasksForInvoice(
invoice: Invoice,
plan: Plan & { steps?: Array<{ id: string; offsetDays: number; order: number }> },
trx: TransactionClientContract
): Promise<void> {
if (!plan.steps?.length) return
// Statuts pré-check-in : aucune task à programmer.
if (invoice.status === 'pending' || invoice.status === 'awaiting_user_confirmation') {
return
}
const now = DateTime.utc()
const paidAt = invoice.paidAt
const sortedSteps = plan.steps.slice().sort((a, b) => a.order - b.order)
for (const step of sortedSteps) {
const sendAt = invoice.dueDate.plus({ days: step.offsetDays })
let status: 'scheduled' | 'sent' | 'cancelled'
let sentAt: DateTime | null = null
if (invoice.status === 'paid' && paidAt) {
if (sendAt <= paidAt) {
status = 'sent'
sentAt = sendAt
} else {
status = 'cancelled'
}
} else {
// in_relance, litigation
if (sendAt <= now) {
status = 'sent'
sentAt = sendAt
} else {
status = 'scheduled'
}
}
await RelanceTask.create(
{
organizationId: invoice.organizationId,
invoiceId: invoice.id,
planStepId: step.id,
sendAt,
status,
sentAt,
queueJobId: null,
},
{ client: trx }
)
}
}
type AssetSeedConfig = DemoSeedConfig & { assetsDir: string }
async function seedFromAssetPdfs(config: AssetSeedConfig): Promise<DemoSeedResult> {
const { organizationId, plans, trx, assetsDir } = config
const allFiles = await readdir(assetsDir)
const pdfs = allFiles.filter((f) => f.endsWith('.pdf')).sort()
const specs = pdfs
.map(parseAssetFilename)
.filter((s): s is AssetSpec => s !== null)
if (specs.length === 0) {
// Aucun PDF parseable : on n'aurait pas dû arriver ici.
return { clients: [], invoices: [], rubisEarned: 0 }
}
// Charge les plans avec leurs steps — on en a besoin pour seeder les
// RelanceTasks au bon `sendAt`.
const plansWithSteps = await Plan.query({ client: trx })
.whereIn(
'id',
plans.map((p) => p.id)
)
.preload('steps', (q) => q.orderBy('order', 'asc'))
// Crée tous les clients du pool — round-robin sur les factures.
const clientCount = Math.min(config.clientCount ?? 8, CLIENT_TEMPLATES.length)
const clients: Client[] = []
for (let i = 0; i < clientCount; i++) {
clients.push(await makeClient({ organizationId, index: i, trx }))
}
const invoices: Invoice[] = []
const today = DateTime.utc().startOf('day')
for (const [i, spec] of specs.entries()) {
const client = clients[i % clients.length]!
const plan = plansWithSteps[i % plansWithSteps.length] ?? null
// Upload le PDF vers le drive (MinIO en S3, fs en fallback).
const filePath = join(assetsDir, spec.filename)
const buffer = await readFile(filePath)
const storageKey = `invoice-pdfs/${organizationId}/${randomUUID()}.pdf`
await drive.use().put(storageKey, buffer)
// Dates : on cale la dueDate sur l'offset, on assume 30j de termes.
const dueDate = today.plus({ days: spec.dueOffsetDays })
const issueDate = dueDate.minus({ days: 30 })
let paidAt: DateTime | null = null
if (spec.status === 'paid') {
// Règlement après l'échéance — entre 5 et 30j de retard côté facture.
paidAt = dueDate.plus({ days: randomInt(5, 30) })
}
let rubisEarned = 0
if (spec.status === 'in_relance' || spec.status === 'awaiting_user_confirmation') {
rubisEarned = randomInt(1, 3)
} else if (spec.status === 'paid') {
rubisEarned = randomInt(1, 4)
}
const invoice = await Invoice.create(
{
organizationId,
clientId: client.id,
planId: plan?.id ?? null,
numero: deriveInvoiceNumero(spec.filename, issueDate.year),
amountTtcCents: randomInt(25_000, 800_000),
issueDate,
dueDate,
paidAt,
status: spec.status,
pdfStorageKey: storageKey,
rubisEarned,
notes: pickRandom(INVOICE_NOTES_POOL),
},
{ client: trx }
)
invoices.push(invoice)
await makeActivityForInvoice({ organizationId, invoice, client, trx })
if (plan) {
await seedRelanceTasksForInvoice(invoice, plan, trx)
}
}
const rubisEarned = invoices.reduce((s, inv) => s + inv.rubisEarned, 0)
return { clients, invoices, rubisEarned }
}
// ---------------------------------------------------------------------------
// Recette "historique" — factures paid réparties sur N mois (alimente les
// graphes : encaissé mensuel, DSO, etc.). Pas de PDF, juste des données pour
// que les charts soient parlants.
// ---------------------------------------------------------------------------
type HistoricalSeedConfig = {
organizationId: string
clients: Client[]
plans: Plan[]
trx: TransactionClientContract
/** Total cents que la somme des paid doit approcher. */
targetRevenueCents: number
/** Nombre de factures à générer. */
invoiceCount: number
/** Profondeur en mois (issueDate étalée sur cette fenêtre). */
monthsBack: number
}
async function seedHistoricalInvoices(
config: HistoricalSeedConfig
): Promise<DemoSeedResult> {
const { organizationId, clients, plans, trx, monthsBack } = config
if (clients.length === 0) return { clients, invoices: [], rubisEarned: 0 }
// ~5% de cancelled pour la variété (factures annulées en cours de route).
const cancelledIndexes = new Set<number>()
const cancelledCount = Math.round(config.invoiceCount * 0.05)
while (cancelledIndexes.size < cancelledCount) {
cancelledIndexes.add(randomInt(0, config.invoiceCount - 1))
}
// Distribution des montants — log-uniforme entre 0.3 et 2.8, puis rescale
// pour matcher le CA cible. Ça donne une queue avec quelques grosses
// factures et beaucoup de petites — réaliste pour une TPE.
const paidCount = config.invoiceCount - cancelledIndexes.size
const rawAmounts = Array.from(
{ length: paidCount },
() => 0.3 + Math.random() * 2.5
)
const sumRaw = rawAmounts.reduce((a, b) => a + b, 0)
const scale = config.targetRevenueCents / sumRaw
const paidAmounts = rawAmounts.map((r) => Math.max(20_000, Math.round(r * scale)))
const invoices: Invoice[] = []
const today = DateTime.utc().startOf('day')
let paidIdx = 0
for (let i = 0; i < config.invoiceCount; i++) {
const isCancelled = cancelledIndexes.has(i)
const client = clients[i % clients.length]!
// Plans optionnels — la moitié des historiques n'en avait pas (saisie
// manuelle), pour varier.
const plan = i % 2 === 0 ? (plans[i % plans.length] ?? null) : null
// Date d'émission étalée sur monthsBack mois (avec jitter par jour).
const monthOffset = randomInt(0, monthsBack - 1)
const dayJitter = randomInt(0, 27)
const issueDate = today
.minus({ months: monthOffset })
.startOf('month')
.plus({ days: dayJitter })
const paymentTerm = 30
const dueDate = issueDate.plus({ days: paymentTerm })
let amountTtcCents: number
let paidAt: DateTime | null = null
let status: InvoiceStatus
let rubisEarned = 0
if (isCancelled) {
// Cancelled : montant petit-moyen, pas de paidAt, pas de rubis.
amountTtcCents = randomInt(30_000, 200_000)
status = 'cancelled'
} else {
amountTtcCents = paidAmounts[paidIdx++]!
// Paiement entre -3j (avance) et +25j (retard) par rapport à dueDate.
paidAt = dueDate.plus({ days: randomInt(-3, 25) })
// Mais jamais après aujourd'hui.
if (paidAt > today) paidAt = today.minus({ days: randomInt(0, 5) })
status = 'paid'
// ~70% des paid ont déclenché 0-2 relances avant règlement.
rubisEarned = Math.random() < 0.7 ? randomInt(0, 2) : 0
}
const numero = `F${issueDate.year}-H${String(i + 1).padStart(4, '0')}`
const invoice = await Invoice.create(
{
organizationId,
clientId: client.id,
planId: plan?.id ?? null,
numero,
amountTtcCents,
issueDate,
dueDate,
paidAt,
status,
pdfStorageKey: null,
rubisEarned,
notes: pickRandom(INVOICE_NOTES_POOL),
},
{ client: trx }
)
invoices.push(invoice)
// Activity events minimaux pour la timeline (import + paiement).
await ActivityEvent.create(
{
organizationId,
kind: 'invoice_imported',
at: issueDate,
label: `Facture <b>${numero}</b> importée`,
meta: { invoiceId: invoice.id, clientId: client.id },
},
{ client: trx }
)
if (status === 'paid' && paidAt) {
await ActivityEvent.create(
{
organizationId,
kind: 'invoice_paid',
at: paidAt,
label: `<b>${client.name}</b> a réglé ${numero}`,
meta: { invoiceId: invoice.id, clientId: client.id },
},
{ client: trx }
)
}
}
const rubisEarned = invoices.reduce((s, inv) => s + inv.rubisEarned, 0)
return { clients, invoices, rubisEarned }
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
function pickRandom<T>(arr: readonly T[]): T {
return arr[Math.floor(Math.random() * arr.length)]!
}
// Marque l'export de randomUUID utilisée si on en a besoin ailleurs.
export { randomUUID }

View File

@ -1,25 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Ajoute `microsoft_id` (sub OAuth Microsoft Identity, stable, unique).
* Match prioritaire sur cet ID, fallback email pour lier un compte existant.
*
* Mêmes principes que google_id (cf. migration 1300) :
* - Nullable (un user peut n'avoir que Google ou que email/password)
* - Unique pour éviter les collisions
*/
export default class extends BaseSchema {
protected tableName = 'users'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('microsoft_id', 64).nullable().unique()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('microsoft_id')
})
}
}

View File

@ -1,42 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Mode démo sur les organisations.
*
* - `demo_mode` : flag activé/désactivé. Quand true, les services
* time-sensitive (relances, check-ins, KPIs dashboard) lisent
* `virtual_now` au lieu de `Date.now()`. Les emails sont capturés
* dans la table demo_captured_emails au lieu d'être envoyés via
* Resend.
*
* - `virtual_now` : horloge virtuelle. Permet d'avancer le temps
* pendant une démo (1 jour démo 800 ms en réel par défaut).
* Null = on ne touche à rien (= comportement prod normal).
*
* - `demo_speed_factor` : multiplicateur d'accélération côté UI.
* Stocké côté serveur pour la persistance entre sessions, mais
* le tick reste piloté par le client.
*
* Tous les champs sont à `null`/`false` par défaut zéro impact sur
* les organisations existantes (cf. priorité produit : la prod est
* intacte tant que demo_mode reste off).
*/
export default class extends BaseSchema {
protected tableName = 'organizations'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.boolean('demo_mode').notNullable().defaultTo(false)
table.timestamp('virtual_now', { useTz: true }).nullable()
table.smallint('demo_speed_factor').notNullable().defaultTo(1)
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('demo_mode')
table.dropColumn('virtual_now')
table.dropColumn('demo_speed_factor')
})
}
}

View File

@ -1,53 +0,0 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Boîte de réception démo : quand `org.demo_mode = true`, les emails
* de relance et de check-in ne partent PAS via Resend mais sont stockés
* ici. Lus par le SPA via `/demo/inbox` pour montrer "ce que le client
* recevrait" pendant la démo live.
*
* - kind : type d'email (relance ou checkin)
* - to / from : adresses telles qu'on les aurait envoyées
* - subject / body : interpolés (toutes les variables résolues)
* - sent_at : DateTime virtuelle de l'envoi (= virtual_now au moment T)
* - meta : invoiceId / clientId pour permettre des liens dans l'UI
*
* Pas de relation FK : on garde une trace même si la facture est
* supprimée ensuite (l'inbox démo doit être robuste à un reset partiel).
*/
export default class extends BaseSchema {
protected tableName = 'demo_captured_emails'
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')
table.string('kind', 16).notNullable() // 'relance' | 'checkin'
table.string('to_email', 254).notNullable()
table.string('to_name', 200).nullable()
table.string('from_email', 254).notNullable()
table.string('from_name', 200).nullable()
table.string('reply_to', 254).nullable()
table.string('subject', 200).notNullable()
table.text('body').notNullable()
table.jsonb('meta').notNullable().defaultTo('{}')
table.timestamp('sent_at', { useTz: true }).notNullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
// Lectures principales : la dernière inbox d'une org, en ordre desc.
table.index(['organization_id', 'sent_at'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -109,39 +109,6 @@ export class ClientSchema extends BaseModel {
declare updatedAt: DateTime | null
}
export class DemoCapturedEmailSchema extends BaseModel {
static $columns = ['body', 'createdAt', 'fromEmail', 'fromName', 'id', 'kind', 'meta', 'organizationId', 'replyTo', 'sentAt', 'subject', 'toEmail', 'toName', 'updatedAt'] as const
$columns = DemoCapturedEmailSchema.$columns
@column()
declare body: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare fromEmail: string
@column()
declare fromName: string | null
@column({ isPrimary: true })
declare id: string
@column()
declare kind: string
@column()
declare meta: any
@column()
declare organizationId: string
@column()
declare replyTo: string | null
@column.dateTime()
declare sentAt: DateTime
@column()
declare subject: string
@column()
declare toEmail: string
@column()
declare toName: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class ImportBatchSchema extends BaseModel {
static $columns = ['createdAt', 'id', 'organizationId', 'updatedAt'] as const
$columns = ImportBatchSchema.$columns
@ -218,14 +185,10 @@ export class InvoiceSchema extends BaseModel {
}
export class OrganizationSchema extends BaseModel {
static $columns = ['createdAt', 'demoMode', 'demoSpeedFactor', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt', 'virtualNow'] as const
static $columns = ['createdAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt'] as const
$columns = OrganizationSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare demoMode: boolean
@column()
declare demoSpeedFactor: number
@column({ isPrimary: true })
declare id: string
@column()
@ -240,8 +203,6 @@ export class OrganizationSchema extends BaseModel {
declare siret: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
@column.dateTime()
declare virtualNow: DateTime | null
}
export class PlanStepSchema extends BaseModel {
@ -341,7 +302,7 @@ export class RelanceTaskSchema extends BaseModel {
}
export class UserSchema extends BaseModel {
static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'microsoftId', 'organizationId', 'password', 'signature', 'updatedAt'] as const
static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const
$columns = UserSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@ -354,8 +315,6 @@ export class UserSchema extends BaseModel {
@column({ isPrimary: true })
declare id: string
@column()
declare microsoftId: string | null
@column()
declare organizationId: string | null
@column({ serializeAs: null })
declare password: string | null

View File

@ -73,12 +73,6 @@ export default await Env.create(new URL('../', import.meta.url), {
GOOGLE_CLIENT_SECRET: Env.schema.string.optional(),
GOOGLE_CALLBACK_URL: Env.schema.string.optional({ format: 'url', tld: false }),
// Microsoft SSO (Ally)
MICROSOFT_CLIENT_ID: Env.schema.string.optional(),
MICROSOFT_CLIENT_SECRET: Env.schema.string.optional(),
MICROSOFT_TENANT: Env.schema.string.optional(),
MICROSOFT_CALLBACK_URL: Env.schema.string.optional({ format: 'url', tld: false }),
/*
|----------------------------------------------------------
| Variables for configuring the limiter package

View File

@ -43,12 +43,6 @@ router
router
.get('google/callback', [controllers.AuthGoogle, 'callback'])
.as('google.callback')
router
.get('microsoft/redirect', [controllers.AuthMicrosoft, 'redirect'])
.as('microsoft.redirect')
router
.get('microsoft/callback', [controllers.AuthMicrosoft, 'callback'])
.as('microsoft.callback')
})
.prefix('auth')
.as('auth')
@ -103,10 +97,6 @@ router
router.get('', [controllers.Clients, 'index']).as('index')
router.post('', [controllers.Clients, 'store']).as('store')
router.get(':id', [controllers.Clients, 'show']).as('show').where('id', router.matchers.uuid())
router
.get(':id/timeseries', [controllers.Clients, 'timeseries'])
.as('timeseries')
.where('id', router.matchers.uuid())
router.patch(':id', [controllers.Clients, 'update']).as('update').where('id', router.matchers.uuid())
})
.prefix('clients')
@ -141,22 +131,6 @@ router
.as('plans')
.use(middleware.auth())
/**
* Demo auth requise. Mode démo opt-in par org (cf. CLAUDE.md
* Architecture). Routes opérantes seulement si `org.demo_mode = true`.
*/
router
.group(() => {
router.post('start', [controllers.Demo, 'start']).as('start')
router.post('end', [controllers.Demo, 'end']).as('end')
router.post('tick', [controllers.Demo, 'tick']).as('tick')
router.get('state', [controllers.Demo, 'state']).as('state')
router.get('inbox', [controllers.Demo, 'inbox']).as('inbox')
})
.prefix('demo')
.as('demo')
.use(middleware.auth())
/**
* Dashboard auth requise. Calculs agrégés on-the-fly (pas de cache V1).
*/
@ -165,9 +139,6 @@ router
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')
router
.get('timeseries', [controllers.Dashboard, 'timeseries'])
.as('timeseries')
})
.prefix('dashboard')
.as('dashboard')
@ -190,14 +161,6 @@ router
.get('import-batch/:id', [controllers.ImportBatches, 'show'])
.as('import-batch.show')
.where('id', router.matchers.uuid())
router
.get('import-batch/:id/drafts/:draftId/pdf', [
controllers.ImportBatches,
'draftPdf',
])
.as('import-batch.draft.pdf')
.where('id', router.matchers.uuid())
.where('draftId', router.matchers.uuid())
router
.post('import-batch/:id/drafts/:draftId/validate', [
controllers.ImportBatches,
@ -220,10 +183,6 @@ router
.where('id', router.matchers.uuid())
router.get(':id', [controllers.Invoices, 'show']).as('show').where('id', router.matchers.uuid())
router
.get(':id/pdf', [controllers.Invoices, 'pdf'])
.as('pdf')
.where('id', router.matchers.uuid())
router
.post(':id/mark-paid', [controllers.Invoices, 'markPaid'])
.as('mark-paid')

View File

@ -38,7 +38,6 @@
"lucide-react": "^0.475.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"recharts": "^3.8.1",
"sonner": "^1.7.4",
"tailwind-merge": "^3.0.1",
"zod": "^3.24.1"

View File

@ -1,44 +1,27 @@
import { cn } from "@/lib/utils";
import { env } from "@/lib/env";
/**
* Bouton SSO réutilisable (Google, Microsoft, ).
* Bouton "Continuer avec Google".
*
* IMPORTANT c'est un `<a href>`, PAS un fetch button :
* IMPORTANT c'est un `<a href>`, PAS un bouton fetch :
* OAuth nécessite un full-page redirect (le browser doit naviguer vers
* l'écran de consentement du provider avec ses cookies). Un fetch ne
* peut pas suivre les redirections cross-origin avec cookies.
* l'écran de consentement Google). Un fetch ne peut pas suivre les
* redirections cross-origin avec cookies.
*
* L'URL est construite avec VITE_API_URL pour fonctionner aussi bien
* en dev (http://localhost:3333) qu'en prod (https://app.rubis...) où
* nginx reverse-proxy /api/* vers le service rubis-api.
* L'URL est relative nginx (rubis-web) proxy /api/* vers rubis-api,
* donc même origine pour le browser cookie refresh posé par la
* callback est lisible côté SPA.
*/
type SsoProvider = "google" | "microsoft";
const LABELS: Record<SsoProvider, string> = {
google: "Continuer avec Google",
microsoft: "Continuer avec Microsoft",
};
const LOGOS: Record<SsoProvider, () => JSX.Element> = {
google: () => <GoogleLogo aria-hidden="true" />,
microsoft: () => <MicrosoftLogo aria-hidden="true" />,
};
export function SsoButton({
provider,
label,
export function GoogleButton({
label = "Continuer avec Google",
className,
}: {
provider: SsoProvider;
/** Override label (par défaut "Continuer avec X"). */
label?: string;
className?: string;
}) {
const Logo = LOGOS[provider];
return (
<a
href={`${env.VITE_API_URL}/api/v1/auth/${provider}/redirect`}
href="/api/v1/auth/google/redirect"
className={cn(
"inline-flex items-center justify-center gap-2.5 w-full",
"h-11 px-5 rounded-default border border-line bg-white",
@ -50,13 +33,13 @@ export function SsoButton({
className,
)}
>
<Logo />
{label ?? LABELS[provider]}
<GoogleLogo aria-hidden="true" />
{label}
</a>
);
}
/** Logo Google officiel — 4 couleurs, 18px. */
/** Logo Google officiel — 4 couleurs, taille fixe 18px. */
function GoogleLogo(props: React.SVGProps<SVGSVGElement>) {
return (
<svg width="18" height="18" viewBox="0 0 18 18" {...props}>
@ -80,19 +63,7 @@ function GoogleLogo(props: React.SVGProps<SVGSVGElement>) {
);
}
/** Logo Microsoft officiel — 4 carrés, 18px. */
function MicrosoftLogo(props: React.SVGProps<SVGSVGElement>) {
return (
<svg width="18" height="18" viewBox="0 0 23 23" {...props}>
<path fill="#F25022" d="M1 1h10v10H1z" />
<path fill="#7FBA00" d="M12 1h10v10H12z" />
<path fill="#00A4EF" d="M1 12h10v10H1z" />
<path fill="#FFB900" d="M12 12h10v10H12z" />
</svg>
);
}
/** Séparateur "ou" entre la pile SSO et le formulaire email/password. */
/** Séparateur "ou" entre le bouton SSO et le formulaire email/password. */
export function AuthDivider({ label = "ou" }: { label?: string }) {
return (
<div className="flex items-center gap-3 my-4">

View File

@ -1,68 +0,0 @@
import type { TooltipProps } from "recharts";
import { cn } from "@/lib/utils";
/**
* Tooltip thémé Rubis petite card cream + bordure rubis-glow.
* Reçoit en props un formatter pour personnaliser l'affichage des
* valeurs (ex. "1 240,00 €" pour les montants, "12 j" pour le DSO).
*/
type ChartTooltipProps = TooltipProps<number, string> & {
/** Convertit le label X (ex. "2026-04-01" → "avril 2026"). */
formatLabel?: (label: string) => string;
/** Convertit chaque valeur. Reçoit (value, dataKey). */
formatValue?: (value: number, dataKey: string) => string;
/** Label personnalisé pour chaque dataKey. Défaut : dataKey lui-même. */
seriesLabel?: Record<string, string>;
className?: string;
};
export function ChartTooltip({
active,
payload,
label,
formatLabel,
formatValue,
seriesLabel,
className,
}: ChartTooltipProps) {
if (!active || !payload || payload.length === 0) return null;
return (
<div
className={cn(
"rounded-default border border-rubis-glow bg-white px-3 py-2",
"shadow-card text-[12px] font-sans",
className,
)}
>
{label && (
<p className="font-display text-[12px] font-semibold text-ink mb-1 capitalize">
{formatLabel ? formatLabel(String(label)) : String(label)}
</p>
)}
<ul className="space-y-0.5">
{payload.map((p, i) => {
const dataKey = String(p.dataKey ?? p.name ?? i);
const value = typeof p.value === "number" ? p.value : Number(p.value);
return (
<li key={i} className="flex items-center gap-2 text-ink-2 tabular-nums">
<span
aria-hidden="true"
className="size-2 rounded-full"
style={{ backgroundColor: String(p.color) || "var(--color-rubis)" }}
/>
<span className="text-ink-3 mr-1">
{seriesLabel?.[dataKey] ?? dataKey} :
</span>
<strong className="font-semibold text-ink">
{formatValue
? formatValue(Number.isFinite(value) ? value : 0, dataKey)
: String(p.value)}
</strong>
</li>
);
})}
</ul>
</div>
);
}

View File

@ -1,91 +0,0 @@
import { useMemo } from "react";
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { formatEuros } from "@/lib/format";
import {
AXIS_TICK_STYLE,
chartColors,
formatMonthLong,
formatMonthShort,
} from "./theme";
import { ChartTooltip } from "./ChartTooltip";
/**
* Mini bar chart "encaissé par mois" pour la fiche client.
* Format compact (height 140) + barres rubis-glow / rubis selon que
* le mois a eu un paiement ou non. Pas d'axe Y, montants dans le tooltip.
*/
type ClientPaidChartProps = {
data: Array<{ month: string; encaisseCents: number; paidCount: number }>;
height?: number;
};
export function ClientPaidChart({ data, height = 140 }: ClientPaidChartProps) {
const c = chartColors();
// Si aucun paiement sur la période, on affiche un message — un chart vide
// (que des barres à 0) raconte rien.
const hasAnyPaid = useMemo(
() => data.some((d) => d.paidCount > 0),
[data],
);
if (!hasAnyPaid) {
return (
<div
className="flex items-center justify-center text-[12px] italic text-ink-3"
style={{ height }}
>
Aucun paiement reçu de ce client sur les 6 derniers mois.
</div>
);
}
return (
<ResponsiveContainer width="100%" height={height}>
<BarChart data={data} margin={{ top: 4, right: 4, left: 4, bottom: 0 }}>
<CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickFormatter={formatMonthShort}
axisLine={false}
tickLine={false}
tick={AXIS_TICK_STYLE}
dy={4}
/>
<YAxis hide domain={[0, "dataMax + 100"]} />
<Tooltip
content={
<ChartTooltip
formatLabel={formatMonthLong}
formatValue={(v, key) =>
key === "encaisseCents"
? formatEuros(v)
: `${v} facture${v > 1 ? "s" : ""}`
}
seriesLabel={{
encaisseCents: "Encaissé",
paidCount: "Paiements",
}}
/>
}
cursor={{ fill: c.cream2, opacity: 0.6 }}
/>
<Bar
dataKey="encaisseCents"
fill={c.rubis}
radius={[3, 3, 0, 0]}
maxBarSize={28}
/>
</BarChart>
</ResponsiveContainer>
);
}

View File

@ -1,105 +0,0 @@
import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
AXIS_TICK_STYLE,
chartColors,
formatMonthLong,
formatMonthShort,
} from "./theme";
import { ChartTooltip } from "./ChartTooltip";
/**
* DSO mensuel line chart en ink (pas rubis pour distinguer du chart
* encaissé). Une ReferenceLine pointillée à 30j sert de repère visuel
* (norme LME pour le délai de paiement standard B2B).
*/
type DsoTrendChartProps = {
data: Array<{ month: string; dsoDays: number; paidCount: number }>;
height?: number;
/** Référence à afficher en pointillé (30j = LME standard). */
reference?: number;
};
export function DsoTrendChart({
data,
height = 200,
reference = 30,
}: DsoTrendChartProps) {
const c = chartColors();
// On ne plotte pas les mois sans paiement — sinon la ligne tombe à 0
// et raconte une histoire fausse. On filtre ces points et on les
// gardera comme "trous" visuels (Recharts gère via connectNulls=false
// si on met null sur la valeur).
const enriched = data.map((d) => ({
...d,
dsoDays: d.paidCount > 0 ? d.dsoDays : null,
}));
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={enriched} margin={{ top: 10, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickFormatter={formatMonthShort}
axisLine={false}
tickLine={false}
tick={AXIS_TICK_STYLE}
dy={6}
/>
<YAxis
width={28}
axisLine={false}
tickLine={false}
tick={AXIS_TICK_STYLE}
tickFormatter={(v) => `${v}j`}
domain={[0, "dataMax + 5"]}
/>
<ReferenceLine
y={reference}
stroke={c.ink3}
strokeDasharray="4 4"
strokeWidth={1}
label={{
value: `Norme LME ${reference}j`,
position: "right",
fill: c.ink3,
fontSize: 10,
fontFamily: "var(--font-sans)",
}}
/>
<Tooltip
content={
<ChartTooltip
formatLabel={formatMonthLong}
formatValue={(v, key) =>
key === "dsoDays" ? `${Math.round(v)} jours` : String(v)
}
seriesLabel={{ dsoDays: "DSO moyen" }}
/>
}
cursor={{ stroke: c.ink3, strokeWidth: 1, strokeDasharray: "4 4" }}
/>
<Line
type="monotone"
dataKey="dsoDays"
stroke={c.ink}
strokeWidth={2}
dot={{ fill: c.ink, stroke: c.cream, strokeWidth: 2, r: 3 }}
activeDot={{ r: 5, stroke: c.cream, strokeWidth: 2 }}
connectNulls={false}
/>
</LineChart>
</ResponsiveContainer>
);
}

View File

@ -1,76 +0,0 @@
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { formatEuros } from "@/lib/format";
import {
AXIS_TICK_STYLE,
chartColors,
formatMonthLong,
formatMonthShort,
} from "./theme";
import { ChartTooltip } from "./ChartTooltip";
/**
* Encaissé mensuel area chart avec dégradé rubis.
* X = mois (avr, mai, juin), Y = euros encaissés.
* Pas d'axe Y visible : les valeurs sont dans le tooltip pour rester
* visuellement épuré (le client veut "j'ai gagné combien ce mois", pas
* une grille graduée).
*/
type EncaisseChartProps = {
data: Array<{ month: string; encaisseCents: number }>;
/** Hauteur fixe en px (défaut 220). */
height?: number;
};
export function EncaisseChart({ data, height = 220 }: EncaisseChartProps) {
const c = chartColors();
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 10, right: 8, left: 8, bottom: 0 }}>
<defs>
<linearGradient id="rubis-glow-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={c.rubis} stopOpacity={0.25} />
<stop offset="100%" stopColor={c.rubis} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke={c.line} strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="month"
tickFormatter={formatMonthShort}
axisLine={false}
tickLine={false}
tick={AXIS_TICK_STYLE}
dy={6}
/>
<YAxis hide domain={[0, "dataMax + 100"]} />
<Tooltip
content={
<ChartTooltip
formatLabel={formatMonthLong}
formatValue={(v) => formatEuros(v)}
seriesLabel={{ encaisseCents: "Encaissé" }}
/>
}
cursor={{ stroke: c.rubis, strokeWidth: 1, strokeDasharray: "4 4" }}
/>
<Area
type="monotone"
dataKey="encaisseCents"
stroke={c.rubis}
strokeWidth={2}
fill="url(#rubis-glow-gradient)"
dot={{ fill: c.rubis, stroke: c.cream, strokeWidth: 2, r: 3 }}
activeDot={{ r: 5, stroke: c.cream, strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
);
}

View File

@ -1,120 +0,0 @@
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { formatEuros } from "@/lib/format";
import { chartColors, STATUS_COLOR, STATUS_LABEL } from "./theme";
import { ChartTooltip } from "./ChartTooltip";
/**
* Pipeline factures par statut donut chart.
*
* Affiche le nombre de factures (count) par statut, avec un total
* centré au milieu du donut. Les montants sont dans le tooltip.
*
* Les statuts sans facture sont filtrés (sinon le donut a des slices
* de 0 qui chargent le visuel).
*/
type PipelineChartProps = {
data: Array<{ status: string; count: number; amountCents: number }>;
height?: number;
};
export function PipelineChart({ data, height = 220 }: PipelineChartProps) {
const c = chartColors();
const filtered = data.filter((d) => d.count > 0);
const total = filtered.reduce((s, d) => s + d.count, 0);
if (total === 0) {
return (
<div
className="flex items-center justify-center text-[12.5px] italic text-ink-3"
style={{ height }}
>
Aucune facture pour l'instant — créez-en une depuis l'écran factures.
</div>
);
}
return (
<div className="relative" style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Tooltip
content={
<ChartTooltip
formatValue={(v, key) =>
key === "count" ? `${v} factures` : formatEuros(v)
}
seriesLabel={{ count: "Nombre", amountCents: "Montant" }}
/>
}
/>
<Pie
data={filtered}
dataKey="count"
nameKey="status"
innerRadius="62%"
outerRadius="92%"
paddingAngle={2}
stroke={c.cream}
strokeWidth={2}
isAnimationActive={false}
>
{filtered.map((entry) => (
<Cell
key={entry.status}
fill={STATUS_COLOR[entry.status] ?? c.line}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
{/* Total centré au milieu du donut */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<p className="font-display text-[28px] font-bold tabular-nums text-ink leading-none">
{total}
</p>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-ink-3 mt-1">
Factures
</p>
</div>
</div>
);
}
/**
* Légende compacte à afficher à côté du donut. Liste les statuts non-zero
* avec leur couleur, count et montant.
*/
export function PipelineLegend({
data,
}: {
data: Array<{ status: string; count: number; amountCents: number }>;
}) {
const filtered = data.filter((d) => d.count > 0);
return (
<ul className="flex flex-col gap-1.5">
{filtered.map((d) => (
<li
key={d.status}
className="flex items-baseline gap-2 text-[12.5px]"
>
<span
aria-hidden="true"
className="size-2 rounded-full shrink-0 mt-1"
style={{ backgroundColor: STATUS_COLOR[d.status] }}
/>
<span className="text-ink-2 flex-1 truncate">
{STATUS_LABEL[d.status] ?? d.status}
</span>
<span className="font-display font-semibold text-ink tabular-nums">
{d.count}
</span>
<span className="text-ink-3 tabular-nums text-[11.5px]">
· {formatEuros(d.amountCents)}
</span>
</li>
))}
</ul>
);
}

View File

@ -1,81 +0,0 @@
/**
* Tokens et primitives partagés par tous les charts Rubis.
* On reste **strict** sur la palette : que des couleurs marque (rubis +
* neutres chauds), pas de bleu/vert/catégoriel générique de BI dashboard.
*/
import type { CSSProperties } from "react";
/** Lit la valeur d'une CSS custom-property posée par @theme dans app.css. */
function cssVar(name: string): string {
// Côté SSR / tests, document est absent — on fallback hex.
if (typeof window === "undefined") return "";
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
/**
* Couleurs résolues à l'usage (pas en module-scope car les CSS vars
* peuvent ne pas être prêtes au tout premier import). Cf. `chartColors()`.
*/
export function chartColors() {
return {
rubis: cssVar("--color-rubis") || "#9F1239",
rubisDeep: cssVar("--color-rubis-deep") || "#771328",
rubisLight: cssVar("--color-rubis-light") || "#C9415C",
rubisGlow: cssVar("--color-rubis-glow") || "#FBE4EA",
cream: cssVar("--color-cream") || "#FAF7F2",
cream2: cssVar("--color-cream-2") || "#F5EFE7",
line: cssVar("--color-line") || "#E8E0D6",
ink: cssVar("--color-ink") || "#1A1410",
ink2: cssVar("--color-ink-2") || "#4F4640",
ink3: cssVar("--color-ink-3") || "#8A7F76",
};
}
/**
* Couleur d'un statut de facture — réutilise l'identité des badges côté
* liste pour que pipeline charts et tableaux racontent la même histoire.
*/
export const STATUS_COLOR: Record<string, string> = {
pending: "#E8E0D6", // line — neutre, en attente
awaiting_user_confirmation: "#FBE4EA", // rubis-glow
in_relance: "#9F1239", // rubis primaire
litigation: "#1A1410", // ink — sérieux
paid: "#771328", // rubis-deep
};
export const STATUS_LABEL: Record<string, string> = {
pending: "En attente",
awaiting_user_confirmation: "Confirmation en cours",
in_relance: "En relance",
litigation: "Litige",
paid: "Encaissé",
};
/** Style label commun pour les axes (Inter, tabular-nums, ink-3). */
export const AXIS_TICK_STYLE: CSSProperties = {
fontFamily: "var(--font-sans)",
fontSize: 11,
fontFeatureSettings: '"tnum"',
fill: "var(--color-ink-3)",
};
/** Format mois "2026-04-01" → "avr" pour les ticks X compacts. */
const MONTH_FORMAT = new Intl.DateTimeFormat("fr-FR", { month: "short" });
export function formatMonthShort(monthIso: string): string {
// "2026-04-01" → date locale (no need for parseISO, Date est suffisant)
const d = new Date(monthIso);
if (Number.isNaN(d.getTime())) return monthIso;
return MONTH_FORMAT.format(d).replace(".", "");
}
/** Format mois long pour les tooltips. */
const MONTH_LONG = new Intl.DateTimeFormat("fr-FR", {
month: "long",
year: "numeric",
});
export function formatMonthLong(monthIso: string): string {
const d = new Date(monthIso);
if (Number.isNaN(d.getTime())) return monthIso;
return MONTH_LONG.format(d);
}

View File

@ -15,8 +15,7 @@ import { cn } from "@/lib/utils";
* - On laisse l'utilisateur déclarer l'intent.
*/
type KpiCardProps = {
/** Texte ou node — accepte un GlossaryTerm si la métrique a une définition. */
label: React.ReactNode;
label: string;
value: string;
delta?: string;
/** Sens du delta affiché (sert juste à colorer subtilement). Default neutral. */

View File

@ -1,224 +0,0 @@
import { useEffect, useState } from "react";
import { Play, Pause, X, Gauge } from "lucide-react";
import { cn } from "@/lib/utils";
import {
SPEED_OPTIONS,
type Speed,
useDemoEnd,
useDemoState,
useDemoTick,
} from "@/lib/demo";
import { Gem } from "@/components/brand/Gem";
import { DemoEmailSlide } from "./DemoEmailSlide";
/**
* Horloge virtuelle de démo visible top-right de _app, uniquement
* quand `org.demoMode = true`.
*
* Anatomie :
*
* vendredi 18 mai 2026
* J+5 / prochain
* [] 1x 2x 5x [] [×]
*
*
* - Date pleine, font display, mise à jour live à chaque frame
* - Rail rubis-glow avec une pastille qui glisse de virtualNow vers le
* prochain event (proportion calculée backend SPA)
* - Play/Pause + sélecteur de vitesse 1x/2x/5x
* - Bouton fermer = `/demo/end`
*
* Quand un event est déclenché, la slide-over droite s'ouvre avec
* l'email capturé. L'horloge est en pause tant que tous les events
* en attente n'ont pas é acquittés (clic "Continuer").
*/
const FR_DATE = new Intl.DateTimeFormat("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
});
export function DemoClock() {
const { data: state } = useDemoState();
const endMutation = useDemoEnd();
const enabled = state?.demoMode === true;
const tick = useDemoTick({
enabled,
initialVirtualNow: state?.virtualNow,
});
// Re-sync local virtualNow quand le backend change (start/reset)
useEffect(() => {
if (state?.virtualNow) tick.resetTo(state.virtualNow);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state?.virtualNow, enabled]);
if (!enabled) return null;
const dateStr = FR_DATE.format(tick.virtualNow);
// Progression vers le prochain event (0..1) — sert au rail visuel.
const progress = computeProgress({
virtualNow: tick.virtualNow,
nextEventAt: state?.nextEventAt ?? null,
});
const hasPending = tick.pendingEvents.length > 0;
return (
<>
<div
className={cn(
"fixed top-4 right-4 z-30 w-[300px]",
"rounded-card border border-rubis-glow bg-white shadow-card",
"px-4 py-3",
)}
>
{/* En-tête : date + tag DÉMO */}
<div className="flex items-start justify-between mb-2">
<div>
<p className="font-display text-[14.5px] font-bold leading-tight text-ink capitalize tabular-nums">
{dateStr}
</p>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold mt-0.5">
Mode démo · horloge virtuelle
</p>
</div>
<button
type="button"
onClick={() => endMutation.mutate()}
className="size-6 flex items-center justify-center rounded-full text-ink-3 hover:text-rubis-deep hover:bg-rubis-glow/40 transition-colors"
aria-label="Quitter le mode démo"
title="Quitter le mode démo"
>
<X size={14} />
</button>
</div>
{/* Rail rubis-glow avec pastille qui glisse */}
<div className="relative h-2 mb-3 mt-1">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-line" />
<div
className="absolute top-1/2 -translate-y-1/2 h-px bg-rubis transition-[width] duration-300"
style={{ width: `${progress * 100}%` }}
/>
<span
aria-hidden="true"
className="absolute -top-1 size-3 rotate-45 bg-rubis shadow-rubis transition-[left] duration-300"
style={{ left: `calc(${progress * 100}% - 6px)` }}
/>
<Gem
size={10}
aria-hidden="true"
className="absolute -top-1 -left-0.5"
/>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => (tick.playing ? tick.pause() : tick.play())}
disabled={hasPending}
className={cn(
"size-9 flex items-center justify-center rounded-default",
"bg-rubis text-white shadow-rubis hover:bg-rubis-deep transition-colors",
"disabled:bg-line disabled:text-ink-3 disabled:shadow-none disabled:cursor-not-allowed",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
aria-label={tick.playing ? "Pause" : "Lecture"}
>
{tick.playing ? <Pause size={14} /> : <Play size={14} className="ml-0.5" />}
</button>
<SpeedSelector value={tick.speed} onChange={tick.setSpeed} />
<p className="ml-auto text-[10.5px] text-ink-3 italic tabular-nums">
{state?.nextEventAt
? `${shortNextLabel(tick.virtualNow, state.nextEventAt)}`
: "aucun event en file"}
</p>
</div>
</div>
{/* Slide-over : empile les events fired à acquitter un par un */}
{hasPending && (
<DemoEmailSlide
event={tick.pendingEvents[0]!}
remaining={tick.pendingEvents.length - 1}
virtualNow={tick.virtualNow}
onContinue={tick.acknowledge}
/>
)}
</>
);
}
function SpeedSelector({
value,
onChange,
}: {
value: Speed;
onChange: (s: Speed) => void;
}) {
return (
<div
role="radiogroup"
aria-label="Vitesse"
className="inline-flex items-center gap-1 rounded-default border border-line bg-cream-2/40 px-1.5 py-0.5"
>
<Gauge size={11} className="text-ink-3" aria-hidden="true" />
{SPEED_OPTIONS.map((s) => {
const active = s === value;
return (
<button
key={s}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(s)}
className={cn(
"h-6 px-1.5 rounded-sharp text-[11px] font-semibold tabular-nums transition-colors",
active
? "bg-rubis text-white"
: "text-ink-2 hover:bg-cream-2",
)}
>
{s}x
</button>
);
})}
</div>
);
}
function computeProgress({
virtualNow,
nextEventAt,
}: {
virtualNow: Date;
nextEventAt: string | null;
}): number {
if (!nextEventAt) return 0;
const next = new Date(nextEventAt).getTime();
// On affiche la progression sur une fenêtre de 30 jours autour du prochain event
// (évite que la pastille soit collée à 0% ou 100% en permanence).
const start = next - 30 * 86400000;
const now = virtualNow.getTime();
if (now <= start) return 0;
if (now >= next) return 1;
return (now - start) / (next - start);
}
function shortNextLabel(now: Date, iso: string): string {
const next = new Date(iso).getTime();
const diffMs = next - now.getTime();
if (diffMs <= 0) return "imminent";
const days = Math.round(diffMs / 86400000);
if (days <= 0) return "aujourd'hui";
return `dans ${days} j`;
}

View File

@ -1,425 +0,0 @@
import { useEffect, useState } from "react";
import { Mail, ArrowRight, X, Check, AlertCircle, ExternalLink, Calendar, FileText } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { toast } from "sonner";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import { queryKeysDemo } from "@/lib/demo";
import { cn } from "@/lib/utils";
import { formatEuros, formatDate, formatDueDelta, isOverdue } from "@/lib/format";
import type { DemoCapturedEmail, FiredEvent } from "@/lib/demo";
import { Button } from "@/components/ui/Button";
import { StatusBadge } from "@/components/ui/StatusBadge";
/**
* Forme minimale du retour `/api/v1/invoices/:id` qu'on consomme.
* On ne charge pas la timeline ici pas utile pour le slide démo.
*/
type InvoiceDetail = {
id: string;
numero: string;
clientName: string;
amountTtcCents: number;
issueDate: string;
dueDate: string;
status:
| "pending"
| "in_relance"
| "awaiting_user_confirmation"
| "paid"
| "litigation"
| "cancelled";
planName: string | null;
};
/**
* Slide-over droite déclenchée à chaque event fired pendant la démo.
*
* Flow narratif en 2 étapes :
*
* Étape 1 "Cette facture a-t-elle été payée ?"
* Oui Non
* mark facture paid on passe à l'étape 2
* cancel relances futures voir l'email envoyé
* écran "encaissée"
*
* Étape 2 Preview de l'email (Non) OU confirmation paiement (Oui)
*
* Le clic sur "Continuer la démo" reprend l'horloge.
*/
const FR_DATETIME = new Intl.DateTimeFormat("fr-FR", {
weekday: "long",
day: "numeric",
month: "long",
});
type Step = "ask" | "email" | "paid";
export function DemoEmailSlide({
event,
remaining,
virtualNow,
onContinue,
}: {
event: FiredEvent;
remaining: number;
virtualNow: Date;
onContinue: () => void;
}) {
const qc = useQueryClient();
const [step, setStep] = useState<Step>("ask");
const [email, setEmail] = useState<DemoCapturedEmail | null>(null);
const [marking, setMarking] = useState(false);
// Détail de la facture concernée (montant, dates, client, plan).
const { data: invoice } = useQuery({
queryKey: queryKeys.invoices.detail(event.invoiceId),
queryFn: () => api.get<InvoiceDetail>(`/api/v1/invoices/${event.invoiceId}`),
staleTime: 30_000,
});
// Reset à chaque nouvel event
useEffect(() => {
setStep("ask");
setEmail(null);
}, [event.taskId]);
// Charge l'email capturé en arrière-plan, pour qu'il soit prêt si on
// passe à l'étape 'email'.
useEffect(() => {
if (!event.capturedEmailId) return;
let cancelled = false;
void api
.get<DemoCapturedEmail[]>("/api/v1/demo/inbox")
.then((list) => {
if (cancelled) return;
const found = list.find((e) => e.id === event.capturedEmailId);
setEmail(found ?? null);
})
.catch(() => setEmail(null));
return () => {
cancelled = true;
};
}, [event.capturedEmailId]);
const onMarkPaid = async () => {
setMarking(true);
try {
await api.post(`/api/v1/invoices/${event.invoiceId}/mark-paid`);
toast.success(`${event.invoiceNumero} marquée encaissée. + 1 rubis.`);
// Rafraîchit l'écosystème (dashboard, factures, charts) — le user
// voit la valeur du check-in en direct.
void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() });
void qc.invalidateQueries({ queryKey: queryKeys.invoices.all() });
void qc.invalidateQueries({ queryKey: queryKeysDemo.state() });
setStep("paid");
} catch {
toast.error("Impossible de marquer la facture payée.");
} finally {
setMarking(false);
}
};
const isRelance = event.kind === "relance";
return (
<>
{/* Backdrop cliquable pour fermer */}
<button
type="button"
aria-label="Fermer"
onClick={onContinue}
className="fixed inset-0 z-40 bg-ink/10 backdrop-blur-[2px] cursor-default"
/>
<aside
role="dialog"
aria-label="Émission Rubis pendant la démo"
className={cn(
"fixed top-0 right-0 z-50 h-screen w-full max-w-[520px]",
"bg-cream border-l border-line shadow-card flex flex-col",
"animate-in slide-in-from-right duration-200",
)}
>
{/* Header — titre adapté à l'event */}
<div className="flex items-center gap-3 border-b border-line bg-white px-5 py-4">
<span
className={cn(
"shrink-0 size-9 flex items-center justify-center rounded-full",
isRelance ? "bg-rubis text-white" : "bg-rubis-glow text-rubis-deep",
)}
>
<Mail size={15} />
</span>
<div className="flex-1 min-w-0">
<p className="font-display text-[14.5px] font-bold text-ink leading-tight">
{isRelance ? "Relance" : "Confirmation"} de Facture {event.invoiceNumero}
</p>
<p className="text-[11.5px] text-ink-3 capitalize">
{FR_DATETIME.format(virtualNow)}
</p>
</div>
<button
type="button"
onClick={onContinue}
className="size-7 flex items-center justify-center rounded-full text-ink-3 hover:bg-cream-2"
aria-label="Fermer"
>
<X size={14} />
</button>
</div>
{/* Body — change selon l'étape */}
<div className="flex-1 overflow-y-auto px-5 py-5 space-y-5">
{/* Card facture — toujours visible : contexte + lien vers la fiche */}
<InvoiceCard invoice={invoice ?? null} fallbackNumero={event.invoiceNumero} onNavigate={onContinue} />
{step === "ask" && (
<AskStep
isRelance={isRelance}
marking={marking}
onYes={onMarkPaid}
onNo={() => setStep("email")}
/>
)}
{step === "email" && <EmailStep email={email} />}
{step === "paid" && <PaidStep invoiceNumero={event.invoiceNumero} />}
</div>
{/* Footer — uniquement à l'étape email/paid (l'étape ask a ses propres boutons) */}
{step !== "ask" && (
<div className="border-t border-line bg-white px-5 py-4 flex items-center justify-between">
<p className="text-[11.5px] text-ink-3 italic">
{remaining > 0
? `${remaining} autre${remaining > 1 ? "s" : ""} event${remaining > 1 ? "s" : ""} en file`
: "Cliquez pour reprendre l'horloge"}
</p>
<Button size="sm" onClick={onContinue}>
Continuer la démo <ArrowRight size={14} />
</Button>
</div>
)}
</aside>
</>
);
}
/** Étape 1 — la question. */
function AskStep({
isRelance,
marking,
onYes,
onNo,
}: {
isRelance: boolean;
marking: boolean;
onYes: () => void;
onNo: () => void;
}) {
return (
<div className="flex flex-col items-start gap-4">
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold mb-2">
Avant d'envoyer
</p>
<h3 className="font-display text-[22px] font-bold tracking-[-0.018em] text-ink leading-tight">
Avez-vous é payé sur cette facture ?
</h3>
<p className="mt-2 text-[13px] text-ink-2 leading-relaxed">
{isRelance
? "Rubis est sur le point de relancer votre client. Si la facture vient d'être réglée, on évite l'email inutile et on encaisse +1 rubis."
: "Rubis s'apprête à vous demander confirmation. Si vous savez déjà qu'elle est payée, on saute cette étape."}
</p>
</div>
<div className="flex flex-col gap-2.5 w-full">
<Button
size="md"
variant="primary"
loading={marking}
onClick={onYes}
className="w-full justify-start"
>
<Check size={15} aria-hidden="true" />
Oui la facture est payée
</Button>
<Button
size="md"
variant="secondary"
onClick={onNo}
className="w-full justify-start"
>
<AlertCircle size={15} aria-hidden="true" />
Non toujours en attente
</Button>
</div>
<p className="text-[11.5px] text-ink-3 italic leading-snug">
En conditions réelles, l'email de confirmation pose cette question au
client final. Ici on raccourcit pour la démo.
</p>
</div>
);
}
/**
* Card de contexte facture visible en permanence dans le slide-over.
* Cliquable : ouvre la fiche facture dans la vraie app (ferme la slide,
* l'horloge reste en pause pour qu'on puisse revenir).
*/
function InvoiceCard({
invoice,
fallbackNumero,
onNavigate,
}: {
invoice: InvoiceDetail | null;
fallbackNumero: string;
onNavigate: () => void;
}) {
if (!invoice) {
return (
<div className="rounded-card border border-line bg-white px-4 py-3">
<p className="text-[12px] text-ink-3 italic">
Chargement de la facture {fallbackNumero}
</p>
</div>
);
}
const dueLabel = formatDueDelta(invoice.dueDate);
const isLate = isOverdue(invoice.dueDate) && invoice.status !== "paid";
return (
<Link
to="/factures/$id"
params={{ id: invoice.id }}
onClick={onNavigate}
className={cn(
"block rounded-card border border-line bg-white px-4 py-3.5",
"transition-[border-color,box-shadow,transform] duration-150",
"hover:border-rubis hover:shadow-soft hover:-translate-y-px",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
aria-label={`Voir la fiche de la facture ${invoice.numero}`}
>
{/* Header : numéro + statut + flèche externe */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5">
<FileText size={13} className="text-ink-3 shrink-0" aria-hidden="true" />
<p className="font-display text-[14px] font-semibold tracking-tight text-ink truncate">
{invoice.numero}
</p>
</div>
<p className="text-[12.5px] text-ink-2 truncate">{invoice.clientName}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={invoice.status} withoutIcon />
<ExternalLink size={13} className="text-ink-3" aria-hidden="true" />
</div>
</div>
{/* Montant en gros + dates */}
<div className="flex items-end justify-between gap-3">
<p className="font-display text-[22px] font-bold tabular-nums leading-none text-ink">
{formatEuros(invoice.amountTtcCents)}
</p>
<div className="text-right">
<div className="flex items-center gap-1 justify-end text-[11.5px] text-ink-3 tabular-nums">
<Calendar size={11} aria-hidden="true" />
<span>
Émise {formatDate(invoice.issueDate)} · échue{" "}
{formatDate(invoice.dueDate)}
</span>
</div>
<p
className={cn(
"mt-0.5 text-[11.5px] font-medium tabular-nums",
isLate ? "text-rubis-deep" : "text-ink-3",
)}
>
{dueLabel}
</p>
</div>
</div>
{invoice.planName && (
<p className="mt-3 pt-3 border-t border-line text-[11.5px] text-ink-3">
Plan : <strong className="font-medium text-ink-2">{invoice.planName}</strong>
</p>
)}
</Link>
);
}
/** Étape 2A — preview de l'email envoyé (réponse "Non"). */
function EmailStep({ email }: { email: DemoCapturedEmail | null }) {
if (!email) {
return (
<p className="text-[13px] italic text-ink-3 text-center py-8">
Chargement de l'email
</p>
);
}
return (
<article className="rounded-card border border-line bg-white shadow-soft overflow-hidden">
<div className="border-b border-line bg-cream-2/50 px-5 py-3 space-y-1">
<p className="text-[11.5px] text-ink-3">
<span className="font-semibold text-ink-2">De :</span>{" "}
{email.from.name} &lt;{email.from.email}&gt;
</p>
<p className="text-[11.5px] text-ink-3">
<span className="font-semibold text-ink-2">À :</span>{" "}
{email.to.name ? `${email.to.name}` : ""}
{email.to.email}
</p>
{email.replyTo && (
<p className="text-[11.5px] text-ink-3">
<span className="font-semibold text-ink-2">Reply-To :</span>{" "}
{email.replyTo}
</p>
)}
<p className="font-display text-[15px] font-bold text-ink mt-1.5 leading-tight">
{email.subject}
</p>
</div>
<div className="px-5 py-4">
<pre className="whitespace-pre-wrap font-sans text-[13.5px] leading-relaxed text-ink-2">
{email.body}
</pre>
</div>
</article>
);
}
/** Étape 2B — confirmation paiement (réponse "Oui"). */
function PaidStep({ invoiceNumero }: { invoiceNumero: string }) {
return (
<div className="flex flex-col items-start gap-4 py-4">
<div className="size-12 rounded-full bg-rubis-glow flex items-center justify-center text-rubis-deep">
<Check size={22} strokeWidth={2.5} aria-hidden="true" />
</div>
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold mb-1">
Encaissée
</p>
<h3 className="font-display text-[22px] font-bold tracking-[-0.018em] text-ink leading-tight">
Facture <em className="text-rubis not-italic">{invoiceNumero}</em>{" "}
marquée payée
</h3>
<p className="mt-3 text-[13.5px] text-ink-2 leading-relaxed">
Les relances futures de cette facture sont annulées
automatiquement. Pas d'email inutile envoyé à votre client.
Vous gagnez{" "}
<strong className="text-rubis-deep">+1 rubis</strong> pour les 10 minutes
que Rubis vient de vous économiser.
</p>
</div>
<p className="text-[11.5px] text-ink-3 italic leading-snug">
Regardez le dashboard derrière : le compteur a déjà bougé.
</p>
</div>
);
}

View File

@ -1,102 +0,0 @@
import { useState } from "react";
import { Sparkles, Power } from "lucide-react";
import { toast } from "sonner";
import { useDemoEnd, useDemoStart, useDemoState } from "@/lib/demo";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
/**
* Bouton "Mode démo" pour /parametres.
*
* Active le flag `demo_mode` sur l'org du user, qui :
* - Diverte les emails vers une boîte capturée (pas de Resend)
* - Active l'horloge virtuelle (top-right)
* - Permet d'avancer le temps en accéléré
*
* À désactiver après une démo pour repasser en prod normale.
*/
export function DemoToggle() {
const { data: state } = useDemoState();
const startMutation = useDemoStart();
const endMutation = useDemoEnd();
const [confirmEnd, setConfirmEnd] = useState(false);
const isDemo = state?.demoMode === true;
if (isDemo) {
return (
<Card padding="md" className="border-rubis bg-rubis-glow/30">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold">
Mode démo actif
</p>
<p className="mt-1 font-display text-[16px] font-bold text-ink">
L'horloge virtuelle tourne
</p>
<p className="mt-1.5 text-[12.5px] text-ink-2 leading-snug max-w-md">
Les emails sont capturés dans la boîte démo (pas envoyés à de
vrais clients). Vos KPIs et le DSO suivent l'horloge virtuelle.
Quittez le mode démo pour revenir au temps réel.
</p>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
{confirmEnd ? (
<>
<p className="text-[12.5px] text-ink-2">
Sûr ? L'inbox démo sera conservée mais l'horloge revient au
temps réel.
</p>
<Button
variant="danger"
size="sm"
loading={endMutation.isPending}
onClick={() => {
endMutation.mutate(undefined, {
onSuccess: () => {
toast.success("Mode démo désactivé.");
setConfirmEnd(false);
},
});
}}
>
<Power size={13} /> Quitter le mode démo
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirmEnd(false)}>
Annuler
</Button>
</>
) : (
<Button variant="secondary" size="sm" onClick={() => setConfirmEnd(true)}>
<Power size={13} /> Quitter le mode démo
</Button>
)}
</div>
</Card>
);
}
return (
<Card padding="md">
<p className="text-[12.5px] text-ink-2 leading-snug max-w-md mb-3">
Active une horloge virtuelle qui permet d'avancer dans le temps en
accéléré. Idéal pour montrer Rubis en condition réelle pendant une
démo : les emails apparaissent en direct, ne partent pas chez de vrais
clients.
</p>
<Button
size="sm"
loading={startMutation.isPending}
onClick={() => {
startMutation.mutate(undefined, {
onSuccess: () => toast.success("Mode démo activé. Horloge en pause."),
});
}}
>
<Sparkles size={13} /> Démarrer une démo
</Button>
</Card>
);
}

View File

@ -1,84 +1,23 @@
import { useEffect, useState } from "react";
import { FileText, AlertCircle } from "lucide-react";
import { api, ApiError } from "@/lib/api";
import { FileText } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Aperçu du fichier importé (PDF / image) utilisé sur :
* - la review OCR (volet gauche, source = batch + draft)
* - la fiche facture (source = invoice id direct)
* Aperçu PDF côté review OCR placeholder visuel.
*
* Fetch via api.fetchBlob (Bearer auto-injecté) object URL <iframe>
* pour les PDF (viewer Chrome/Safari natif), <img> pour les images.
* Fallback "barres" si pdfAvailable=false.
* V1 : affiche le nom du fichier + une "preview" abstraite (barres) qui
* suggèrent un document. Le vrai render PDF (via react-pdf, pdf.js ou un
* iframe avec object URL) viendra quand le backend stockera réellement
* les fichiers dans MinIO.
*
* Anti-IA-look : pas un viewer générique gris/blanc fond cream-2 avec
* des barres rubis-glow pour suggérer le contenu et garder l'identité.
*/
type PdfPreviewProps = {
filename: string;
/** Source 1 : draft d'un import en cours (batchId + draftId). */
batchId?: string;
draftId?: string;
/** Source 2 : facture validée (invoiceId direct). Prioritaire. */
invoiceId?: string;
/** Indique si le backend a effectivement un fichier (sinon fallback). */
pdfAvailable?: boolean;
className?: string;
};
export function PdfPreview({
filename,
batchId,
draftId,
invoiceId,
pdfAvailable = true,
className,
}: PdfPreviewProps) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [contentType, setContentType] = useState<string>("application/pdf");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!pdfAvailable) {
setObjectUrl(null);
return;
}
// Source : invoiceId prioritaire, sinon batch+draft.
const path = invoiceId
? `/api/v1/invoices/${invoiceId}/pdf`
: batchId && draftId
? `/api/v1/invoices/import-batch/${batchId}/drafts/${draftId}/pdf`
: null;
if (!path) {
setObjectUrl(null);
return;
}
const controller = new AbortController();
let url: string | null = null;
setError(null);
api
.fetchBlob(path, controller.signal)
.then(({ blob, contentType: ct }) => {
url = URL.createObjectURL(blob);
setObjectUrl(url);
setContentType(ct);
})
.catch((err) => {
if (controller.signal.aborted) return;
if (err instanceof ApiError && err.status === 404) {
setError("Aperçu indisponible.");
} else {
setError("Impossible de charger l'aperçu.");
}
});
return () => {
controller.abort();
if (url) URL.revokeObjectURL(url);
};
}, [batchId, draftId, invoiceId, pdfAvailable]);
const isImage = contentType.startsWith("image/");
export function PdfPreview({ filename, className }: PdfPreviewProps) {
return (
<div
className={cn(
@ -93,72 +32,37 @@ export function PdfPreview({
</span>
</div>
<div className="flex-1 bg-cream/60 min-h-[420px] relative">
{objectUrl ? (
isImage ? (
<img
src={objectUrl}
alt={`Aperçu ${filename}`}
className="w-full h-full max-h-[680px] object-contain bg-white"
/>
) : (
<iframe
src={objectUrl}
title={`Aperçu ${filename}`}
className="w-full h-full min-h-[680px] border-0"
/>
)
) : error ? (
<div className="flex flex-col items-center justify-center gap-2 h-full p-7 text-ink-3">
<AlertCircle size={20} className="text-rubis-deep" />
<p className="text-[13px]">{error}</p>
<p className="text-[11.5px] italic">Vous pouvez quand même valider à partir des champs OCR.</p>
</div>
) : pdfAvailable && batchId && draftId ? (
<div className="flex items-center justify-center h-full p-7">
<div
className="size-6 animate-spin rounded-full border-2 border-rubis-glow border-t-rubis"
aria-label="Chargement de l'aperçu"
/>
</div>
) : (
<PdfPlaceholder />
)}
{/* Pseudo-page A4 ratio. Les "barres" simulent du contenu OCRisé. */}
<div className="flex-1 bg-cream/60 p-7 min-h-[420px]">
<div className="space-y-2.5">
<div className="h-3 w-1/2 rounded-sharp bg-ink/15" />
<div className="h-3 w-1/3 rounded-sharp bg-ink/8" />
</div>
<div className="mt-7 space-y-2">
<div className="h-2.5 w-3/4 rounded-sharp bg-ink/10" />
<div className="h-2.5 w-2/3 rounded-sharp bg-ink/10" />
<div className="h-2.5 w-1/2 rounded-sharp bg-ink/10" />
</div>
<div className="mt-9 grid grid-cols-2 gap-3">
<div className="h-7 rounded-sharp bg-rubis-glow/70" />
<div className="h-7 rounded-sharp bg-rubis-glow/70" />
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div className="h-7 rounded-sharp bg-cream-2" />
<div className="h-7 rounded-sharp bg-cream-2" />
</div>
<div className="mt-9 space-y-2">
<div className="h-2.5 w-2/3 rounded-sharp bg-ink/10" />
<div className="h-2.5 w-1/2 rounded-sharp bg-ink/10" />
</div>
<div className="mt-7 flex justify-end">
<div className="h-9 w-1/3 rounded-sharp bg-ink/15" />
</div>
</div>
</div>
);
}
/**
* Placeholder utilisé quand aucun PDF n'est dispo (mocks MSW, ou
* import sans fichier réel). Garde l'identité visuelle.
*/
function PdfPlaceholder() {
return (
<div className="p-7">
<div className="space-y-2.5">
<div className="h-3 w-1/2 rounded-sharp bg-ink/15" />
<div className="h-3 w-1/3 rounded-sharp bg-ink/8" />
</div>
<div className="mt-7 space-y-2">
<div className="h-2.5 w-3/4 rounded-sharp bg-ink/10" />
<div className="h-2.5 w-2/3 rounded-sharp bg-ink/10" />
<div className="h-2.5 w-1/2 rounded-sharp bg-ink/10" />
</div>
<div className="mt-9 grid grid-cols-2 gap-3">
<div className="h-7 rounded-sharp bg-rubis-glow/70" />
<div className="h-7 rounded-sharp bg-rubis-glow/70" />
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<div className="h-7 rounded-sharp bg-cream-2" />
<div className="h-7 rounded-sharp bg-cream-2" />
</div>
<div className="mt-9 space-y-2">
<div className="h-2.5 w-2/3 rounded-sharp bg-ink/10" />
<div className="h-2.5 w-1/2 rounded-sharp bg-ink/10" />
</div>
<p className="mt-7 text-[11px] italic text-ink-3 text-center">
Aperçu non disponible pour cette source.
<p className="border-t border-line bg-cream-2/40 px-4 py-2 text-[11px] italic text-ink-3">
Aperçu simplifié le rendu PDF complet arrivera avec le vrai pipeline
d&apos;import.
</p>
</div>
);

View File

@ -7,7 +7,6 @@ import { Button } from "@/components/ui/Button";
import { AppSidebar } from "./AppSidebar";
import { AppTopbar } from "./AppTopbar";
import { MobileTabBar } from "./MobileTabBar";
import { DemoClock } from "@/components/demo/DemoClock";
import { Link } from "@tanstack/react-router";
import {
ManualInvoiceProvider,
@ -96,9 +95,6 @@ function AppLayoutInner({ children, title, subtitle, actions }: AppLayoutProps)
</div>
<MobileTabBar />
{/* Horloge démo — auto-cachée si org.demoMode = false */}
<DemoClock />
</div>
);
}

View File

@ -5,7 +5,6 @@ import {
ListChecks,
Users,
Settings,
TrendingUp,
} from "lucide-react";
import { Brand } from "@/components/brand/Brand";
@ -37,7 +36,6 @@ export function AppSidebar({ rubisThisMonth = 0 }: { rubisThisMonth?: number })
<NavLink to="/factures" icon={<FileText size={17} />} label="Factures" />
<NavLink to="/plans" icon={<ListChecks size={17} />} label="Plans de relance" />
<NavLink to="/clients" icon={<Users size={17} />} label="Clients" />
<NavLink to="/insights" icon={<TrendingUp size={17} />} label="Insights" />
<NavLink to="/parametres" icon={<Settings size={17} />} label="Paramètres" />
</nav>

View File

@ -84,7 +84,7 @@ export function AiGenerateModal({
<DialogContent maxWidth={720}>
<DialogHeader>
<DialogTitle>
<span className="inline-flex items-center gap-2 border border-rubis">
<span className="inline-flex items-center gap-2">
<Sparkles size={16} className="text-rubis" /> Générer avec l'IA
</span>
</DialogTitle>

View File

@ -1,80 +0,0 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Asterisk } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Term wrapper affiche un terme avec une petite astérisque cliquable/hoverable
* qui révèle sa définition. Pour le glossaire métier (DSO, LME, mise en demeure).
*
* Pas de tooltip natif (`title=`) parce qu'il s'affiche en gris système et
* casse la DA. Radix Tooltip est déjà dans la stack pour Dialog, on en
* profite ici.
*/
type GlossaryTermProps = {
/** Le mot/expression visible (ex. "DSO moyen"). */
children: React.ReactNode;
/** Définition affichée dans le tooltip. */
definition: React.ReactNode;
/** Côté du tooltip (défaut bottom). */
side?: "top" | "right" | "bottom" | "left";
/** Classe sur le wrapper. */
className?: string;
/** Position de l'astérisque (défaut : after = à droite). */
marker?: "before" | "after";
};
export function GlossaryTerm({
children,
definition,
side = "bottom",
className,
marker = "after",
}: GlossaryTermProps) {
const star = (
<Asterisk
size={9}
strokeWidth={2.5}
aria-hidden="true"
className="inline-block text-rubis align-text-top translate-y-[1px]"
/>
);
return (
<TooltipPrimitive.Provider delayDuration={200}>
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>
<span
className={cn(
"inline-flex items-center gap-0.5 cursor-help",
"underline-offset-4 decoration-dotted decoration-ink-3/40",
"hover:decoration-rubis hover:decoration-solid",
className,
)}
tabIndex={0}
>
{marker === "before" && star}
{children}
{marker === "after" && star}
</span>
</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
side={side}
sideOffset={6}
collisionPadding={12}
className={cn(
"z-50 max-w-[280px] rounded-default border border-rubis-glow bg-white",
"px-3 py-2 text-[12.5px] leading-snug text-ink-2",
"shadow-card",
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
)}
>
{definition}
<TooltipPrimitive.Arrow className="fill-white" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
);
}

View File

@ -1,95 +0,0 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Pagination minimale précédent / page courante / suivant. Pas de jump
* direct vers une page (V1 : on n'en a pas besoin, les listes sont triées
* par actionnabilité, l'user lit le top puis pagine séquentiellement).
*
* S'efface si une seule page (ou zéro) — pas la peine d'encombrer.
*/
type PaginationProps = {
page: number;
total: number;
pageSize: number;
onPageChange: (next: number) => void;
className?: string;
/** Label personnalisé (ex: "factures", "clients"). Utilisé pour le compteur. */
itemLabel?: string;
};
export function Pagination({
page,
total,
pageSize,
onPageChange,
className,
itemLabel = "lignes",
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1) return null;
const start = (page - 1) * pageSize + 1;
const end = Math.min(page * pageSize, total);
return (
<nav
aria-label="Pagination"
className={cn(
"flex items-center justify-between gap-3 px-1 py-3",
className,
)}
>
<p className="text-[12.5px] text-ink-3 tabular-nums">
<span className="font-medium text-ink-2">
{start.toLocaleString("fr-FR")}{end.toLocaleString("fr-FR")}
</span>{" "}
sur {total.toLocaleString("fr-FR")} {itemLabel}
</p>
<div className="flex items-center gap-1">
<PageButton
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
aria-label="Page précédente"
>
<ChevronLeft size={14} aria-hidden="true" />
</PageButton>
<span className="px-2 text-[12.5px] text-ink-2 tabular-nums">
{page} / {totalPages}
</span>
<PageButton
disabled={page >= totalPages}
onClick={() => onPageChange(page + 1)}
aria-label="Page suivante"
>
<ChevronRight size={14} aria-hidden="true" />
</PageButton>
</div>
</nav>
);
}
function PageButton({
disabled,
onClick,
children,
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
"size-8 inline-flex items-center justify-center rounded-sharp border border-line bg-white text-ink-2",
"transition-[background-color,border-color,color] duration-100",
"hover:border-rubis hover:text-rubis cursor-pointer",
"disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-line disabled:hover:text-ink-2",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
)}
{...rest}
>
{children}
</button>
);
}

View File

@ -34,25 +34,6 @@ type RequestOptions = {
signal?: AbortSignal;
/** Si true, n'inclut pas le header Authorization (utile pour /auth/login). */
anonymous?: boolean;
/**
* Si true, ne déroule pas l'enveloppe `{ data, meta }` utile pour les
* endpoints paginés qui ont besoin de meta.total / meta.page.
*/
envelope?: boolean;
};
/**
* Méta-info standard renvoyée par les endpoints paginés. Mappe le
* `meta: { total, page }` que l'API Adonis renvoie aux côtés de `data`.
*/
export type PaginationMeta = {
total: number;
page: number;
};
export type ListResponse<T> = {
data: T[];
meta?: PaginationMeta;
};
/**
@ -140,11 +121,9 @@ async function rawRequest<T>(path: string, options: RequestOptions = {}): Promis
);
}
// Convention de réponse Adonis : { data: ..., meta?: ... }. Par défaut on
// extrait `data` (contrat documenté). Si le caller demande l'enveloppe
// (`envelope: true`), on renvoie le json tel quel — utile pour récupérer
// `meta` (total, page) sur les endpoints paginés.
if (options.envelope) return json as T;
// Convention de réponse Adonis : { data: ..., meta?: ... }. On extrait
// `data` quand il est présent (contrat documenté), sinon on renvoie
// le body tel quel (cas rare : endpoint qui retourne un objet plat).
return (json?.data ?? json) as T;
}
@ -190,16 +169,6 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
export const api = {
get: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
request<T>(path, { ...options, method: "GET" }),
/**
* Variante GET qui renvoie l'enveloppe `{ data, meta }` complète pour
* les endpoints paginés on a besoin de `meta.total` afin de rendre
* un compteur "X factures" et des contrôles précédent/suivant.
*/
getList: <T>(
path: string,
options?: Omit<RequestOptions, "method" | "body" | "envelope">,
): Promise<ListResponse<T>> =>
request<ListResponse<T>>(path, { ...options, method: "GET", envelope: true }),
post: <T>(
path: string,
body?: unknown,
@ -212,23 +181,4 @@ export const api = {
): Promise<T> => request<T>(path, { ...options, method: "PATCH", body }),
delete: <T>(path: string, options?: Omit<RequestOptions, "method" | "body">): Promise<T> =>
request<T>(path, { ...options, method: "DELETE" }),
/**
* Fetch d'un binaire (PDF, image) avec le Bearer auto-injecté. Renvoie
* un Blob + le content-type. Pas de silent refresh sur 401 (cas rare
* pour des assets longue durée), si besoin re-fetch côté caller.
*/
fetchBlob: async (path: string, signal?: AbortSignal): Promise<{ blob: Blob; contentType: string }> => {
const url = path.startsWith("http") ? path : `${env.VITE_API_URL}${path}`;
const res = await fetch(url, {
headers: authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {},
credentials: "include",
signal,
});
if (!res.ok) {
throw new ApiError(res.status, "blob_fetch_failed", `HTTP ${res.status} on ${path}`);
}
const blob = await res.blob();
return { blob, contentType: res.headers.get("content-type") ?? blob.type };
},
};

View File

@ -1,243 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "./api";
import { queryKeys } from "./queryKeys";
/**
* Mode démo côté SPA horloge virtuelle + boucle de tick.
*
* L'idée : avancer virtualNow LOCALEMENT (smooth via rAF) puis sync
* périodiquement avec le backend qui fire les tasks dues. Quand un
* event est déclenché, on auto-pause et on pousse l'email dans une
* file pour la slide-over.
*
* Tout le code démo est isolé ici et dans /components/demo. La prod
* ne charge ce hook que pour les orgs avec `demoMode = true`.
*/
export type FiredEvent = {
kind: "relance" | "checkin";
taskId: string;
invoiceId: string;
invoiceNumero: string;
capturedEmailId: string | null;
firedAt: string;
};
export type DemoState = {
demoMode: boolean;
virtualNow?: string; // ISO
speedFactor?: number;
nextEventAt?: string | null;
inboxCount?: number;
};
export type DemoCapturedEmail = {
id: string;
kind: "relance" | "checkin";
from: { email: string; name: string | null };
to: { email: string; name: string | null };
replyTo: string | null;
subject: string;
body: string;
meta: Record<string, unknown>;
sentAt: string;
};
/**
* Vitesse : nombre de jours-démo par seconde réelle.
* 1 = 1 jour-démo / seconde (= 30 jours en 30s, lecture posée pour démo)
* 2 = 2 jours/s, etc.
*/
export const SPEED_OPTIONS = [1, 2, 5] as const;
export type Speed = (typeof SPEED_OPTIONS)[number];
/**
* Interval entre 2 syncs backend pendant la lecture, en ms.
* Trade-off : plus court = plus réactif aux events, plus de charge.
*/
const SYNC_INTERVAL_MS = 250;
export const queryKeysDemo = {
state: () => ["demo", "state"] as const,
inbox: () => ["demo", "inbox"] as const,
};
/**
* Hook principal query l'état démo. Si demoMode=false, retourne juste
* le flag. Le composant DemoClock se monte conditionnellement.
*/
export function useDemoState() {
return useQuery({
queryKey: queryKeysDemo.state(),
queryFn: () => api.get<DemoState>("/api/v1/demo/state"),
staleTime: 0,
refetchOnWindowFocus: false,
});
}
export function useDemoInbox(enabled: boolean) {
return useQuery({
queryKey: queryKeysDemo.inbox(),
queryFn: () => api.get<DemoCapturedEmail[]>("/api/v1/demo/inbox"),
enabled,
staleTime: 0,
refetchOnWindowFocus: false,
});
}
export function useDemoStart() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post<DemoState>("/api/v1/demo/start"),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: queryKeysDemo.state() });
void qc.invalidateQueries({ queryKey: queryKeysDemo.inbox() });
void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() });
},
});
}
export function useDemoEnd() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => api.post<{ demoMode: false }>("/api/v1/demo/end"),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: queryKeysDemo.state() });
void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() });
},
});
}
/**
* Boucle de tick avance virtualNow localement (rAF) et sync le
* backend périodiquement. Quand fired events > 0 : auto-pause +
* push event dans la file.
*
* Retourne :
* - virtualNow : Date locale, mise à jour à chaque frame
* - playing : true si la pastille avance
* - speed : 1 | 2 | 5
* - play / pause / setSpeed : controls
* - pendingEvents : queue des events fired (à dépiler par l'UI quand
* l'utilisateur les acquitte)
* - acknowledge : retire le 1er event de la file (et quand vide, l'UI
* sait qu'on peut reprendre)
*
* @param initialVirtualNow ISO string de la backend lors du mount
* @param enabled false = ne pas démarrer la boucle (pour orgs non-démo)
*/
export function useDemoTick(params: {
enabled: boolean;
initialVirtualNow?: string;
}) {
const { enabled, initialVirtualNow } = params;
const qc = useQueryClient();
const [virtualNow, setVirtualNow] = useState<Date>(() =>
initialVirtualNow ? new Date(initialVirtualNow) : new Date(),
);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState<Speed>(1);
const [pendingEvents, setPendingEvents] = useState<FiredEvent[]>([]);
// Ref pour le scheduler — on évite de recréer la closure à chaque render.
const lastTickRef = useRef<number>(performance.now());
const lastSyncRef = useRef<number>(0);
const virtualNowRef = useRef<Date>(virtualNow);
virtualNowRef.current = virtualNow;
const playingRef = useRef(playing);
playingRef.current = playing;
const speedRef = useRef(speed);
speedRef.current = speed;
// Sync avec le backend.
const syncWithBackend = async () => {
const target = virtualNowRef.current.toISOString();
try {
const { firedEvents } = await api.post<{
virtualNow: string;
firedEvents: FiredEvent[];
}>("/api/v1/demo/tick", { virtualNow: target });
if (firedEvents.length > 0) {
// Auto-pause + push events
playingRef.current = false;
setPlaying(false);
setPendingEvents((prev) => [...prev, ...firedEvents]);
// Refresh inbox + dashboard pour que l'UI reflète l'état réel
void qc.invalidateQueries({ queryKey: queryKeysDemo.inbox() });
void qc.invalidateQueries({ queryKey: queryKeys.dashboard.all() });
}
} catch {
// Erreur réseau / backend down → on pause silencieusement, l'UI
// reste cohérente, l'utilisateur peut retenter.
playingRef.current = false;
setPlaying(false);
}
};
// Boucle rAF
useEffect(() => {
if (!enabled) return;
let raf = 0;
const loop = (now: number) => {
raf = requestAnimationFrame(loop);
if (!playingRef.current) {
lastTickRef.current = now;
return;
}
const elapsedRealMs = now - lastTickRef.current;
lastTickRef.current = now;
// speed = jours-démo par seconde réelle.
// → 1 ms réelle = (speed / 1000) jour-démo = (speed * 86_400_000 / 1000) ms virtuelles.
const advanceMs = (speedRef.current * elapsedRealMs * 86_400_000) / 1000;
const next = new Date(virtualNowRef.current.getTime() + advanceMs);
virtualNowRef.current = next;
setVirtualNow(next);
// Sync avec backend toutes les SYNC_INTERVAL_MS
if (now - lastSyncRef.current >= SYNC_INTERVAL_MS) {
lastSyncRef.current = now;
void syncWithBackend();
}
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]);
const acknowledge = () => {
setPendingEvents((prev) => prev.slice(1));
};
// L'UI considère qu'on peut "play" si on n'a aucun event en attente.
const canPlay = pendingEvents.length === 0;
return useMemo(
() => ({
virtualNow,
playing,
speed,
pendingEvents,
canPlay,
play: () => {
if (!pendingEvents.length) {
lastTickRef.current = performance.now();
setPlaying(true);
}
},
pause: () => setPlaying(false),
setSpeed: (s: Speed) => setSpeed(s),
acknowledge,
// Reset à la valeur backend (utile après /demo/start)
resetTo: (iso: string) => {
const d = new Date(iso);
virtualNowRef.current = d;
setVirtualNow(d);
},
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[virtualNow, playing, speed, pendingEvents, canPlay],
);
}

View File

@ -1,46 +0,0 @@
/**
* Glossaire métier définitions partagées par les tooltips.
* Centraliser ici pour pas réécrire la même chose à 3 endroits + garder
* le ton produit cohérent.
*/
export const GLOSSARY = {
dso: (
<>
<strong>DSO</strong> (Days Sales Outstanding) = délai moyen entre
l'émission d'une facture et son paiement. Plus le chiffre est bas,
plus vous êtes payé vite. La norme française B2B (LME) est{" "}
<strong>30 jours</strong>.
</>
),
lme: (
<>
<strong>LME</strong> = loi de modernisation de l'économie (2008).
Elle plafonne les délais de paiement entre entreprises à{" "}
<strong>60 jours</strong> (ou 45 j fin de mois). Au-delà, sanctions
DGCCRF jusqu'à 2 M.
</>
),
miseEnDemeure: (
<>
<strong>Mise en demeure</strong> = relance formelle écrite avec un
délai impératif (8 jours en général). Étape obligatoire avant toute
procédure judiciaire de recouvrement. Sous Rubis, elle est toujours
validée manuellement avant envoi.
</>
),
encaisse: (
<>
<strong>Encaissé</strong> = montant total des factures effectivement
payées sur la période, hors TVA si non collectée. C'est l'argent
qui est arrivé sur votre compte, pas juste facturé.
</>
),
rubis: (
<>
<strong>1 rubis</strong> = 10 minutes de votre temps libérées par
Rubis. À chaque relance envoyée automatiquement et chaque facture
réglée, vous gagnez un rubis.
</>
),
} as const;

View File

@ -21,7 +21,6 @@ export const queryKeys = {
detail: (id: string) => ["clients", "detail", id] as const,
},
dashboard: {
all: () => ["dashboard"] as const,
kpis: () => ["dashboard", "kpis"] as const,
activity: () => ["dashboard", "activity"] as const,
},

View File

@ -28,20 +28,9 @@ import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Textarea } from "@/components/ui/Textarea";
import { StatusBadge } from "@/components/ui/StatusBadge";
import { ClientPaidChart } from "@/components/charts/ClientPaidChart";
import type { InvoiceListItem } from "@/components/factures/InvoiceTable";
import type { ClientWithStats } from "@/components/clients/ClientTable";
type ClientTimeseries = {
range: 3 | 6 | 12;
paidByMonth: Array<{
month: string;
encaisseCents: number;
paidCount: number;
dsoDays: number;
}>;
};
type ClientDetail = ClientWithStats & {
invoices: InvoiceListItem[];
};
@ -66,12 +55,6 @@ function ClientDetailPage() {
queryFn: () => api.get<ClientDetail>(`/api/v1/clients/${id}`),
});
const { data: timeseries } = useQuery({
queryKey: ["clients", id, "timeseries", 6] as const,
queryFn: () =>
api.get<ClientTimeseries>(`/api/v1/clients/${id}/timeseries?range=6`),
});
// Notes : édition locale + sauvegarde sur blur. Garde le draft local pour
// ne pas refetch écraser ce que l'user est en train de taper.
const [notesDraft, setNotesDraft] = useState<string>("");
@ -207,19 +190,6 @@ function ClientDetailPage() {
/>
</section>
{/* Mini chart "encaissé sur 6 mois" pour ce client */}
<Card padding="md">
<div className="flex items-center justify-between mb-3">
<div>
<Eyebrow tone="ink">Paiements · 6 mois</Eyebrow>
<p className="mt-1 text-[12.5px] text-ink-3">
Évolution des encaissements de ce client.
</p>
</div>
</div>
<ClientPaidChart data={timeseries?.paidByMonth ?? []} />
</Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.5fr_1fr]">
{/* Liste des factures du client */}
<section>

View File

@ -17,12 +17,9 @@ import {
type InvoiceListItem,
} from "@/components/factures/InvoiceTable";
import { InvoiceCardList } from "@/components/factures/InvoiceCardList";
import { Pagination } from "@/components/ui/Pagination";
import { useManualInvoice } from "@/hooks/useManualInvoiceDialog";
import { uploadInvoiceFiles } from "@/lib/invoices";
const INVOICES_PAGE_SIZE = 50;
/** Status filter key — superset des InvoiceStatus + "all" pour "Toutes". */
const FILTER_KEYS = [
"all",
@ -55,7 +52,7 @@ export const Route = createFileRoute("/_app/factures")({
loader: ({ context }) => {
void context.queryClient.prefetchQuery({
queryKey: queryKeys.invoices.list({}),
queryFn: () => api.getList<InvoiceListItem>("/api/v1/invoices"),
queryFn: () => api.get<InvoiceListItem[]>("/api/v1/invoices"),
});
},
});
@ -103,28 +100,24 @@ function FacturesPage() {
if (files.length > 0 && !upload.isPending) upload.mutate(files);
};
const currentPage = search.page ?? 1;
const { data: response, isPending } = useQuery({
const { data: invoices = [], isPending } = useQuery({
queryKey: queryKeys.invoices.list({
status: search.status as InvoiceStatus | "all" | undefined,
q: search.q,
clientId: search.clientId,
page: currentPage,
page: search.page,
}),
queryFn: () => {
const params = new URLSearchParams();
if (search.status && search.status !== "all") params.set("status", search.status);
if (search.q) params.set("q", search.q);
if (search.clientId) params.set("clientId", search.clientId);
if (currentPage > 1) params.set("page", String(currentPage));
const qs = params.toString();
return api.getList<InvoiceListItem>(
return api.get<InvoiceListItem[]>(
`/api/v1/invoices${qs ? `?${qs}` : ""}`,
);
},
});
const invoices = response?.data ?? [];
const paginationTotal = response?.meta?.total ?? invoices.length;
const { data: counts } = useQuery({
queryKey: ["invoices", "counts"] as const,
@ -204,19 +197,6 @@ function FacturesPage() {
<InvoiceCardList invoices={invoices} />
</div>
<Pagination
page={currentPage}
total={paginationTotal}
pageSize={INVOICES_PAGE_SIZE}
itemLabel="factures"
onPageChange={(next) =>
void navigate({
to: "/factures",
search: (prev) => ({ ...prev, page: next }),
})
}
/>
{/* Compact dropzone en bas toujours là pour drag-and-drop rapide
sans avoir à vider la liste. */}
<div className="mt-4">

View File

@ -16,7 +16,6 @@ import { Eyebrow } from "@/components/ui/Eyebrow";
import { StatusBadge } from "@/components/ui/StatusBadge";
import { Timeline, type TimelineEvent } from "@/components/ui/Timeline";
import { Textarea } from "@/components/ui/Textarea";
import { PdfPreview } from "@/components/factures/PdfPreview";
const checkinSearchSchema = z.object({
checkin: z.enum(["paid", "pending", "expired", "invalid", "already_answered"]).optional(),
@ -77,9 +76,9 @@ function InvoiceDetailPage() {
const labels: Record<typeof checkin, string> = {
paid: "Facture marquée encaissée.",
pending: "Relance activée pour cette facture.",
expired: "Ce lien de confirmation a expiré.",
invalid: "Ce lien de confirmation est invalide.",
already_answered: "Cette confirmation avait déjà été traitée.",
expired: "Ce lien de check-in a expiré.",
invalid: "Ce lien de check-in est invalide.",
already_answered: "Ce check-in avait déjà été traité.",
};
const isErrorToast = checkin === "expired" || checkin === "invalid";
@ -172,30 +171,18 @@ function InvoiceDetailPage() {
)}
</header>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.1fr_1fr]">
{/* PDF source — colonne gauche, document tel qu'importé */}
<PdfPreview
filename={`${invoice.numero}.pdf`}
invoiceId={invoice.id}
pdfAvailable={!!invoice.pdfStorageKey}
/>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.4fr_1fr]">
{/* Timeline */}
<Card padding="md">
<Eyebrow tone="ink">
Timeline
{invoice.plan && <span className="text-ink-3"> · plan {invoice.plan.name}</span>}
</Eyebrow>
<Timeline events={invoice.timeline} className="mt-5" />
</Card>
{/* Colonne droite : timeline + client + notes empilés */}
{/* Sidepanel : client + notes */}
<div className="flex flex-col gap-4">
<Card padding="md">
<Eyebrow tone="ink">
Timeline
{invoice.plan && <span className="text-ink-3"> · plan {invoice.plan.name}</span>}
</Eyebrow>
{!isPaid && invoice.status !== "litigation" && invoice.plan && (
<p className="mt-3 rounded-sharp bg-cream-2/60 px-3 py-2 text-[12px] text-ink-2 leading-snug">
<strong className="text-ink">Aucune relance ne part sans votre validation.</strong>{" "}
Avant chaque envoi, Rubis vous demande si la facture a é réglée vous gardez la main.
</p>
)}
<Timeline events={invoice.timeline} className="mt-5" />
</Card>
<Card padding="md">
<Eyebrow tone="ink">Client</Eyebrow>
<p className="mt-3 font-display text-[16px] font-semibold text-ink">
@ -238,13 +225,9 @@ function InvoiceDetailSkeleton() {
<div className="h-3 w-20 rounded bg-cream-2" />
<div className="h-8 w-2/3 rounded bg-cream-2" />
<div className="h-4 w-1/2 rounded bg-cream-2" />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.1fr_1fr]">
<div className="h-[480px] rounded-card bg-cream-2" />
<div className="flex flex-col gap-4">
<div className="h-48 rounded-card bg-cream-2" />
<div className="h-32 rounded-card bg-cream-2" />
<div className="h-32 rounded-card bg-cream-2" />
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1.4fr_1fr]">
<div className="h-72 rounded-card bg-cream-2" />
<div className="h-72 rounded-card bg-cream-2" />
</div>
</div>
);

View File

@ -55,8 +55,6 @@ type DraftFields = {
type ImportDraft = {
id: string;
filename: string;
/** Clé de stockage S3/MinIO. null en mode démo MSW (pas de vrai fichier). */
pdfStorageKey: string | null;
extracted: DraftFields;
edited: DraftFields;
confidence: Partial<Record<keyof DraftFields, number>>;
@ -243,12 +241,7 @@ function ImportReviewPage() {
{/* === Body : 2 cols === */}
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_1.1fr]">
<PdfPreview
filename={currentDraft.filename}
batchId={batchId}
draftId={currentDraft.id}
pdfAvailable={!!currentDraft.pdfStorageKey}
/>
<PdfPreview filename={currentDraft.filename} />
<Card padding="md" className="flex flex-col gap-5">
<div className="flex flex-col gap-1">

View File

@ -1,16 +1,12 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { Camera, Plus, ArrowDownRight, ArrowRight } from "lucide-react";
import { Camera, Plus, ArrowDownRight } from "lucide-react";
import { api } from "@/lib/api";
import { queryKeys } from "@/lib/queryKeys";
import { formatEuros } from "@/lib/format";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { GlossaryTerm } from "@/components/ui/GlossaryTerm";
import { GLOSSARY } from "@/lib/glossary";
import { RubisHero } from "@/components/dashboard/RubisHero";
import { KpiCard } from "@/components/dashboard/KpiCard";
import {
@ -21,27 +17,6 @@ import {
TopLatePayers,
type LatePayer,
} from "@/components/dashboard/TopLatePayers";
import { EncaisseChart } from "@/components/charts/EncaisseChart";
import { DsoTrendChart } from "@/components/charts/DsoTrendChart";
import {
PipelineChart,
PipelineLegend,
} from "@/components/charts/PipelineChart";
type Timeseries = {
range: 3 | 6 | 12;
paidByMonth: Array<{
month: string;
encaisseCents: number;
paidCount: number;
dsoDays: number;
}>;
pipelineByStatus: Array<{
status: string;
count: number;
amountCents: number;
}>;
};
type DashboardKpis = {
rubisCount: number;
@ -86,11 +61,6 @@ function DashboardPage() {
queryFn: () => api.get<LatePayer[]>("/api/v1/dashboard/top-late"),
});
const { data: timeseries } = useQuery({
queryKey: ["dashboard", "timeseries", 6] as const,
queryFn: () => api.get<Timeseries>("/api/v1/dashboard/timeseries?range=6"),
});
return (
<div className="flex flex-col gap-6 lg:gap-7">
{/* Actions mobile : visibles seulement sur mobile (le topbar mobile montre la marque). */}
@ -144,9 +114,7 @@ function DashboardPage() {
intent="positive"
/>
<KpiCard
label={
<GlossaryTerm definition={GLOSSARY.dso}>DSO moyen</GlossaryTerm>
}
label="DSO moyen"
value={`${kpis?.dsoDays ?? 0} j`}
delta={
kpis?.dsoDeltaDays && kpis.dsoDeltaDays !== 0
@ -159,74 +127,9 @@ function DashboardPage() {
/>
</section>
{/* Charts encaissé + DSO côte à côte, puis pipeline en pleine largeur.
Cliquable vers /insights pour la version avec range selector. */}
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr] lg:gap-5">
<Card padding="md" className="flex flex-col">
<div className="flex items-center justify-between mb-3">
<div>
<Eyebrow tone="ink">Encaissé · 6 mois</Eyebrow>
<p className="mt-1 text-[12.5px] text-ink-3">
Argent récupéré chaque mois.
</p>
</div>
<Link
to="/insights"
className="text-[12px] text-rubis hover:underline underline-offset-4"
>
Détails
</Link>
</div>
<EncaisseChart data={timeseries?.paidByMonth ?? []} />
</Card>
<Card padding="md" className="flex flex-col">
<div className="flex items-center justify-between mb-3">
<div>
<Eyebrow tone="ink">
<GlossaryTerm definition={GLOSSARY.dso}>DSO</GlossaryTerm>
{" · 6 mois"}
</Eyebrow>
<p className="mt-1 text-[12.5px] text-ink-3">
Délai moyen entre émission et paiement.
</p>
</div>
<Link
to="/insights"
className="text-[12px] text-rubis hover:underline underline-offset-4"
>
Détails
</Link>
</div>
<DsoTrendChart data={timeseries?.paidByMonth ?? []} />
</Card>
</section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr] lg:gap-5">
<Card padding="md">
<div className="flex items-center justify-between mb-3">
<Eyebrow tone="ink">Pipeline factures</Eyebrow>
<Link
to="/factures"
className="text-[12px] text-rubis hover:underline underline-offset-4"
>
Voir <ArrowRight size={11} className="inline" />
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-[200px_1fr] gap-4 items-center">
<PipelineChart
data={timeseries?.pipelineByStatus ?? []}
height={200}
/>
<PipelineLegend data={timeseries?.pipelineByStatus ?? []} />
</div>
</Card>
<TopLatePayers payers={latePayers} />
</section>
<section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1.2fr_1fr] lg:gap-5">
<ActivityFeed events={activity} />
<TopLatePayers payers={latePayers} />
</section>
{/* Petite signature visuelle en bas — discret, juste pour aérer. */}

View File

@ -1,201 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api } from "@/lib/api";
import { formatEuros } from "@/lib/format";
import { cn } from "@/lib/utils";
import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { GlossaryTerm } from "@/components/ui/GlossaryTerm";
import { GLOSSARY } from "@/lib/glossary";
import { EncaisseChart } from "@/components/charts/EncaisseChart";
import { DsoTrendChart } from "@/components/charts/DsoTrendChart";
import {
PipelineChart,
PipelineLegend,
} from "@/components/charts/PipelineChart";
type Range = 3 | 6 | 12;
type Timeseries = {
range: Range;
paidByMonth: Array<{
month: string;
encaisseCents: number;
paidCount: number;
dsoDays: number;
}>;
pipelineByStatus: Array<{
status: string;
count: number;
amountCents: number;
}>;
};
export const Route = createFileRoute("/_app/insights")({
component: InsightsPage,
});
const RANGES: { value: Range; label: string }[] = [
{ value: 3, label: "3 mois" },
{ value: 6, label: "6 mois" },
{ value: 12, label: "12 mois" },
];
function InsightsPage() {
const [range, setRange] = useState<Range>(6);
const { data: ts } = useQuery({
queryKey: ["dashboard", "timeseries", range] as const,
queryFn: () =>
api.get<Timeseries>(`/api/v1/dashboard/timeseries?range=${range}`),
});
const totalEncaisse =
ts?.paidByMonth.reduce((s, m) => s + m.encaisseCents, 0) ?? 0;
const totalPaid =
ts?.paidByMonth.reduce((s, m) => s + m.paidCount, 0) ?? 0;
const dsoMoyen = (() => {
if (!ts) return 0;
const months = ts.paidByMonth.filter((m) => m.paidCount > 0);
if (months.length === 0) return 0;
return Math.round(
months.reduce((s, m) => s + m.dsoDays, 0) / months.length,
);
})();
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<Eyebrow>Insights</Eyebrow>
<h1 className="mt-2 font-display text-[28px] font-bold tracking-[-0.022em] text-ink lg:text-[32px]">
Comprendre vos <em className="text-rubis">flux</em>
</h1>
<p className="mt-1.5 text-[14px] text-ink-3 max-w-xl leading-relaxed">
Vue d'ensemble du portefeuille et des encaissements. Filtre la
période pour comparer.
</p>
</div>
<RangePicker value={range} onChange={setRange} />
</header>
{/* Récap chiffré au-dessus des charts pour ancrer la lecture. */}
<section
aria-label="Synthèse de la période"
className="grid grid-cols-2 gap-3 lg:grid-cols-3 lg:gap-4"
>
<SummaryCard
label={`Encaissé ${range} mois`}
value={formatEuros(totalEncaisse)}
/>
<SummaryCard label="Factures payées" value={String(totalPaid)} />
<SummaryCard
label={
<GlossaryTerm definition={GLOSSARY.dso}>DSO moyen</GlossaryTerm>
}
value={dsoMoyen > 0 ? `${dsoMoyen} j` : "—"}
/>
</section>
{/* Encaissé — pleine largeur, chart le plus important */}
<Card padding="md">
<div className="mb-4">
<Eyebrow tone="ink">Encaissement mensuel</Eyebrow>
<p className="mt-1 text-[13px] text-ink-3">
Total facturé encaissé chaque mois sur la période sélectionnée.
</p>
</div>
<EncaisseChart data={ts?.paidByMonth ?? []} height={300} />
</Card>
{/* DSO + Pipeline côte à côte */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-5">
<Card padding="md">
<div className="mb-4">
<Eyebrow tone="ink">
Délai de paiement (
<GlossaryTerm definition={GLOSSARY.dso}>DSO</GlossaryTerm>)
</Eyebrow>
<p className="mt-1 text-[13px] text-ink-3">
Jours moyens entre l'émission et le paiement. La référence
à 30 j est la norme{" "}
<GlossaryTerm definition={GLOSSARY.lme}>LME</GlossaryTerm> pour le B2B.
</p>
</div>
<DsoTrendChart data={ts?.paidByMonth ?? []} height={260} />
</Card>
<Card padding="md">
<div className="mb-4">
<Eyebrow tone="ink">Pipeline factures</Eyebrow>
<p className="mt-1 text-[13px] text-ink-3">
Répartition par statut sur l'ensemble du portefeuille.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-[220px_1fr] gap-4 items-center">
<PipelineChart
data={ts?.pipelineByStatus ?? []}
height={220}
/>
<PipelineLegend data={ts?.pipelineByStatus ?? []} />
</div>
</Card>
</div>
</div>
);
}
function RangePicker({
value,
onChange,
}: {
value: Range;
onChange: (r: Range) => void;
}) {
return (
<div
role="radiogroup"
aria-label="Période"
className="inline-flex rounded-default border border-line bg-white p-0.5 self-start sm:self-end"
>
{RANGES.map((r) => {
const active = r.value === value;
return (
<button
key={r.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(r.value)}
className={cn(
"px-3 h-8 rounded-default text-[12.5px] font-medium tabular-nums",
"transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rubis-glow",
active
? "bg-rubis text-white"
: "text-ink-2 hover:bg-cream-2",
)}
>
{r.label}
</button>
);
})}
</div>
);
}
function SummaryCard({ label, value }: { label: React.ReactNode; value: string }) {
return (
<Card padding="md">
<p className="text-[10.5px] font-semibold uppercase tracking-[0.14em] text-ink-3">
{label}
</p>
<p className="mt-2 font-display text-[24px] font-bold leading-none tracking-[-0.018em] tabular-nums text-ink">
{value}
</p>
</Card>
);
}

View File

@ -5,7 +5,6 @@ import { AccountForm } from "@/components/settings/AccountForm";
import { OrganizationForm } from "@/components/settings/OrganizationForm";
import { SignatureForm } from "@/components/settings/SignatureForm";
import { DangerZone } from "@/components/settings/DangerZone";
import { DemoToggle } from "@/components/demo/DemoToggle";
export const Route = createFileRoute("/_app/parametres")({
component: ParametresPage,
@ -64,18 +63,6 @@ function ParametresPage() {
<SignatureForm />
</SettingsSection>
<SettingsSection
eyebrow="Démonstration"
title={
<>
Faire vivre Rubis en <em className="text-rubis">accéléré</em>
</>
}
description="Mode démo : horloge virtuelle qui avance dans le temps, emails capturés au lieu d'être envoyés à de vrais clients. Idéal pour montrer Rubis à un prospect."
>
<DemoToggle />
</SettingsSection>
<SettingsSection
eyebrow="Zone danger"
title="Déconnexion et compte"

View File

@ -628,7 +628,7 @@ function StepMessages({
Étape {selectedIdx + 1} · J{selected.offsetDays >= 0 ? "+" : ""}
{selected.offsetDays}
</p>
<Button size="sm" variant="secondary" onClick={() => setAiOpen(true)}>
<Button size="sm" variant="ghost" onClick={() => setAiOpen(true)}>
<Sparkles size={13} className="text-rubis" /> Générer avec l'IA
</Button>
</div>

View File

@ -9,9 +9,9 @@ import { authStore } from "@/lib/auth";
import { Gem } from "@/components/brand/Gem";
/**
* Callback SSO côté SPA partagé entre Google et Microsoft. Se charge :
* Callback Google côté SPA se charge :
* 1. d'appeler POST /api/v1/auth/refresh (le cookie httpOnly posé par
* la callback backend OAuth est auto-envoyé)
* /api/v1/auth/google/callback est auto-envoyé)
* 2. de stocker l'access token + user dans authStore
* 3. de naviguer vers `?next=...` (envoyé par le backend selon l'état
* d'onboarding : "/" pour user complet, "/onboarding/entreprise"
@ -23,12 +23,12 @@ const searchSchema = z.object({
next: z.string().default("/"),
});
export const Route = createFileRoute("/auth/sso/complete")({
export const Route = createFileRoute("/auth/google/complete")({
validateSearch: searchSchema,
component: SsoCompletePage,
component: GoogleCompletePage,
});
function SsoCompletePage() {
function GoogleCompletePage() {
const { next } = Route.useSearch();
const navigate = useNavigate();
// Strict-mode protect : avoid double-firing the refresh in dev.

View File

@ -16,31 +16,18 @@ import { Field } from "@/components/ui/Field";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Brand } from "@/components/brand/Brand";
import { Gem } from "@/components/brand/Gem";
import { SsoButton, AuthDivider } from "@/components/auth/SsoButton";
const ssoErrorEnum = z
.enum(["denied", "state_mismatch", "error", "no_email"])
.optional();
import { GoogleButton, AuthDivider } from "@/components/auth/GoogleButton";
const searchSchema = z.object({
redirect: z.string().optional(),
google: ssoErrorEnum,
microsoft: ssoErrorEnum,
google: z.enum(["denied", "state_mismatch", "error", "no_email"]).optional(),
});
const SSO_ERROR_MESSAGES: Record<string, Record<string, string>> = {
google: {
denied: "Connexion Google annulée.",
state_mismatch: "Session expirée, réessayez la connexion Google.",
error: "Connexion Google impossible. Réessayez dans un instant.",
no_email: "Votre compte Google n'a pas d'email associé.",
},
microsoft: {
denied: "Connexion Microsoft annulée.",
state_mismatch: "Session expirée, réessayez la connexion Microsoft.",
error: "Connexion Microsoft impossible. Réessayez dans un instant.",
no_email: "Votre compte Microsoft n'a pas d'email associé.",
},
const GOOGLE_ERROR_MESSAGES: Record<string, string> = {
denied: "Connexion Google annulée.",
state_mismatch: "Session expirée, réessayez la connexion Google.",
error: "Connexion Google impossible. Réessayez dans un instant.",
no_email: "Votre compte Google n'a pas d'email associé.",
};
export const Route = createFileRoute("/login")({
@ -52,15 +39,12 @@ function LoginPage() {
const navigate = useNavigate();
const search = Route.useSearch();
// Toast d'erreur si on revient d'un échec SSO (?google=denied, ?microsoft=…).
// Toast d'erreur si on revient d'un échec Google SSO (?google=denied|...).
useEffect(() => {
for (const provider of ["google", "microsoft"] as const) {
const code = search[provider];
if (code && SSO_ERROR_MESSAGES[provider]?.[code]) {
toast.error(SSO_ERROR_MESSAGES[provider]![code]!);
}
if (search.google && GOOGLE_ERROR_MESSAGES[search.google]) {
toast.error(GOOGLE_ERROR_MESSAGES[search.google]!);
}
}, [search.google, search.microsoft]);
}, [search.google]);
const loginMutation = useMutation({
mutationFn: async (input: LoginInput) =>
@ -154,9 +138,8 @@ function LoginPage() {
</Link>
</p>
<div className="mt-7 flex flex-col gap-2">
<SsoButton provider="google" />
<SsoButton provider="microsoft" />
<div className="mt-7">
<GoogleButton />
<AuthDivider />
</div>

View File

@ -19,7 +19,7 @@ import { Card } from "@/components/ui/Card";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Brand } from "@/components/brand/Brand";
import { Gem } from "@/components/brand/Gem";
import { SsoButton, AuthDivider } from "@/components/auth/SsoButton";
import { GoogleButton, AuthDivider } from "@/components/auth/GoogleButton";
export const Route = createFileRoute("/signup")({
component: SignupPage,
@ -128,9 +128,8 @@ function SignupPage() {
</Link>
</p>
<div className="mt-7 flex flex-col gap-2">
<SsoButton provider="google" label="S'inscrire avec Google" />
<SsoButton provider="microsoft" label="S'inscrire avec Microsoft" />
<div className="mt-7">
<GoogleButton label="S'inscrire avec Google" />
<AuthDivider />
</div>

View File

@ -135,10 +135,3 @@ data:
# Le callback URL doit matcher EXACTEMENT ce qui est configuré dans
# Google Cloud Console (OAuth Client → Authorized redirect URIs).
GOOGLE_CALLBACK_URL: 'https://app.rubis.arthurbarre.fr/api/v1/auth/google/callback'
# Microsoft SSO — MICROSOFT_CLIENT_ID/SECRET sont dans rubis-app-secrets.
# MICROSOFT_TENANT : 'common' (work + perso), 'organizations' (M365 only),
# ou un tenant ID Azure AD spécifique. Le callback URL doit matcher
# EXACTEMENT le redirect URI configuré côté Azure App registration.
MICROSOFT_TENANT: 'common'
MICROSOFT_CALLBACK_URL: 'https://app.rubis.arthurbarre.fr/api/v1/auth/microsoft/callback'

288
pnpm-lock.yaml generated
View File

@ -222,9 +222,6 @@ importers:
react-dom:
specifier: ^19.2.5
version: 19.2.5(react@19.2.5)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1)
sonner:
specifier: ^1.7.4
version: 1.7.4(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@ -1934,17 +1931,6 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/binding-android-arm64@1.0.0-rc.17':
resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -2401,9 +2387,6 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@stylistic/eslint-plugin@5.10.0':
resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -2803,33 +2786,6 @@ packages:
'@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@ -2889,9 +2845,6 @@ packages:
'@types/superagent@8.1.9':
resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
@ -3366,50 +3319,6 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
@ -3441,9 +3350,6 @@ packages:
supports-color:
optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@ -3597,9 +3503,6 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es-toolkit@1.46.1:
resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==}
esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'}
@ -4102,12 +4005,6 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.7:
resolution: {integrity: sha512-LFVFtAROHcDy1er5UI6nodRFnZ2SgdCXhfNSI+DpObO8N7Pur/muBGsjzH5wpnFHCYhYVQxZskCkV4koQ//3/Q==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@ -4141,10 +4038,6 @@ packages:
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
interpret@2.2.0:
resolution: {integrity: sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==}
engines: {node: '>= 0.10'}
@ -4963,18 +4856,6 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-remove-scroll-bar@2.3.8:
resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
engines: {node: '>=10'}
@ -5033,14 +4914,6 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
recharts@3.8.1:
resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
rechoir@0.8.0:
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
engines: {node: '>= 10.13.0'}
@ -5057,14 +4930,6 @@ packages:
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
engines: {node: '>=4'}
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@ -5080,9 +4945,6 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@ -5440,9 +5302,6 @@ packages:
timekeeper@2.3.1:
resolution: {integrity: sha512-LeQRS7/4JcC0PgdSFnfUiStQEdiuySlCj/5SJ18D+T1n9BoY7PxKFfCwLulpHXoLUFr67HxBddQdEX47lDGx1g==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@ -5661,9 +5520,6 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@ -7742,18 +7598,6 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.7
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.5
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1)
'@rolldown/binding-android-arm64@1.0.0-rc.17':
optional: true
@ -8229,8 +8073,6 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@stylistic/eslint-plugin@5.10.0(eslint@10.3.0(jiti@2.7.0))':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.3.0(jiti@2.7.0))
@ -8599,30 +8441,6 @@ snapshots:
'@types/cookiejar@2.1.5': {}
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/deep-eql@4.0.2': {}
'@types/esrecurse@4.3.1': {}
@ -8684,8 +8502,6 @@ snapshots:
'@types/node': 25.6.0
form-data: 4.0.5
'@types/use-sync-external-store@0.0.6': {}
'@types/validator@13.15.10': {}
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)':
@ -9176,44 +8992,6 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
@ -9233,8 +9011,6 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
decimal.js@10.6.0: {}
decompress-response@6.0.0:
@ -9346,8 +9122,6 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.3
es-toolkit@1.46.1: {}
esbuild@0.27.7:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7
@ -9889,10 +9663,6 @@ snapshots:
ignore@7.0.5: {}
immer@10.2.0: {}
immer@11.1.7: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@ -9914,8 +9684,6 @@ snapshots:
ini@1.3.8: {}
internmap@2.0.3: {}
interpret@2.2.0: {}
ioredis@5.10.1:
@ -10684,15 +10452,6 @@ snapshots:
react-is@18.3.1: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.5
use-sync-external-store: 1.6.0(react@19.2.5)
optionalDependencies:
'@types/react': 19.2.14
redux: 5.0.1
react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5):
dependencies:
react: 19.2.5
@ -10750,26 +10509,6 @@ snapshots:
real-require@0.2.0: {}
recharts@3.8.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react-is@18.3.1)(react@19.2.5)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1))(react@19.2.5)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.46.1
eventemitter3: 5.0.4
immer: 10.2.0
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-is: 18.3.1
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.5)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
rechoir@0.8.0:
dependencies:
resolve: 1.22.12
@ -10785,12 +10524,6 @@ snapshots:
dependencies:
redis-errors: 1.2.0
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect-metadata@0.2.2: {}
regexp-tree@0.1.27: {}
@ -10801,8 +10534,6 @@ snapshots:
require-directory@2.1.1: {}
reselect@5.1.1: {}
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
@ -11176,8 +10907,6 @@ snapshots:
timekeeper@2.3.1: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@ -11365,23 +11094,6 @@ snapshots:
vary@1.1.2: {}
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite-node@3.2.4(@types/node@24.12.2)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.4):
dependencies:
cac: 6.7.14