add factories
All checks were successful
Build & Deploy Web / build-and-deploy (push) Successful in 59s
Build & Deploy API / build-and-deploy (push) Successful in 1m37s

This commit is contained in:
ordinarthur 2026-05-07 11:34:00 +02:00
parent 933c6496b1
commit 1633fb9bf0
13 changed files with 1192 additions and 136 deletions

View File

@ -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 *)"
]
}
}

View File

@ -100,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,
})

View File

@ -13,6 +13,7 @@ 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
@ -95,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}`,
@ -325,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

View 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."
)
}
}

View File

@ -8,12 +8,17 @@
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 type Plan from '#models/plan'
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)
@ -330,17 +335,60 @@ const INVOICE_RECIPE: Array<{ status: InvoiceStatus; issueOffsetDays: number; pl
{ 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
const clientCount = Math.min(config.clientCount ?? 8, CLIENT_TEMPLATES.length)
// Crée les clients
// 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 }))
}
// Crée les factures (round-robin sur les clients + plans)
const invoices: Invoice[] = []
for (const [i, recipe] of INVOICE_RECIPE.entries()) {
const client = clients[i % clients.length]!
@ -354,12 +402,390 @@ export async function seedDemoOrg(config: DemoSeedConfig): Promise<DemoSeedResul
trx,
})
invoices.push(invoice)
await makeActivityForInvoice({
organizationId,
invoice,
client,
trx,
})
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)

View File

@ -220,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')

View File

@ -46,7 +46,7 @@ export const STATUS_COLOR: Record<string, string> = {
export const STATUS_LABEL: Record<string, string> = {
pending: "En attente",
awaiting_user_confirmation: "Check-in en cours",
awaiting_user_confirmation: "Confirmation en cours",
in_relance: "En relance",
litigation: "Litige",
paid: "Encaissé",

View File

@ -1,24 +1,62 @@
import { useEffect, useState } from "react";
import { Mail, ArrowRight, X } from "lucide-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";
/**
* Slide-over droite affichant l'email qui vient d'être déclenché.
* Tant que l'utilisateur n'a pas cliqué "Continuer la démo", l'horloge
* reste en pause c'est l'effet "le temps s'est arrêté pour montrer".
* 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",
hour: "2-digit",
minute: "2-digit",
});
type Step = "ask" | "email" | "paid";
export function DemoEmailSlide({
event,
remaining,
@ -30,13 +68,28 @@ export function DemoEmailSlide({
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(() => {
if (!event.capturedEmailId) {
setEmail(null);
return;
}
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")
@ -51,104 +104,322 @@ export function DemoEmailSlide({
};
}, [event.capturedEmailId]);
// Petit slide-in subtil — pas une animation tape-à-l'œil, on tient la DA.
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 discret on ne ferme pas au clic dehors (volontaire :
l'utilisateur DOIT acquitter pour reprendre la démo). */}
<div
aria-hidden="true"
className="fixed inset-0 z-40 bg-ink/10 backdrop-blur-[2px]"
{/* 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="Email reçu pendant la démo"
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",
"bg-cream border-l border-line shadow-card flex flex-col",
"animate-in slide-in-from-right duration-200",
)}
>
{/* Header */}
{/* 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",
event.kind === "relance" ? "bg-rubis text-white" : "bg-rubis-glow text-rubis-deep",
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">
{event.kind === "relance" ? "Relance envoyée" : "Check-in envoyé"}
{isRelance ? "Relance" : "Confirmation"} de Facture {event.invoiceNumero}
</p>
<p className="text-[11.5px] text-ink-3 capitalize">
{FR_DATETIME.format(virtualNow)} · facture {event.invoiceNumero}
{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>
{/* Email rendu façon vrai client mail */}
<div className="flex-1 overflow-y-auto px-5 py-5">
{email ? (
<article className="rounded-card border border-line bg-white shadow-soft overflow-hidden">
<div className="border-b border-line bg-cream-2/50 px-5 py-3 space-y-1">
<p className="text-[11.5px] text-ink-3">
<span className="font-semibold text-ink-2">De :</span>{" "}
{email.from.name} &lt;{email.from.email}&gt;
</p>
<p className="text-[11.5px] text-ink-3">
<span className="font-semibold text-ink-2">À :</span>{" "}
{email.to.name ? `${email.to.name}` : ""}
{email.to.email}
</p>
{email.replyTo && (
<p className="text-[11.5px] text-ink-3">
<span className="font-semibold text-ink-2">Reply-To :</span>{" "}
{email.replyTo}
</p>
)}
<p className="font-display text-[15px] font-bold text-ink mt-1.5 leading-tight">
{email.subject}
</p>
</div>
<div className="px-5 py-4">
<pre className="whitespace-pre-wrap font-sans text-[13.5px] leading-relaxed text-ink-2">
{email.body}
</pre>
</div>
</article>
) : (
<p className="text-[13px] italic text-ink-3 text-center py-8">
Chargement de l'email
</p>
{/* 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 */}
<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" : ""} email${remaining > 1 ? "s" : ""} à voir`
: "Cliquez pour reprendre la démo"}
</p>
<Button size="sm" onClick={onContinue}>
Continuer <ArrowRight size={14} />
</Button>
</div>
<button
type="button"
onClick={onContinue}
className="absolute top-3 right-3 size-7 flex items-center justify-center rounded-full text-ink-3 hover:bg-cream-2"
aria-label="Fermer"
>
<X size={14} />
</button>
{/* Footer — uniquement à l'étape email/paid (l'étape ask a ses propres boutons) */}
{step !== "ask" && (
<div className="border-t border-line bg-white px-5 py-4 flex items-center justify-between">
<p className="text-[11.5px] text-ink-3 italic">
{remaining > 0
? `${remaining} autre${remaining > 1 ? "s" : ""} event${remaining > 1 ? "s" : ""} en file`
: "Cliquez pour reprendre l'horloge"}
</p>
<Button size="sm" onClick={onContinue}>
Continuer la démo <ArrowRight size={14} />
</Button>
</div>
)}
</aside>
</>
);
}
/** Étape 1 — la question. */
function AskStep({
isRelance,
marking,
onYes,
onNo,
}: {
isRelance: boolean;
marking: boolean;
onYes: () => void;
onNo: () => void;
}) {
return (
<div className="flex flex-col items-start gap-4">
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold mb-2">
Avant d'envoyer
</p>
<h3 className="font-display text-[22px] font-bold tracking-[-0.018em] text-ink leading-tight">
Avez-vous é payé sur cette facture ?
</h3>
<p className="mt-2 text-[13px] text-ink-2 leading-relaxed">
{isRelance
? "Rubis est sur le point de relancer votre client. Si la facture vient d'être réglée, on évite l'email inutile et on encaisse +1 rubis."
: "Rubis s'apprête à vous demander confirmation. Si vous savez déjà qu'elle est payée, on saute cette étape."}
</p>
</div>
<div className="flex flex-col gap-2.5 w-full">
<Button
size="md"
variant="primary"
loading={marking}
onClick={onYes}
className="w-full justify-start"
>
<Check size={15} aria-hidden="true" />
Oui la facture est payée
</Button>
<Button
size="md"
variant="secondary"
onClick={onNo}
className="w-full justify-start"
>
<AlertCircle size={15} aria-hidden="true" />
Non toujours en attente
</Button>
</div>
<p className="text-[11.5px] text-ink-3 italic leading-snug">
En conditions réelles, l'email de confirmation pose cette question au
client final. Ici on raccourcit pour la démo.
</p>
</div>
);
}
/**
* Card de contexte facture visible en permanence dans le slide-over.
* Cliquable : ouvre la fiche facture dans la vraie app (ferme la slide,
* l'horloge reste en pause pour qu'on puisse revenir).
*/
function InvoiceCard({
invoice,
fallbackNumero,
onNavigate,
}: {
invoice: InvoiceDetail | null;
fallbackNumero: string;
onNavigate: () => void;
}) {
if (!invoice) {
return (
<div className="rounded-card border border-line bg-white px-4 py-3">
<p className="text-[12px] text-ink-3 italic">
Chargement de la facture {fallbackNumero}
</p>
</div>
);
}
const dueLabel = formatDueDelta(invoice.dueDate);
const isLate = isOverdue(invoice.dueDate) && invoice.status !== "paid";
return (
<Link
to="/factures/$id"
params={{ id: invoice.id }}
onClick={onNavigate}
className={cn(
"block rounded-card border border-line bg-white px-4 py-3.5",
"transition-[border-color,box-shadow,transform] duration-150",
"hover:border-rubis hover:shadow-soft hover:-translate-y-px",
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-rubis-glow",
)}
aria-label={`Voir la fiche de la facture ${invoice.numero}`}
>
{/* Header : numéro + statut + flèche externe */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5">
<FileText size={13} className="text-ink-3 shrink-0" aria-hidden="true" />
<p className="font-display text-[14px] font-semibold tracking-tight text-ink truncate">
{invoice.numero}
</p>
</div>
<p className="text-[12.5px] text-ink-2 truncate">{invoice.clientName}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusBadge status={invoice.status} withoutIcon />
<ExternalLink size={13} className="text-ink-3" aria-hidden="true" />
</div>
</div>
{/* Montant en gros + dates */}
<div className="flex items-end justify-between gap-3">
<p className="font-display text-[22px] font-bold tabular-nums leading-none text-ink">
{formatEuros(invoice.amountTtcCents)}
</p>
<div className="text-right">
<div className="flex items-center gap-1 justify-end text-[11.5px] text-ink-3 tabular-nums">
<Calendar size={11} aria-hidden="true" />
<span>
Émise {formatDate(invoice.issueDate)} · échue{" "}
{formatDate(invoice.dueDate)}
</span>
</div>
<p
className={cn(
"mt-0.5 text-[11.5px] font-medium tabular-nums",
isLate ? "text-rubis-deep" : "text-ink-3",
)}
>
{dueLabel}
</p>
</div>
</div>
{invoice.planName && (
<p className="mt-3 pt-3 border-t border-line text-[11.5px] text-ink-3">
Plan : <strong className="font-medium text-ink-2">{invoice.planName}</strong>
</p>
)}
</Link>
);
}
/** Étape 2A — preview de l'email envoyé (réponse "Non"). */
function EmailStep({ email }: { email: DemoCapturedEmail | null }) {
if (!email) {
return (
<p className="text-[13px] italic text-ink-3 text-center py-8">
Chargement de l'email
</p>
);
}
return (
<article className="rounded-card border border-line bg-white shadow-soft overflow-hidden">
<div className="border-b border-line bg-cream-2/50 px-5 py-3 space-y-1">
<p className="text-[11.5px] text-ink-3">
<span className="font-semibold text-ink-2">De :</span>{" "}
{email.from.name} &lt;{email.from.email}&gt;
</p>
<p className="text-[11.5px] text-ink-3">
<span className="font-semibold text-ink-2">À :</span>{" "}
{email.to.name ? `${email.to.name}` : ""}
{email.to.email}
</p>
{email.replyTo && (
<p className="text-[11.5px] text-ink-3">
<span className="font-semibold text-ink-2">Reply-To :</span>{" "}
{email.replyTo}
</p>
)}
<p className="font-display text-[15px] font-bold text-ink mt-1.5 leading-tight">
{email.subject}
</p>
</div>
<div className="px-5 py-4">
<pre className="whitespace-pre-wrap font-sans text-[13.5px] leading-relaxed text-ink-2">
{email.body}
</pre>
</div>
</article>
);
}
/** Étape 2B — confirmation paiement (réponse "Oui"). */
function PaidStep({ invoiceNumero }: { invoiceNumero: string }) {
return (
<div className="flex flex-col items-start gap-4 py-4">
<div className="size-12 rounded-full bg-rubis-glow flex items-center justify-center text-rubis-deep">
<Check size={22} strokeWidth={2.5} aria-hidden="true" />
</div>
<div>
<p className="text-[10.5px] uppercase tracking-[0.12em] text-rubis font-semibold mb-1">
Encaissée
</p>
<h3 className="font-display text-[22px] font-bold tracking-[-0.018em] text-ink leading-tight">
Facture <em className="text-rubis not-italic">{invoiceNumero}</em>{" "}
marquée payée
</h3>
<p className="mt-3 text-[13.5px] text-ink-2 leading-relaxed">
Les relances futures de cette facture sont annulées
automatiquement. Pas d'email inutile envoyé à votre client.
Vous gagnez{" "}
<strong className="text-rubis-deep">+1 rubis</strong> pour les 10 minutes
que Rubis vient de vous économiser.
</p>
</div>
<p className="text-[11.5px] text-ink-3 italic leading-snug">
Regardez le dashboard derrière : le compteur a déjà bougé.
</p>
</div>
);
}

View File

@ -4,24 +4,22 @@ import { api, ApiError } from "@/lib/api";
import { cn } from "@/lib/utils";
/**
* Aperçu du fichier importé (PDF / image) côté review OCR.
* 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 = placeholder hardcoded (`<PdfPreview filename={...} />` sans batchId)
* on n'avait jamais branché le streaming MinIO. Maintenant on fetch
* GET /invoices/import-batch/:id/drafts/:draftId/pdf via api.fetchBlob
* (Bearer auto-injecté), on crée un object URL, et on l'affiche dans :
* - <iframe> pour les PDF (le viewer Chrome/Safari natif s'occupe du rendu)
* - <img> pour les PNG/JPG
*
* Le fallback "barres" reste si le draft n'a pas de pdfStorageKey
* (cas rare : démo MSW, ou import par URL plus tard).
* 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;
/** Nécessaires pour fetch le binaire. Si absents → fallback placeholder. */
/** Source 1 : draft d'un import en cours (batchId + draftId). */
batchId?: string;
draftId?: string;
/** Indique si le backend a effectivement un PDF stocké pour ce draft. */
/** Source 2 : facture validée (invoiceId direct). Prioritaire. */
invoiceId?: string;
/** Indique si le backend a effectivement un fichier (sinon fallback). */
pdfAvailable?: boolean;
className?: string;
};
@ -30,6 +28,7 @@ export function PdfPreview({
filename,
batchId,
draftId,
invoiceId,
pdfAvailable = true,
className,
}: PdfPreviewProps) {
@ -38,7 +37,17 @@ export function PdfPreview({
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!batchId || !draftId || !pdfAvailable) {
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;
}
@ -47,10 +56,7 @@ export function PdfPreview({
setError(null);
api
.fetchBlob(
`/api/v1/invoices/import-batch/${batchId}/drafts/${draftId}/pdf`,
controller.signal,
)
.fetchBlob(path, controller.signal)
.then(({ blob, contentType: ct }) => {
url = URL.createObjectURL(blob);
setObjectUrl(url);
@ -69,7 +75,7 @@ export function PdfPreview({
controller.abort();
if (url) URL.revokeObjectURL(url);
};
}, [batchId, draftId, pdfAvailable]);
}, [batchId, draftId, invoiceId, pdfAvailable]);
const isImage = contentType.startsWith("image/");

View 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>
);
}

View File

@ -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 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,

View File

@ -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">

View File

@ -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 é 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>
);