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>
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
/**
|
|
* Template "Classique" — sobre, sérieux, header texte centré.
|
|
*
|
|
* Cible : cabinets, professions réglementées, structures traditionnelles.
|
|
* Pas de bandeau coloré, pas d'ornementation. L'accent color est utilisé
|
|
* uniquement pour les filets de séparation et le numéro de facture.
|
|
*/
|
|
|
|
import React from 'react'
|
|
import { Document, Page, View, Text, Image, StyleSheet } from '@react-pdf/renderer'
|
|
import {
|
|
type InvoiceTemplateProps,
|
|
PALETTE,
|
|
formatCents,
|
|
formatDate,
|
|
formatQuantity,
|
|
formatTvaRate,
|
|
formatAddress,
|
|
daysBetween,
|
|
} from '#pdf-templates/common'
|
|
|
|
const styles = StyleSheet.create({
|
|
page: {
|
|
paddingTop: 56,
|
|
paddingBottom: 56,
|
|
paddingHorizontal: 56,
|
|
fontSize: 10,
|
|
color: PALETTE.ink,
|
|
fontFamily: 'Helvetica',
|
|
lineHeight: 1.5,
|
|
},
|
|
// Header
|
|
headerCenter: { textAlign: 'center', marginBottom: 24 },
|
|
logo: { width: 80, height: 32, objectFit: 'contain', marginBottom: 8, alignSelf: 'center' },
|
|
companyName: { fontSize: 16, fontWeight: 'bold', marginBottom: 4 },
|
|
companyMeta: { fontSize: 9, color: PALETTE.ink2 },
|
|
divider: { borderBottomWidth: 1, marginVertical: 16 },
|
|
// Titre facture
|
|
invoiceBlock: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20 },
|
|
invoiceTitle: { fontSize: 22, fontWeight: 'bold' },
|
|
invoiceMeta: { fontSize: 10 },
|
|
invoiceMetaRow: { flexDirection: 'row', justifyContent: 'space-between', gap: 12 },
|
|
invoiceMetaLabel: { color: PALETTE.ink2 },
|
|
// Client
|
|
clientBlock: {
|
|
borderWidth: 1,
|
|
borderColor: PALETTE.line,
|
|
padding: 12,
|
|
marginBottom: 20,
|
|
width: '50%',
|
|
alignSelf: 'flex-end',
|
|
},
|
|
clientLabel: { fontSize: 8, textTransform: 'uppercase', color: PALETTE.ink3, marginBottom: 4 },
|
|
clientName: { fontSize: 11, 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, fontWeight: 'bold' },
|
|
// Totals
|
|
totalsBlock: { alignSelf: 'flex-end', width: '45%', marginBottom: 24 },
|
|
totalRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 3 },
|
|
totalRowGrand: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
paddingVertical: 8,
|
|
marginTop: 4,
|
|
borderTopWidth: 1,
|
|
},
|
|
totalLabel: { color: PALETTE.ink2 },
|
|
grandLabel: { fontSize: 12, fontWeight: 'bold' },
|
|
grandAmount: { fontSize: 12, 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.4,
|
|
},
|
|
})
|
|
|
|
export function ClassiqueTemplate(props: InvoiceTemplateProps) {
|
|
const accent = { color: props.accentColor }
|
|
const dividerAccent = { borderBottomColor: props.accentColor }
|
|
const grandAccent = { borderTopColor: 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 centré : logo + identité émetteur */}
|
|
<View style={styles.headerCenter}>
|
|
{props.logoUrl ? <Image src={props.logoUrl} style={styles.logo} /> : null}
|
|
<Text style={styles.companyName}>{props.issuer.companyName ?? '—'}</Text>
|
|
{issuerAddress.map((line, i) => (
|
|
<Text key={`addr-${i}`} style={styles.companyMeta}>
|
|
{line}
|
|
</Text>
|
|
))}
|
|
{props.issuer.siret ? (
|
|
<Text style={styles.companyMeta}>SIRET {props.issuer.siret}</Text>
|
|
) : null}
|
|
{props.issuer.tvaIntra ? (
|
|
<Text style={styles.companyMeta}>TVA {props.issuer.tvaIntra}</Text>
|
|
) : null}
|
|
</View>
|
|
|
|
<View style={[styles.divider, dividerAccent]} />
|
|
|
|
{/* Bloc facture (gauche) + meta (droite) */}
|
|
<View style={styles.invoiceBlock}>
|
|
<View>
|
|
<Text style={[styles.invoiceTitle, accent]}>FACTURE</Text>
|
|
<Text style={styles.invoiceMeta}>N° {props.numero}</Text>
|
|
</View>
|
|
<View style={{ minWidth: 180 }}>
|
|
<View style={styles.invoiceMetaRow}>
|
|
<Text style={styles.invoiceMetaLabel}>Date d'émission</Text>
|
|
<Text>{formatDate(props.issueDate)}</Text>
|
|
</View>
|
|
<View style={styles.invoiceMetaRow}>
|
|
<Text style={styles.invoiceMetaLabel}>Date d'échéance</Text>
|
|
<Text>{formatDate(props.dueDate)}</Text>
|
|
</View>
|
|
<View style={styles.invoiceMetaRow}>
|
|
<Text style={styles.invoiceMetaLabel}>Délai</Text>
|
|
<Text>
|
|
{props.paymentTermsDays} jour{props.paymentTermsDays > 1 ? 's' : ''}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Client */}
|
|
<View style={styles.clientBlock}>
|
|
<Text style={styles.clientLabel}>Adressée à</Text>
|
|
<Text style={styles.clientName}>{props.client.name}</Text>
|
|
{clientAddress.map((line, i) => (
|
|
<Text key={`cli-${i}`} style={styles.companyMeta}>
|
|
{line}
|
|
</Text>
|
|
))}
|
|
{props.client.siret ? (
|
|
<Text style={styles.companyMeta}>SIRET {props.client.siret}</Text>
|
|
) : null}
|
|
{props.client.tvaIntra ? (
|
|
<Text style={styles.companyMeta}>TVA {props.client.tvaIntra}</Text>
|
|
) : null}
|
|
</View>
|
|
|
|
{/* Table des lignes */}
|
|
<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>
|
|
|
|
{/* Ventilation TVA — affichée seulement si plusieurs taux */}
|
|
{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, grandAccent]}>
|
|
<Text style={[styles.grandLabel, accent]}>Total TTC</Text>
|
|
<Text style={[styles.grandAmount, accent]}>
|
|
{formatCents(props.amountTtcCents)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Paiement / RIB */}
|
|
{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}
|
|
|
|
{/* Pied légal */}
|
|
<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}
|
|
<Text>
|
|
Échéance : {formatDate(props.dueDate)} (
|
|
{daysBetween(props.issueDate, props.dueDate)} jours)
|
|
</Text>
|
|
</View>
|
|
</Page>
|
|
</Document>
|
|
)
|
|
}
|