add factories
This commit is contained in:
parent
933c6496b1
commit
1633fb9bf0
@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
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."
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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é",
|
||||
|
||||
@ -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} <{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>
|
||||
) : (
|
||||
<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 é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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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/");
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user