ordinarthur ab07cd4a3b feat(invoices): génération PDF native via @react-pdf/renderer (Phase 2)
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>
2026-05-14 02:16:45 +02:00

287 lines
11 KiB
TypeScript

/**
* Template "Élégant" — filets fins, mise en page éditoriale.
*
* Cible : boutiques premium, artisanat haut de gamme, hôtellerie-restauration
* qualitative. Filets de séparation horizontaux discrets, deux colonnes
* pour l'identité, accent color sur les filets et le mot "Facture" centré.
*/
import React from 'react'
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer'
import {
type InvoiceTemplateProps,
PALETTE,
formatCents,
formatDate,
formatQuantity,
formatTvaRate,
formatAddress,
} from '#pdf-templates/common'
const styles = StyleSheet.create({
page: {
paddingTop: 56,
paddingBottom: 56,
paddingHorizontal: 64,
fontSize: 10,
color: PALETTE.ink,
fontFamily: 'Times-Roman',
lineHeight: 1.55,
},
// Header deux colonnes
header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20 },
headerLeft: { flex: 1 },
headerRight: { flex: 1, alignItems: 'flex-end' },
logo: { width: 64, height: 26, objectFit: 'contain', marginBottom: 10 },
companyName: { fontSize: 12, fontWeight: 'bold', marginBottom: 2 },
small: { fontSize: 9, color: PALETTE.ink2 },
// Titre centré
titleBlock: { alignItems: 'center', marginVertical: 16 },
titleAccent: { fontSize: 9, textTransform: 'uppercase', letterSpacing: 4, marginBottom: 6 },
titleText: { fontSize: 24, fontWeight: 'bold', fontFamily: 'Times-Bold' },
titleNumero: { fontSize: 11, marginTop: 4, color: PALETTE.ink2, fontStyle: 'italic' },
hairline: { borderBottomWidth: 0.5, marginVertical: 12 },
// Méta
metaRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24 },
metaBlock: { flex: 1 },
metaLabel: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, marginBottom: 4 },
metaValue: { fontSize: 11 },
clientName: { fontSize: 12, fontWeight: 'bold' },
// Table
table: { marginBottom: 16 },
tableHeader: {
flexDirection: 'row',
borderBottomWidth: 1,
paddingBottom: 6,
marginBottom: 4,
},
tableRow: {
flexDirection: 'row',
paddingVertical: 6,
borderBottomWidth: 0.5,
borderBottomColor: PALETTE.line,
},
cellDesc: { flex: 4 },
cellQty: { flex: 1, textAlign: 'right' },
cellPu: { flex: 1.5, textAlign: 'right' },
cellTva: { flex: 1, textAlign: 'right' },
cellTotal: { flex: 1.5, textAlign: 'right' },
cellHead: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, letterSpacing: 1 },
// Totaux
totalsBlock: { alignSelf: 'flex-end', width: '45%', marginBottom: 24 },
totalRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
totalRowGrand: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingTop: 10,
paddingBottom: 4,
marginTop: 6,
borderTopWidth: 1,
borderBottomWidth: 0.5,
paddingHorizontal: 4,
},
totalLabel: { color: PALETTE.ink2 },
grandLabel: { fontSize: 13, fontWeight: 'bold' },
grandAmount: { fontSize: 13, fontWeight: 'bold' },
// TVA breakdown
tvaBlock: { marginBottom: 16, width: '60%' },
tvaHeader: {
flexDirection: 'row',
borderBottomWidth: 0.5,
borderBottomColor: PALETTE.line,
paddingBottom: 3,
},
tvaRow: { flexDirection: 'row', paddingVertical: 2 },
tvaCell: { flex: 1, textAlign: 'right', fontSize: 9 },
tvaCellLeft: { flex: 1, textAlign: 'left', fontSize: 9, color: PALETTE.ink2 },
// Footer
paymentBlock: { marginTop: 12, marginBottom: 12 },
paymentLabel: {
fontSize: 8,
textTransform: 'uppercase',
color: PALETTE.ink3,
marginBottom: 4,
},
legalBlock: {
marginTop: 'auto',
paddingTop: 12,
borderTopWidth: 0.5,
borderTopColor: PALETTE.line,
fontSize: 7,
color: PALETTE.ink3,
lineHeight: 1.5,
fontStyle: 'italic',
},
})
export function ElegantTemplate(props: InvoiceTemplateProps) {
const accent = { color: props.accentColor }
const hairlineAccent = { borderBottomColor: props.accentColor }
const issuerAddress = formatAddress({
line1: props.issuer.addressLine1,
line2: props.issuer.addressLine2,
zip: props.issuer.addressZip,
city: props.issuer.addressCity,
country: props.issuer.addressCountry,
})
const clientAddress = formatAddress({
line1: props.client.addressLine1,
line2: props.client.addressLine2,
zip: props.client.addressZip,
city: props.client.addressCity,
country: props.client.addressCountry,
})
const showTvaBreakdown = props.tvaBreakdown.length > 1
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header : émetteur à gauche, dates à droite */}
<View style={styles.header}>
<View style={styles.headerLeft}>
{props.logoUrl ? <Image src={props.logoUrl} style={styles.logo} /> : null}
<Text style={styles.companyName}>{props.issuer.companyName ?? '—'}</Text>
{issuerAddress.map((line, i) => (
<Text key={`a-${i}`} style={styles.small}>
{line}
</Text>
))}
{props.issuer.siret ? (
<Text style={styles.small}>SIRET {props.issuer.siret}</Text>
) : null}
{props.issuer.tvaIntra ? (
<Text style={styles.small}>TVA {props.issuer.tvaIntra}</Text>
) : null}
</View>
<View style={styles.headerRight}>
<Text style={styles.metaLabel}>Émise le</Text>
<Text style={styles.metaValue}>{formatDate(props.issueDate)}</Text>
<Text style={[styles.metaLabel, { marginTop: 6 }]}>Échéance</Text>
<Text style={styles.metaValue}>{formatDate(props.dueDate)}</Text>
</View>
</View>
{/* Titre centré */}
<View style={[styles.hairline, hairlineAccent]} />
<View style={styles.titleBlock}>
<Text style={[styles.titleAccent, accent]}>Facture</Text>
<Text style={styles.titleText}>N° {props.numero}</Text>
<Text style={styles.titleNumero}>
Établie le {formatDate(props.issueDate)}
</Text>
</View>
<View style={[styles.hairline, hairlineAccent]} />
{/* Méta client */}
<View style={styles.metaRow}>
<View style={styles.metaBlock}>
<Text style={styles.metaLabel}>Adressée à</Text>
<Text style={styles.clientName}>{props.client.name}</Text>
{clientAddress.map((line, i) => (
<Text key={`cli-${i}`} style={styles.small}>
{line}
</Text>
))}
{props.client.siret ? (
<Text style={styles.small}>SIRET {props.client.siret}</Text>
) : null}
{props.client.tvaIntra ? (
<Text style={styles.small}>TVA {props.client.tvaIntra}</Text>
) : null}
</View>
<View style={[styles.metaBlock, { alignItems: 'flex-end' }]}>
<Text style={styles.metaLabel}>Délai de paiement</Text>
<Text style={styles.metaValue}>
{props.paymentTermsDays} jour{props.paymentTermsDays > 1 ? 's' : ''}
</Text>
</View>
</View>
{/* Table */}
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.cellDesc, styles.cellHead]}>Désignation</Text>
<Text style={[styles.cellQty, styles.cellHead]}>Qté</Text>
<Text style={[styles.cellPu, styles.cellHead]}>P.U. HT</Text>
<Text style={[styles.cellTva, styles.cellHead]}>TVA</Text>
<Text style={[styles.cellTotal, styles.cellHead]}>Total HT</Text>
</View>
{props.lines.map((l) => (
<View key={l.id} style={styles.tableRow}>
<Text style={styles.cellDesc}>{l.description}</Text>
<Text style={styles.cellQty}>{formatQuantity(l.quantity)}</Text>
<Text style={styles.cellPu}>{formatCents(l.unitPriceCents)}</Text>
<Text style={styles.cellTva}>{formatTvaRate(l.tvaRate)}</Text>
<Text style={styles.cellTotal}>{formatCents(l.totalHtCents)}</Text>
</View>
))}
</View>
{showTvaBreakdown ? (
<View style={styles.tvaBlock}>
<View style={styles.tvaHeader}>
<Text style={[styles.tvaCellLeft, styles.cellHead]}>Taux</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Base HT</Text>
<Text style={[styles.tvaCell, styles.cellHead]}>Montant TVA</Text>
</View>
{props.tvaBreakdown.map((b) => (
<View key={`tva-${b.rate}`} style={styles.tvaRow}>
<Text style={styles.tvaCellLeft}>{formatTvaRate(b.rate)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.htCents)}</Text>
<Text style={styles.tvaCell}>{formatCents(b.tvaCents)}</Text>
</View>
))}
</View>
) : null}
{/* Totaux */}
<View style={styles.totalsBlock}>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total HT</Text>
<Text>{formatCents(props.amountHtCents)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>TVA</Text>
<Text>{formatCents(props.amountTvaCents)}</Text>
</View>
<View style={[styles.totalRowGrand, hairlineAccent, { borderTopColor: props.accentColor }]}>
<Text style={styles.grandLabel}>Total TTC</Text>
<Text style={[styles.grandAmount, accent]}>
{formatCents(props.amountTtcCents)}
</Text>
</View>
</View>
{props.rib.iban || props.rib.bic ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Coordonnées de paiement</Text>
{props.rib.bankName ? <Text>{props.rib.bankName}</Text> : null}
{props.rib.iban ? <Text>IBAN : {props.rib.iban}</Text> : null}
{props.rib.bic ? <Text>BIC : {props.rib.bic}</Text> : null}
</View>
) : null}
{props.footerNotes ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Notes</Text>
<Text>{props.footerNotes}</Text>
</View>
) : null}
<View style={styles.legalBlock}>
<Text>{props.penaltyRateText}</Text>
<Text>{props.escompteText}</Text>
{props.footerLegalText ? <Text>{props.footerLegalText}</Text> : null}
{props.issuer.rcs || props.issuer.capital ? (
<Text>
{[props.issuer.formeJuridique, props.issuer.capital, props.issuer.rcs]
.filter(Boolean)
.join(' · ')}
</Text>
) : null}
</View>
</Page>
</Document>
)
}