Compare commits
9 Commits
ea539cd1d4
...
1633fb9bf0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1633fb9bf0 | ||
|
|
933c6496b1 | ||
|
|
6eb9ca4120 | ||
|
|
2d3766cc3d | ||
|
|
32fcb02108 | ||
|
|
4113cb56d3 | ||
|
|
2c2724c634 | ||
|
|
ec2232e4b3 | ||
|
|
7521e1fff6 |
@ -111,6 +111,30 @@ 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.
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpm -F api typecheck)",
|
||||
"Bash(pnpm -F @rubis/web typecheck)"
|
||||
"Bash(pnpm -F @rubis/web typecheck)",
|
||||
"Bash(rtk grep *)",
|
||||
"Bash(rtk node *)",
|
||||
"Bash(rtk pnpm *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,4 +87,18 @@ 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
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import User from '#models/user'
|
||||
import Organization from '#models/organization'
|
||||
import { provisionDefaultPlans } from '#services/default_plans'
|
||||
import { issueRefreshToken } from '#services/refresh_token'
|
||||
import {
|
||||
emitSsoSessionAndRedirect,
|
||||
findOrCreateUserFromSso,
|
||||
nextRouteAfterSso,
|
||||
} from '#services/sso_session'
|
||||
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.
|
||||
@ -15,36 +14,20 @@ import crypto from 'node:crypto'
|
||||
* 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, 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.
|
||||
* 4. Backend matche/crée l'user (cf. findOrCreateUserFromSso),
|
||||
* pose le refresh cookie, redirige vers /auth/sso/complete?next=...
|
||||
*/
|
||||
export default class AuthGoogleController {
|
||||
/**
|
||||
* GET /api/v1/auth/google/redirect — entrée du flow OAuth.
|
||||
* Le bouton "Continuer avec Google" pointe directement ici (pas un fetch).
|
||||
*/
|
||||
/** GET /api/v1/auth/google/redirect — entrée du flow OAuth. */
|
||||
async redirect(ctx: HttpContext) {
|
||||
return ctx.ally.use('google').redirect()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
/** GET /api/v1/auth/google/callback — retour de Google. */
|
||||
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`)
|
||||
}
|
||||
@ -62,67 +45,14 @@ export default class AuthGoogleController {
|
||||
return ctx.response.redirect(`${webUrl}/login?google=no_email`)
|
||||
}
|
||||
|
||||
// 1. Lookup par google_id (canonique)
|
||||
let user = await User.findBy('googleId', googleUser.id)
|
||||
let isNewUser = false
|
||||
const { user, isNewUser } = await findOrCreateUserFromSso({
|
||||
provider: 'google',
|
||||
providerId: googleUser.id,
|
||||
email: googleUser.email,
|
||||
fullName: googleUser.name ?? null,
|
||||
})
|
||||
|
||||
// 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)}`)
|
||||
const next = await nextRouteAfterSso(user, isNewUser)
|
||||
return emitSsoSessionAndRedirect(ctx, user, next, webUrl)
|
||||
}
|
||||
}
|
||||
|
||||
55
apps/api/app/controllers/auth_microsoft_controller.ts
Normal file
55
apps/api/app/controllers/auth_microsoft_controller.ts
Normal file
@ -0,0 +1,55 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -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 }) < DateTime.now()) {
|
||||
if (task.sentAt && task.sentAt.plus({ hours: CHECKIN_TTL_HOURS }) < (await clock.now(task.organizationId))) {
|
||||
task.status = 'expired'
|
||||
await task.save()
|
||||
return { redirect: spaRedirectUrl('expired') }
|
||||
@ -77,17 +77,18 @@ 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 = DateTime.now()
|
||||
task.answeredAt = nowOrg
|
||||
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 = DateTime.now()
|
||||
invoice.paidAt = nowOrg
|
||||
invoice.rubisEarned = invoice.rubisEarned + 1
|
||||
await invoice.save()
|
||||
|
||||
@ -99,7 +100,7 @@ export default class CheckinController {
|
||||
await recordActivity({
|
||||
organizationId: invoice.organizationId,
|
||||
kind: 'invoice_paid',
|
||||
label: `Facture <b>${invoice.numero}</b> marquée encaissée via check-in`,
|
||||
label: `Facture <b>${invoice.numero}</b> marquée encaissée via confirmation`,
|
||||
meta: { invoiceId: invoice.id, clientId: invoice.clientId },
|
||||
trx,
|
||||
})
|
||||
@ -133,7 +134,7 @@ export default class CheckinController {
|
||||
task.useTransaction(trx)
|
||||
task.status = 'answered'
|
||||
task.answer = 'still_pending'
|
||||
task.answeredAt = DateTime.now()
|
||||
task.answeredAt = await clock.now(invoice.organizationId)
|
||||
await task.save()
|
||||
})
|
||||
|
||||
|
||||
@ -1,9 +1,27 @@
|
||||
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
|
||||
@ -96,11 +114,27 @@ 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: [], // TODO: brancher quand le domaine Invoice arrive
|
||||
invoices: invoices.map((inv) => new InvoiceTransformer(inv).toObject()),
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -148,6 +182,34 @@ 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.
|
||||
*/
|
||||
|
||||
@ -1,7 +1,17 @@
|
||||
import ActivityEvent from '#models/activity_event'
|
||||
import { computeKpis, topLatePayers } from '#services/dashboard'
|
||||
import {
|
||||
computeKpis,
|
||||
computeTimeseries,
|
||||
topLatePayers,
|
||||
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(),
|
||||
})
|
||||
|
||||
const ACTIVITY_DEFAULT_LIMIT = 20
|
||||
|
||||
@ -62,4 +72,21 @@ 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 })
|
||||
}
|
||||
}
|
||||
|
||||
204
apps/api/app/controllers/demo_controller.ts
Normal file
204
apps/api/app/controllers/demo_controller.ts
Normal file
@ -0,0 +1,204 @@
|
||||
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),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -120,6 +120,48 @@ 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
|
||||
*
|
||||
|
||||
@ -12,6 +12,8 @@ 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
|
||||
|
||||
@ -47,7 +49,9 @@ function serializeInvoice(i: Invoice) {
|
||||
*/
|
||||
function buildTimeline(
|
||||
invoice: Invoice,
|
||||
relanceTasks: RelanceTask[] = []
|
||||
relanceTasks: RelanceTask[] = [],
|
||||
// `now` injecté par le caller — orgs en mode démo lisent depuis virtualNow.
|
||||
now: DateTime = DateTime.utc()
|
||||
): Array<{
|
||||
id: string
|
||||
state: 'past' | 'current' | 'future'
|
||||
@ -70,7 +74,7 @@ function buildTimeline(
|
||||
|
||||
if (invoice.plan?.steps?.length && invoice.status !== 'paid' && invoice.status !== 'cancelled') {
|
||||
const dueMs = invoice.dueDate.toMillis()
|
||||
const nowMs = DateTime.now().toMillis()
|
||||
const nowMs = now.toMillis()
|
||||
const taskByStepId = new Map(relanceTasks.map((task) => [task.planStepId, task]))
|
||||
let currentSet = false
|
||||
|
||||
@ -92,13 +96,16 @@ 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'
|
||||
? `Email envoyé · "${subject}"`
|
||||
: `Email programmé · "${subject}"`
|
||||
: invoice.status === 'pending'
|
||||
? `À programmer après check-in · "${subject}"`
|
||||
: `Relance non programmée · "${subject}"`
|
||||
? `Envoyée après votre confirmation · "${subject}"`
|
||||
: task.status === 'cancelled'
|
||||
? `Annulée — facture encaissée · "${subject}"`
|
||||
: `Confirmation avant envoi · "${subject}"`
|
||||
: `Confirmation avant envoi · "${subject}"`
|
||||
|
||||
events.push({
|
||||
id: `${invoice.id}__step_${step.order}`,
|
||||
@ -257,7 +264,7 @@ export default class InvoicesController {
|
||||
requiresManualValidation: s.requiresManualValidation,
|
||||
})),
|
||||
},
|
||||
timeline: buildTimeline(invoice, relanceTasks),
|
||||
timeline: buildTimeline(invoice, relanceTasks, await clock.now(invoice.organizationId)),
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -322,6 +329,51 @@ 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
|
||||
@ -348,7 +400,7 @@ export default class InvoicesController {
|
||||
await db.transaction(async (trx) => {
|
||||
invoice.useTransaction(trx)
|
||||
invoice.status = 'paid'
|
||||
invoice.paidAt = DateTime.now()
|
||||
invoice.paidAt = await clock.now(invoice.organizationId)
|
||||
invoice.rubisEarned = invoice.rubisEarned + 1
|
||||
await invoice.save()
|
||||
|
||||
|
||||
@ -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 = DateTime.now()
|
||||
task.sentAt = await clock.now(invoice.organizationId)
|
||||
await task.save()
|
||||
}
|
||||
|
||||
@ -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 = DateTime.now()
|
||||
task.sentAt = await clock.now(invoice.organizationId)
|
||||
await task.save()
|
||||
|
||||
await recordActivity({
|
||||
@ -87,10 +87,11 @@ 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 = DateTime.now()
|
||||
task.sentAt = sentAt
|
||||
await task.save()
|
||||
|
||||
invoice.useTransaction(trx)
|
||||
|
||||
9
apps/api/app/models/demo_captured_email.ts
Normal file
9
apps/api/app/models/demo_captured_email.ts
Normal file
@ -0,0 +1,9 @@
|
||||
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>
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
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'
|
||||
@ -33,7 +34,7 @@ export async function recordActivity(opts: RecordOpts): Promise<ActivityEvent> {
|
||||
kind,
|
||||
label,
|
||||
meta,
|
||||
at: at ?? DateTime.now(),
|
||||
at: at ?? (await clock.now(organizationId)),
|
||||
},
|
||||
trx ? { client: trx } : undefined
|
||||
)
|
||||
|
||||
163
apps/api/app/services/ally/microsoft_driver.ts
Normal file
163
apps/api/app/services/ally/microsoft_driver.ts
Normal file
@ -0,0 +1,163 @@
|
||||
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)
|
||||
}
|
||||
@ -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 = DateTime.now()
|
||||
const now = await clock.now(invoice.organizationId)
|
||||
const sendAtRaw = invoice.dueDate
|
||||
const sendAt = sendAtRaw < now ? now.plus({ minutes: 1 }) : sendAtRaw
|
||||
|
||||
|
||||
111
apps/api/app/services/clock.ts
Normal file
111
apps/api/app/services/clock.ts
Normal file
@ -0,0 +1,111 @@
|
||||
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 où 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()
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
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')"
|
||||
|
||||
@ -38,7 +39,7 @@ function startOfDay(d: DateTime): Date {
|
||||
* Le contrat reste stable côté SPA.
|
||||
*/
|
||||
export async function computeKpis(organizationId: string): Promise<DashboardKpis> {
|
||||
const now = DateTime.now()
|
||||
const now = await clock.now(organizationId)
|
||||
const monthStart = startOfMonth(now)
|
||||
const todayStart = startOfDay(now)
|
||||
const prevMonthStart = startOfMonth(now.minus({ months: 1 }))
|
||||
@ -128,7 +129,7 @@ export async function topLatePayers(
|
||||
organizationId: string,
|
||||
limit = 5
|
||||
): Promise<Array<{ clientId: string; name: string; lateInvoicesCount: number }>> {
|
||||
const today = startOfDay(DateTime.now())
|
||||
const today = startOfDay(await clock.now(organizationId))
|
||||
|
||||
const rows = await db
|
||||
.from('invoices')
|
||||
@ -148,3 +149,141 @@ 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())
|
||||
}
|
||||
|
||||
48
apps/api/app/services/demo/capture.ts
Normal file
48
apps/api/app/services/demo/capture.ts
Normal file
@ -0,0 +1,48 @@
|
||||
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
|
||||
}
|
||||
140
apps/api/app/services/demo/dispatch.ts
Normal file
140
apps/api/app/services/demo/dispatch.ts
Normal file
@ -0,0 +1,140 @@
|
||||
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
|
||||
}
|
||||
@ -2,6 +2,8 @@ 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'
|
||||
@ -34,16 +36,19 @@ 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 (UTC pour cohérence).
|
||||
// Jours de retard arrondis à l'entier — démo-aware via `now` injecté.
|
||||
const daysLate = Math.floor(
|
||||
DateTime.utc().startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days
|
||||
now.startOf('day').diff(invoice.dueDate.startOf('day'), 'days').days
|
||||
)
|
||||
return {
|
||||
client: {
|
||||
@ -80,14 +85,38 @@ export async function sendRelanceEmail({
|
||||
user,
|
||||
organization,
|
||||
}: RelancePayload) {
|
||||
const vars = buildRelanceVars({ invoice, client, user, organization })
|
||||
const vars = buildRelanceVars({
|
||||
invoice,
|
||||
client,
|
||||
user,
|
||||
organization,
|
||||
now: await clock.now(invoice.organizationId),
|
||||
})
|
||||
|
||||
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(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
|
||||
m.from(fromAddress, fromName)
|
||||
.to(client.email, client.name)
|
||||
.subject(subject)
|
||||
// Texte brut pour V1 — on ajoutera un template HTML quand on aura
|
||||
@ -144,9 +173,25 @@ 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(env.get('MAIL_FROM_ADDRESS', 'relances@rubis-sur-l-ongle.fr'), env.get('MAIL_FROM_NAME', "Rubis Sur l'Ongle"))
|
||||
m.from(fromAddress, fromName)
|
||||
.to(user.email, user.fullName ?? user.email)
|
||||
.subject(subject)
|
||||
.text(body)
|
||||
|
||||
@ -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 = DateTime.now()
|
||||
const now = await clock.now(invoice.organizationId)
|
||||
const created: RelanceTask[] = []
|
||||
const steps = plan.steps.slice().sort((a, b) => a.order - b.order)
|
||||
const firstOverdueStep = steps.find(
|
||||
|
||||
108
apps/api/app/services/sso_session.ts
Normal file
108
apps/api/app/services/sso_session.ts
Normal file
@ -0,0 +1,108 @@
|
||||
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)}`)
|
||||
}
|
||||
134
apps/api/commands/demo_schedule_relance.ts
Normal file
134
apps/api/commands/demo_schedule_relance.ts
Normal file
@ -0,0 +1,134 @@
|
||||
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."
|
||||
)
|
||||
}
|
||||
}
|
||||
151
apps/api/commands/seed_demo.ts
Normal file
151
apps/api/commands/seed_demo.ts
Normal file
@ -0,0 +1,151 @@
|
||||
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.')
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,14 @@
|
||||
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 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).
|
||||
* V1 : Google + Microsoft 365 (cf. CLAUDE.md → Auth).
|
||||
* Le callback URL pointe vers l'API (/api/v1/auth/{provider}/callback).
|
||||
* En prod, le reverse proxy nginx (rubis-web) achemine /api/* vers ce
|
||||
* service, donc la même URL fonctionne pour le browser et pour Google.
|
||||
* service, donc la même URL fonctionne browser et provider.
|
||||
*/
|
||||
const allyConfig = defineConfig({
|
||||
google: services.google({
|
||||
@ -25,6 +24,19 @@ 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
|
||||
|
||||
808
apps/api/database/factories.ts
Normal file
808
apps/api/database/factories.ts
Normal file
@ -0,0 +1,808 @@
|
||||
/**
|
||||
* 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 }
|
||||
@ -0,0 +1,25 @@
|
||||
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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -109,6 +109,39 @@ 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
|
||||
@ -185,10 +218,14 @@ export class InvoiceSchema extends BaseModel {
|
||||
}
|
||||
|
||||
export class OrganizationSchema extends BaseModel {
|
||||
static $columns = ['createdAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt'] as const
|
||||
static $columns = ['createdAt', 'demoMode', 'demoSpeedFactor', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'rubisCount', 'siret', 'updatedAt', 'virtualNow'] 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()
|
||||
@ -203,6 +240,8 @@ 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 {
|
||||
@ -302,7 +341,7 @@ export class RelanceTaskSchema extends BaseModel {
|
||||
}
|
||||
|
||||
export class UserSchema extends BaseModel {
|
||||
static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'organizationId', 'password', 'signature', 'updatedAt'] as const
|
||||
static $columns = ['createdAt', 'email', 'fullName', 'googleId', 'id', 'microsoftId', 'organizationId', 'password', 'signature', 'updatedAt'] as const
|
||||
$columns = UserSchema.$columns
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime
|
||||
@ -315,6 +354,8 @@ 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
|
||||
|
||||
@ -73,6 +73,12 @@ 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
|
||||
|
||||
@ -43,6 +43,12 @@ 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')
|
||||
@ -97,6 +103,10 @@ 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')
|
||||
@ -131,6 +141,22 @@ 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).
|
||||
*/
|
||||
@ -139,6 +165,9 @@ 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')
|
||||
@ -161,6 +190,14 @@ 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,
|
||||
@ -183,6 +220,10 @@ 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')
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
"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"
|
||||
|
||||
@ -1,27 +1,44 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Bouton "Continuer avec Google".
|
||||
* Bouton SSO réutilisable (Google, Microsoft, …).
|
||||
*
|
||||
* IMPORTANT — c'est un `<a href>`, PAS un bouton fetch :
|
||||
* IMPORTANT — c'est un `<a href>`, PAS un fetch button :
|
||||
* OAuth nécessite un full-page redirect (le browser doit naviguer vers
|
||||
* l'écran de consentement Google). Un fetch ne peut pas suivre les
|
||||
* redirections cross-origin avec cookies.
|
||||
* l'écran de consentement du provider avec ses cookies). Un fetch ne
|
||||
* peut pas suivre les redirections cross-origin avec cookies.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function GoogleButton({
|
||||
label = "Continuer avec Google",
|
||||
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,
|
||||
className,
|
||||
}: {
|
||||
provider: SsoProvider;
|
||||
/** Override label (par défaut "Continuer avec X"). */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const Logo = LOGOS[provider];
|
||||
return (
|
||||
<a
|
||||
href="/api/v1/auth/google/redirect"
|
||||
href={`${env.VITE_API_URL}/api/v1/auth/${provider}/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",
|
||||
@ -33,13 +50,13 @@ export function GoogleButton({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<GoogleLogo aria-hidden="true" />
|
||||
{label}
|
||||
<Logo />
|
||||
{label ?? LABELS[provider]}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Logo Google officiel — 4 couleurs, taille fixe 18px. */
|
||||
/** Logo Google officiel — 4 couleurs, 18px. */
|
||||
function GoogleLogo(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" {...props}>
|
||||
@ -63,7 +80,19 @@ function GoogleLogo(props: React.SVGProps<SVGSVGElement>) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Séparateur "ou" entre le bouton SSO et le formulaire email/password. */
|
||||
/** 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. */
|
||||
export function AuthDivider({ label = "ou" }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
68
apps/web/src/components/charts/ChartTooltip.tsx
Normal file
68
apps/web/src/components/charts/ChartTooltip.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
91
apps/web/src/components/charts/ClientPaidChart.tsx
Normal file
91
apps/web/src/components/charts/ClientPaidChart.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
105
apps/web/src/components/charts/DsoTrendChart.tsx
Normal file
105
apps/web/src/components/charts/DsoTrendChart.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/components/charts/EncaisseChart.tsx
Normal file
76
apps/web/src/components/charts/EncaisseChart.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
120
apps/web/src/components/charts/PipelineChart.tsx
Normal file
120
apps/web/src/components/charts/PipelineChart.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
81
apps/web/src/components/charts/theme.ts
Normal file
81
apps/web/src/components/charts/theme.ts
Normal file
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@ -15,7 +15,8 @@ import { cn } from "@/lib/utils";
|
||||
* - On laisse l'utilisateur déclarer l'intent.
|
||||
*/
|
||||
type KpiCardProps = {
|
||||
label: string;
|
||||
/** Texte ou node — accepte un GlossaryTerm si la métrique a une définition. */
|
||||
label: React.ReactNode;
|
||||
value: string;
|
||||
delta?: string;
|
||||
/** Sens du delta affiché (sert juste à colorer subtilement). Default neutral. */
|
||||
|
||||
224
apps/web/src/components/demo/DemoClock.tsx
Normal file
224
apps/web/src/components/demo/DemoClock.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
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 été 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`;
|
||||
}
|
||||
425
apps/web/src/components/demo/DemoEmailSlide.tsx
Normal file
425
apps/web/src/components/demo/DemoEmailSlide.tsx
Normal file
@ -0,0 +1,425 @@
|
||||
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 été 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} <{email.from.email}>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
102
apps/web/src/components/demo/DemoToggle.tsx
Normal file
102
apps/web/src/components/demo/DemoToggle.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,23 +1,84 @@
|
||||
import { FileText } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FileText, AlertCircle } from "lucide-react";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Aperçu PDF côté review OCR — placeholder visuel.
|
||||
* Aperçu du fichier importé (PDF / image) — utilisé sur :
|
||||
* - la review OCR (volet gauche, source = batch + draft)
|
||||
* - la fiche facture (source = invoice id direct)
|
||||
*
|
||||
* 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é.
|
||||
* 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.
|
||||
*/
|
||||
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, className }: PdfPreviewProps) {
|
||||
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/");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -32,37 +93,72 @@ export function PdfPreview({ filename, className }: PdfPreviewProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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 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 />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<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'import.
|
||||
/**
|
||||
* 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -7,6 +7,7 @@ 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,
|
||||
@ -95,6 +96,9 @@ function AppLayoutInner({ children, title, subtitle, actions }: AppLayoutProps)
|
||||
</div>
|
||||
|
||||
<MobileTabBar />
|
||||
|
||||
{/* Horloge démo — auto-cachée si org.demoMode = false */}
|
||||
<DemoClock />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
ListChecks,
|
||||
Users,
|
||||
Settings,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Brand } from "@/components/brand/Brand";
|
||||
@ -36,6 +37,7 @@ 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>
|
||||
|
||||
|
||||
@ -84,7 +84,7 @@ export function AiGenerateModal({
|
||||
<DialogContent maxWidth={720}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 border border-rubis">
|
||||
<Sparkles size={16} className="text-rubis" /> Générer avec l'IA
|
||||
</span>
|
||||
</DialogTitle>
|
||||
|
||||
80
apps/web/src/components/ui/GlossaryTerm.tsx
Normal file
80
apps/web/src/components/ui/GlossaryTerm.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/components/ui/Pagination.tsx
Normal file
95
apps/web/src/components/ui/Pagination.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -34,6 +34,25 @@ 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;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -121,9 +140,11 @@ async function rawRequest<T>(path: string, options: RequestOptions = {}): Promis
|
||||
);
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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;
|
||||
return (json?.data ?? json) as T;
|
||||
}
|
||||
|
||||
@ -169,6 +190,16 @@ 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 où 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,
|
||||
@ -181,4 +212,23 @@ 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 };
|
||||
},
|
||||
};
|
||||
|
||||
243
apps/web/src/lib/demo.ts
Normal file
243
apps/web/src/lib/demo.ts
Normal file
@ -0,0 +1,243 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
46
apps/web/src/lib/glossary.tsx
Normal file
46
apps/web/src/lib/glossary.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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;
|
||||
@ -21,6 +21,7 @@ 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,
|
||||
},
|
||||
|
||||
@ -28,9 +28,20 @@ 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[];
|
||||
};
|
||||
@ -55,6 +66,12 @@ 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>("");
|
||||
@ -190,6 +207,19 @@ 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>
|
||||
|
||||
@ -17,9 +17,12 @@ 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",
|
||||
@ -52,7 +55,7 @@ export const Route = createFileRoute("/_app/factures")({
|
||||
loader: ({ context }) => {
|
||||
void context.queryClient.prefetchQuery({
|
||||
queryKey: queryKeys.invoices.list({}),
|
||||
queryFn: () => api.get<InvoiceListItem[]>("/api/v1/invoices"),
|
||||
queryFn: () => api.getList<InvoiceListItem>("/api/v1/invoices"),
|
||||
});
|
||||
},
|
||||
});
|
||||
@ -100,24 +103,28 @@ function FacturesPage() {
|
||||
if (files.length > 0 && !upload.isPending) upload.mutate(files);
|
||||
};
|
||||
|
||||
const { data: invoices = [], isPending } = useQuery({
|
||||
const currentPage = search.page ?? 1;
|
||||
const { data: response, isPending } = useQuery({
|
||||
queryKey: queryKeys.invoices.list({
|
||||
status: search.status as InvoiceStatus | "all" | undefined,
|
||||
q: search.q,
|
||||
clientId: search.clientId,
|
||||
page: search.page,
|
||||
page: currentPage,
|
||||
}),
|
||||
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.get<InvoiceListItem[]>(
|
||||
return api.getList<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,
|
||||
@ -197,6 +204,19 @@ 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">
|
||||
|
||||
@ -16,6 +16,7 @@ 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(),
|
||||
@ -76,9 +77,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 check-in a expiré.",
|
||||
invalid: "Ce lien de check-in est invalide.",
|
||||
already_answered: "Ce check-in avait déjà été traité.",
|
||||
expired: "Ce lien de confirmation a expiré.",
|
||||
invalid: "Ce lien de confirmation est invalide.",
|
||||
already_answered: "Cette confirmation avait déjà été traitée.",
|
||||
};
|
||||
|
||||
const isErrorToast = checkin === "expired" || checkin === "invalid";
|
||||
@ -171,18 +172,30 @@ function InvoiceDetailPage() {
|
||||
)}
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
|
||||
{/* Sidepanel : client + notes */}
|
||||
{/* Colonne droite : timeline + client + notes empilés */}
|
||||
<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 été 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">
|
||||
@ -225,9 +238,13 @@ 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.4fr_1fr]">
|
||||
<div className="h-72 rounded-card bg-cream-2" />
|
||||
<div className="h-72 rounded-card 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -55,6 +55,8 @@ 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>>;
|
||||
@ -241,7 +243,12 @@ function ImportReviewPage() {
|
||||
|
||||
{/* === Body : 2 cols === */}
|
||||
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_1.1fr]">
|
||||
<PdfPreview filename={currentDraft.filename} />
|
||||
<PdfPreview
|
||||
filename={currentDraft.filename}
|
||||
batchId={batchId}
|
||||
draftId={currentDraft.id}
|
||||
pdfAvailable={!!currentDraft.pdfStorageKey}
|
||||
/>
|
||||
|
||||
<Card padding="md" className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Camera, Plus, ArrowDownRight } from "lucide-react";
|
||||
import { Camera, Plus, ArrowDownRight, ArrowRight } 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 {
|
||||
@ -17,6 +21,27 @@ 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;
|
||||
@ -61,6 +86,11 @@ 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). */}
|
||||
@ -114,7 +144,9 @@ function DashboardPage() {
|
||||
intent="positive"
|
||||
/>
|
||||
<KpiCard
|
||||
label="DSO moyen"
|
||||
label={
|
||||
<GlossaryTerm definition={GLOSSARY.dso}>DSO moyen</GlossaryTerm>
|
||||
}
|
||||
value={`${kpis?.dsoDays ?? 0} j`}
|
||||
delta={
|
||||
kpis?.dsoDeltaDays && kpis.dsoDeltaDays !== 0
|
||||
@ -127,11 +159,76 @@ function DashboardPage() {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 lg:grid-cols-[1.2fr_1fr] lg:gap-5">
|
||||
<ActivityFeed events={activity} />
|
||||
{/* 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>
|
||||
<ActivityFeed events={activity} />
|
||||
</section>
|
||||
|
||||
{/* Petite signature visuelle en bas — discret, juste pour aérer. */}
|
||||
<p className="mt-2 hidden lg:flex items-center gap-1.5 text-[11px] text-ink-3">
|
||||
<ArrowDownRight size={12} aria-hidden="true" />
|
||||
|
||||
201
apps/web/src/routes/_app/insights.tsx
Normal file
201
apps/web/src/routes/_app/insights.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -5,6 +5,7 @@ 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,
|
||||
@ -63,6 +64,18 @@ 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"
|
||||
|
||||
@ -628,7 +628,7 @@ function StepMessages({
|
||||
Étape {selectedIdx + 1} · J{selected.offsetDays >= 0 ? "+" : ""}
|
||||
{selected.offsetDays}
|
||||
</p>
|
||||
<Button size="sm" variant="ghost" onClick={() => setAiOpen(true)}>
|
||||
<Button size="sm" variant="secondary" onClick={() => setAiOpen(true)}>
|
||||
<Sparkles size={13} className="text-rubis" /> Générer avec l'IA
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -9,9 +9,9 @@ import { authStore } from "@/lib/auth";
|
||||
import { Gem } from "@/components/brand/Gem";
|
||||
|
||||
/**
|
||||
* Callback Google côté SPA — se charge :
|
||||
* Callback SSO côté SPA — partagé entre Google et Microsoft. Se charge :
|
||||
* 1. d'appeler POST /api/v1/auth/refresh (le cookie httpOnly posé par
|
||||
* /api/v1/auth/google/callback est auto-envoyé)
|
||||
* la callback backend OAuth 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/google/complete")({
|
||||
export const Route = createFileRoute("/auth/sso/complete")({
|
||||
validateSearch: searchSchema,
|
||||
component: GoogleCompletePage,
|
||||
component: SsoCompletePage,
|
||||
});
|
||||
|
||||
function GoogleCompletePage() {
|
||||
function SsoCompletePage() {
|
||||
const { next } = Route.useSearch();
|
||||
const navigate = useNavigate();
|
||||
// Strict-mode protect : avoid double-firing the refresh in dev.
|
||||
@ -16,18 +16,31 @@ 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 { GoogleButton, AuthDivider } from "@/components/auth/GoogleButton";
|
||||
import { SsoButton, AuthDivider } from "@/components/auth/SsoButton";
|
||||
|
||||
const ssoErrorEnum = z
|
||||
.enum(["denied", "state_mismatch", "error", "no_email"])
|
||||
.optional();
|
||||
|
||||
const searchSchema = z.object({
|
||||
redirect: z.string().optional(),
|
||||
google: z.enum(["denied", "state_mismatch", "error", "no_email"]).optional(),
|
||||
google: ssoErrorEnum,
|
||||
microsoft: ssoErrorEnum,
|
||||
});
|
||||
|
||||
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é.",
|
||||
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é.",
|
||||
},
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
@ -39,12 +52,15 @@ function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch();
|
||||
|
||||
// Toast d'erreur si on revient d'un échec Google SSO (?google=denied|...).
|
||||
// Toast d'erreur si on revient d'un échec SSO (?google=denied, ?microsoft=…).
|
||||
useEffect(() => {
|
||||
if (search.google && GOOGLE_ERROR_MESSAGES[search.google]) {
|
||||
toast.error(GOOGLE_ERROR_MESSAGES[search.google]!);
|
||||
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]!);
|
||||
}
|
||||
}
|
||||
}, [search.google]);
|
||||
}, [search.google, search.microsoft]);
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (input: LoginInput) =>
|
||||
@ -138,8 +154,9 @@ function LoginPage() {
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="mt-7">
|
||||
<GoogleButton />
|
||||
<div className="mt-7 flex flex-col gap-2">
|
||||
<SsoButton provider="google" />
|
||||
<SsoButton provider="microsoft" />
|
||||
<AuthDivider />
|
||||
</div>
|
||||
|
||||
|
||||
@ -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 { GoogleButton, AuthDivider } from "@/components/auth/GoogleButton";
|
||||
import { SsoButton, AuthDivider } from "@/components/auth/SsoButton";
|
||||
|
||||
export const Route = createFileRoute("/signup")({
|
||||
component: SignupPage,
|
||||
@ -128,8 +128,9 @@ function SignupPage() {
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="mt-7">
|
||||
<GoogleButton label="S'inscrire avec Google" />
|
||||
<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" />
|
||||
<AuthDivider />
|
||||
</div>
|
||||
|
||||
|
||||
@ -135,3 +135,10 @@ 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
288
pnpm-lock.yaml
generated
@ -222,6 +222,9 @@ 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)
|
||||
@ -1931,6 +1934,17 @@ 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}
|
||||
@ -2387,6 +2401,9 @@ 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}
|
||||
@ -2786,6 +2803,33 @@ 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==}
|
||||
|
||||
@ -2845,6 +2889,9 @@ 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==}
|
||||
|
||||
@ -3319,6 +3366,50 @@ 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'}
|
||||
@ -3350,6 +3441,9 @@ 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==}
|
||||
|
||||
@ -3503,6 +3597,9 @@ 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'}
|
||||
@ -4005,6 +4102,12 @@ 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'}
|
||||
@ -4038,6 +4141,10 @@ 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'}
|
||||
@ -4856,6 +4963,18 @@ 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'}
|
||||
@ -4914,6 +5033,14 @@ 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'}
|
||||
@ -4930,6 +5057,14 @@ 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==}
|
||||
|
||||
@ -4945,6 +5080,9 @@ 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'}
|
||||
@ -5302,6 +5440,9 @@ 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==}
|
||||
|
||||
@ -5520,6 +5661,9 @@ 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}
|
||||
@ -7598,6 +7742,18 @@ 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
|
||||
|
||||
@ -8073,6 +8229,8 @@ 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))
|
||||
@ -8441,6 +8599,30 @@ 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': {}
|
||||
@ -8502,6 +8684,8 @@ 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)':
|
||||
@ -8992,6 +9176,44 @@ 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
|
||||
@ -9011,6 +9233,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decompress-response@6.0.0:
|
||||
@ -9122,6 +9346,8 @@ 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
|
||||
@ -9663,6 +9889,10 @@ snapshots:
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.7: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@ -9684,6 +9914,8 @@ snapshots:
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
interpret@2.2.0: {}
|
||||
|
||||
ioredis@5.10.1:
|
||||
@ -10452,6 +10684,15 @@ 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
|
||||
@ -10509,6 +10750,26 @@ 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
|
||||
@ -10524,6 +10785,12 @@ 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: {}
|
||||
@ -10534,6 +10801,8 @@ snapshots:
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-from@5.0.0: {}
|
||||
@ -10907,6 +11176,8 @@ snapshots:
|
||||
|
||||
timekeeper@2.3.1: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
@ -11094,6 +11365,23 @@ 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user