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

231 lines
8.7 KiB
TypeScript

/**
* Template "Minimal" — noir et blanc, aéré, aucun ornement.
*
* Cible : indépendants, designers, profils qui veulent un rendu Apple-clean.
* L'accent color n'est utilisé que pour le numéro de facture (sobre).
* Pas de filets, beaucoup d'espace blanc, typographie en valeur.
*/
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: 72,
paddingBottom: 60,
paddingHorizontal: 72,
fontSize: 10,
color: PALETTE.ink,
fontFamily: 'Helvetica',
lineHeight: 1.6,
},
// Header
header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 56 },
headerLeft: { flex: 1 },
headerRight: { flex: 1, alignItems: 'flex-end' },
logo: { width: 48, height: 18, objectFit: 'contain', marginBottom: 12 },
invoiceLabel: { fontSize: 9, textTransform: 'uppercase', color: PALETTE.ink3, letterSpacing: 2 },
invoiceNumero: { fontSize: 18, marginTop: 4 },
companyName: { fontSize: 11, fontWeight: 'bold' },
small: { fontSize: 9, color: PALETTE.ink2 },
// Méta
metaRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 48 },
metaBlock: { flex: 1 },
metaLabel: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, marginBottom: 6 },
metaValue: { fontSize: 11 },
// Table
table: { marginBottom: 24 },
tableHeader: { flexDirection: 'row', paddingBottom: 8 },
tableRow: { flexDirection: 'row', paddingVertical: 8 },
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: 32 },
totalRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
totalRowGrand: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 12,
marginTop: 8,
},
totalLabel: { color: PALETTE.ink2 },
grandLabel: { fontSize: 14, fontWeight: 'bold' },
grandAmount: { fontSize: 14, fontWeight: 'bold' },
// TVA breakdown
tvaBlock: { marginBottom: 24, width: '60%' },
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: 16, marginBottom: 16 },
paymentLabel: {
fontSize: 8,
textTransform: 'uppercase',
color: PALETTE.ink3,
marginBottom: 4,
},
legalBlock: {
marginTop: 'auto',
paddingTop: 24,
fontSize: 7,
color: PALETTE.ink3,
lineHeight: 1.5,
},
})
export function MinimalTemplate(props: InvoiceTemplateProps) {
const accent = { color: 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 sobre : logo + numéro grand */}
<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}
</View>
<View style={styles.headerRight}>
<Text style={styles.invoiceLabel}>Facture</Text>
<Text style={[styles.invoiceNumero, accent]}>{props.numero}</Text>
</View>
</View>
{/* Méta : client + dates */}
<View style={styles.metaRow}>
<View style={styles.metaBlock}>
<Text style={styles.metaLabel}>Client</Text>
<Text style={styles.metaValue}>{props.client.name}</Text>
{clientAddress.map((line, i) => (
<Text key={`c-${i}`} style={styles.small}>
{line}
</Text>
))}
{props.client.siret ? (
<Text style={styles.small}>SIRET {props.client.siret}</Text>
) : null}
</View>
<View style={[styles.metaBlock, { alignItems: 'flex-end' }]}>
<Text style={styles.metaLabel}>Émise le</Text>
<Text style={styles.metaValue}>{formatDate(props.issueDate)}</Text>
<Text style={[styles.metaLabel, { marginTop: 8 }]}>Échéance</Text>
<Text style={styles.metaValue}>{formatDate(props.dueDate)}</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}>
{props.tvaBreakdown.map((b) => (
<View key={`tva-${b.rate}`} style={styles.tvaRow}>
<Text style={styles.tvaCellLeft}>TVA {formatTvaRate(b.rate)} sur</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}>
<Text style={styles.grandLabel}>Total TTC</Text>
<Text style={styles.grandAmount}>{formatCents(props.amountTtcCents)}</Text>
</View>
</View>
{props.rib.iban || props.rib.bic ? (
<View style={styles.paymentBlock}>
<Text style={styles.paymentLabel}>Paiement</Text>
{props.rib.bankName ? <Text style={styles.small}>{props.rib.bankName}</Text> : null}
{props.rib.iban ? <Text style={styles.small}>IBAN {props.rib.iban}</Text> : null}
{props.rib.bic ? <Text style={styles.small}>BIC {props.rib.bic}</Text> : null}
</View>
) : null}
{props.footerNotes ? (
<View style={styles.paymentBlock}>
<Text style={styles.small}>{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>
)
}