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>
231 lines
8.7 KiB
TypeScript
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>
|
|
)
|
|
}
|