Implémente les 4 thèmes de factures (Classique, Moderne, Minimal, Élégant) en composants React PDF et remplace les stubs Phase 1 par la vraie génération + upload MinIO. Templates (app/pdf-templates/) - common.tsx : props partagées, formatters fr-FR (cents → euros, dates longues, taux TVA), palette neutre. - classique : sobre, header texte centré, filets fins. Pour les cabinets et professions réglementées. - moderne : bandeau coloré pleine largeur, logo dans le bandeau. Pour les agences et studios. - minimal : noir et blanc, aéré, accent uniquement sur le numéro. Pour les indépendants et les designers. - elegant : Times Roman, filets fins, titre centré encadré, italique sur le pied légal. Pour les boutiques premium. - index.tsx : dispatcher slug → composant + renderInvoiceToBuffer. Génération - media_storage : nouveau scope `invoice-pdf` (`invoices/<orgId>/<uuid>.pdf`) et fonction `uploadBuffer(buffer, scope, subPath?)` pour stocker les buffers générés en mémoire (vs. uploads multipart existants). - invoice_pdf : `generateInvoicePdf` rend + upload, `previewInvoicePdf` rend en Buffer pour stream HTTP direct. - InvoicesController.pdf : lazy regenerate si pdf_storage_key est null sur une facture native (cas où la génération initiale a échoué). - InvoicesController.previewPdf : synthétise un clientSnapshot depuis les données live, passe dans le pipeline standard. - InvoicesController.storeNative : appelle la vraie génération en post-commit, log + continue si échec. Conformité Factur-X (V1.5) : la structure de génération est un point d'extension Buffer → Buffer ; l'injection d'un XML CII en pièce jointe PDF sera ajoutée sans toucher aux templates. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
149 lines
4.9 KiB
TypeScript
149 lines
4.9 KiB
TypeScript
/**
|
|
* pdf-templates/common — types, formatters, et helpers partagés entre
|
|
* les 4 thèmes de factures Rubis.
|
|
*
|
|
* Les thèmes consomment tous le même `InvoiceTemplateProps`. Le dispatcher
|
|
* (`#pdf-templates/index`) sélectionne le bon composant selon le slug.
|
|
*
|
|
* Convention :
|
|
* - Tous les montants en *centimes* (int). On formate au moment du render
|
|
* via `formatCents(cents)` (sépare millier + ", " + " €").
|
|
* - Toutes les dates en `DateTime` Luxon — on formate via `formatDate(d)`
|
|
* en français long ("15 mai 2026").
|
|
*/
|
|
|
|
import { DateTime } from 'luxon'
|
|
import type {
|
|
InvoiceIssuer,
|
|
InvoiceThemeSlug,
|
|
} from '#services/invoice_settings'
|
|
import type {
|
|
ComputedInvoiceLine,
|
|
TvaBreakdownItem,
|
|
} from '#services/invoice_totals'
|
|
import type { ClientSnapshot } from '#models/invoice'
|
|
|
|
/**
|
|
* Props passées à chaque template. C'est le contrat figé que respectent
|
|
* tous les thèmes — ajouter un champ = mettre à jour les 4 composants.
|
|
*/
|
|
export interface InvoiceTemplateProps {
|
|
/** Métadonnées en-tête : numéro, dates, paiement. */
|
|
numero: string
|
|
issueDate: DateTime
|
|
dueDate: DateTime
|
|
paymentTermsDays: number
|
|
/** Émetteur (snapshot figé à l'émission). */
|
|
issuer: InvoiceIssuer
|
|
/** Client destinataire (snapshot figé à l'émission). */
|
|
client: ClientSnapshot
|
|
/** Lignes calculées (HT par ligne déjà arrondi). */
|
|
lines: ComputedInvoiceLine[]
|
|
/** Ventilation TVA — affichée seulement si plusieurs taux. */
|
|
tvaBreakdown: TvaBreakdownItem[]
|
|
/** Totaux agrégés (en centimes). */
|
|
amountHtCents: number
|
|
amountTvaCents: number
|
|
amountTtcCents: number
|
|
/** Mentions légales (snapshot des settings au moment de l'émission). */
|
|
penaltyRateText: string
|
|
escompteText: string
|
|
footerLegalText: string
|
|
/** Notes libres en pied de page (custom par facture). */
|
|
footerNotes: string | null
|
|
/** RIB pour le pied de page paiement. */
|
|
rib: {
|
|
iban: string | null
|
|
bic: string | null
|
|
bankName: string | null
|
|
}
|
|
/** Couleur d'accent appliquée (hex #RRGGBB). */
|
|
accentColor: string
|
|
/** URL absolue du logo (null = pas de logo). */
|
|
logoUrl: string | null
|
|
}
|
|
|
|
// ============================================================================
|
|
// Formatters (format français)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Formate des centimes en string "X 123,45 €". Pas de décimales sans virgule
|
|
* (toujours 2 chiffres après la virgule, exigence comptable).
|
|
*/
|
|
export function formatCents(cents: number): string {
|
|
const euros = cents / 100
|
|
return new Intl.NumberFormat('fr-FR', {
|
|
style: 'currency',
|
|
currency: 'EUR',
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}).format(euros)
|
|
}
|
|
|
|
/** Format français long : "15 mai 2026". */
|
|
export function formatDate(d: DateTime): string {
|
|
return d.setLocale('fr').toFormat('d MMMM yyyy')
|
|
}
|
|
|
|
/** Format quantité : 2 décimales si non-entier, sinon int. */
|
|
export function formatQuantity(q: number): string {
|
|
return Number.isInteger(q)
|
|
? String(q)
|
|
: new Intl.NumberFormat('fr-FR', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
}).format(q)
|
|
}
|
|
|
|
/** Format taux TVA : "20 %" ou "5,5 %". */
|
|
export function formatTvaRate(rate: number): string {
|
|
return Number.isInteger(rate) ? `${rate} %` : `${rate.toString().replace('.', ',')} %`
|
|
}
|
|
|
|
/**
|
|
* Formate l'adresse postale en multi-lignes utilisable dans le PDF.
|
|
* Retourne un tableau de strings non-vides (le template fait un map → Text).
|
|
*/
|
|
export function formatAddress(parts: {
|
|
line1: string | null | undefined
|
|
line2: string | null | undefined
|
|
zip: string | null | undefined
|
|
city: string | null | undefined
|
|
country: string | null | undefined
|
|
}): string[] {
|
|
const out: string[] = []
|
|
if (parts.line1) out.push(parts.line1)
|
|
if (parts.line2) out.push(parts.line2)
|
|
if (parts.zip || parts.city) {
|
|
out.push([parts.zip, parts.city].filter(Boolean).join(' '))
|
|
}
|
|
// Pays affiché uniquement hors France (par convention factures domestiques).
|
|
if (parts.country && parts.country !== 'FR') out.push(parts.country)
|
|
return out
|
|
}
|
|
|
|
/** Calcule le nombre de jours entre deux dates (issue vs due) pour affichage. */
|
|
export function daysBetween(from: DateTime, to: DateTime): number {
|
|
return Math.round(to.diff(from, 'days').days)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Palette commune
|
|
// ============================================================================
|
|
|
|
/** Tons de gris cohérents avec la palette Rubis (cream + ink). */
|
|
export const PALETTE = {
|
|
ink: '#1A1410',
|
|
ink2: '#4F4640',
|
|
ink3: '#8A7F76',
|
|
line: '#E8E0D6',
|
|
cream: '#FAF7F2',
|
|
paper: '#FFFFFF',
|
|
} as const
|
|
|
|
// ============================================================================
|
|
// Dispatcher type — réexporté pour le sélecteur côté `index.tsx`
|
|
// ============================================================================
|
|
export type { InvoiceThemeSlug }
|