feat: implement pdf generation service based on jsonb snapshots

This commit is contained in:
DanielS
2026-07-09 21:51:23 +02:00
parent 5eef6c76ef
commit 66039eb4cf

View File

@@ -1,6 +1,5 @@
import { Document, Page, Text, View, StyleSheet, Font } from '@react-pdf/renderer';
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer';
// Create styles
const styles = StyleSheet.create({
page: {
padding: 50,
@@ -31,7 +30,6 @@ const styles = StyleSheet.create({
marginBottom: 2,
},
table: {
width: 'auto',
borderStyle: 'solid',
borderWidth: 1,
@@ -63,16 +61,11 @@ const styles = StyleSheet.create({
padding: 8,
textAlign: 'right',
},
tableCell: {
margin: 'auto',
marginTop: 5,
fontSize: 10,
},
total: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 20,
borderTop: 1,
borderTopWidth: 1,
borderColor: '#000',
paddingTop: 10,
},
@@ -88,26 +81,30 @@ const styles = StyleSheet.create({
textAlign: 'center',
color: '#999',
fontSize: 8,
borderTop: 1,
borderTopWidth: 1,
borderColor: '#eee',
paddingTop: 10,
},
sepaBox: {
marginTop: 15,
padding: 10,
backgroundColor: '#f9f9f9',
borderWidth: 1,
borderColor: '#ddd',
}
});
export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
const items = orderSnapshot?.items ?? [];
const taxRate = orderSnapshot?.tax_rate ?? 19;
const isSubscription = order?.type === 'subscription' || orderSnapshot?.billing_cycle === 'monthly';
const oneTimeItems = items.filter((item: any) => item.billing_interval === 'one_time');
const monthlyItems = items.filter((item: any) => item.billing_interval === 'monthly');
const totalNet = items.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const totalTax = Math.round(totalNet * (taxRate / 100) * 100) / 100;
const totalGross = Math.round((totalNet + totalTax) * 100) / 100;
const oneTimeNet = oneTimeItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const oneTimeTax = Math.round(oneTimeNet * (taxRate / 100) * 100) / 100;
const oneTimeGross = Math.round((oneTimeNet + oneTimeTax) * 100) / 100;
const monthlyNet = monthlyItems.reduce((sum: number, item: any) => sum + (item.item_total || 0), 0);
const monthlyTax = Math.round(monthlyNet * (taxRate / 100) * 100) / 100;
const monthlyGross = Math.round((monthlyNet + monthlyTax) * 100) / 100;
const formattedPrice = (val: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val);
return (
<Document>
@@ -125,54 +122,26 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
</View>
</View>
<Text style={styles.title}>Anfragebestätigung</Text>
<Text style={styles.title}>
{isSubscription ? 'Vertragsbestätigung (Abonnement)' : 'B2B-Rechnung'}
</Text>
<View style={styles.section}>
<Text style={styles.label}>Anfragender Kunde</Text>
<Text style={styles.label}>Kunde</Text>
<Text>{customer.company_name}</Text>
<Text>{customer.first_name} {customer.last_name}</Text>
<Text>{customer.address}</Text>
<Text>{customer.zip_code} {customer.city}</Text>
<Text>USt-IdNr: {customer.vat_id}</Text>
{customer.bank_iban && (
<Text style={{ marginTop: 4, color: '#666' }}>
Bank: {customer.bank_name || '-'} · IBAN: {customer.bank_iban} · BIC: {customer.bank_bic || '-'} · Inhaber: {customer.bank_owner || '-'}
</Text>
)}
</View>
<View style={styles.section}>
<Text style={styles.label}>Anfragedetails</Text>
<Text>Anfrage-Nummer: {order.order_number || order.id}</Text>
<Text style={styles.label}>Bestelldetails</Text>
<Text>Bestellnummer: {order.order_number || order.id}</Text>
<Text>Zahlungsart: {isSubscription ? 'SEPA-Lastschrift' : 'Rechnung'}</Text>
<Text>Datum: {new Date(order.created_at || Date.now()).toLocaleDateString('de-DE')}</Text>
</View>
{orderSnapshot?.price_multiplier !== null && orderSnapshot?.price_multiplier !== undefined && (
<View style={[styles.section, { backgroundColor: '#f9f9f9', padding: 8 }]}>
<Text style={styles.label}>Update-Konditionen</Text>
<Text style={{ fontWeight: 'bold' }}>
{orderSnapshot.price_multiplier === 0
? "Update (0-12 Monate): 100% Rabatt (inklusive)"
: orderSnapshot.price_multiplier === 0.15
? "Update (13-14 Monate): 15% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.30
? "Update (15-25 Monate): 30% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.45
? "Update (26-37 Monate): 45% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.60
? "Update (38-49 Monate): 60% vom Listenpreis"
: orderSnapshot.price_multiplier === 0.90
? "Neukauf (>= 50 Monate): 10% Rabatt auf Neukauf"
: `Gebühr: ${orderSnapshot.price_multiplier * 100}%`}
</Text>
{orderSnapshot.last_license_date && (
<Text style={{ marginTop: 2, color: '#666' }}>
Letzte Lizenz vom: {new Date(orderSnapshot.last_license_date).toLocaleDateString('de-DE')}
</Text>
)}
</View>
)}
<View style={styles.table}>
<View style={[styles.tableRow, { backgroundColor: '#f9f9f9' }]}>
<View style={styles.tableCol}><Text style={{ fontWeight: 'bold' }}>Beschreibung</Text></View>
@@ -187,25 +156,23 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
</View>
<View style={styles.tableColPrice}>
<Text>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(item.base_price)}
{' '}{item.billing_interval === 'one_time' ? 'einmalig' : 'mtl.'}
{formattedPrice(item.base_price)} {isSubscription ? 'mtl.' : 'einmalig'}
</Text>
</View>
</View>
{item.selected_modules?.map((mod: any, mIdx: number) => {
const qty = mod.quantity || 1;
const price = mod.total_price ?? (mod.price * qty);
const hasQty = qty > 1;
return (
<View key={mIdx} style={styles.tableRow}>
<View style={[styles.tableCol, { paddingLeft: 20 }]}>
<Text style={{ color: '#666' }}>
+ {mod.module_name} {hasQty ? `(x${qty})` : ''}
+ {mod.module_name} {qty > 1 ? `(x${qty})` : ''}
</Text>
</View>
<View style={styles.tableColPrice}>
<Text style={{ color: '#666' }}>
+{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price)}
+{formattedPrice(price)}
</Text>
</View>
</View>
@@ -217,63 +184,49 @@ export const InvoicePDF = ({ order, orderSnapshot, customer }: any) => {
<View style={styles.total}>
<View style={{ width: '70%' }}>
{oneTimeNet > 0 && (
<View style={{ marginBottom: monthlyNet > 0 ? 10 : 0 }}>
{monthlyNet > 0 && (
<Text style={{ fontWeight: 'bold', marginBottom: 4, fontSize: 11 }}>Einmaliger Betrag:</Text>
)}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeNet)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeTax)}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5, gap: 15 }}>
<Text style={[styles.totalLabel, { flexShrink: 1 }]}>
{monthlyNet > 0 ? 'Gesamtbetrag einmalig (brutto):' : 'Gesamtbetrag (brutto):'}
</Text>
<Text style={[styles.totalLabel, { minWidth: 90, textAlign: 'right' }]}>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(oneTimeGross)}
</Text>
</View>
</View>
)}
{oneTimeNet > 0 && monthlyNet > 0 && (
<View style={{ borderTopWidth: 1, borderTopColor: '#eee', marginVertical: 10 }} />
)}
{monthlyNet > 0 && (
<View>
{oneTimeNet > 0 && (
<Text style={{ fontWeight: 'bold', marginBottom: 4, fontSize: 11 }}>Monatlicher Betrag:</Text>
)}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyNet)} / mtl.</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyTax)} / mtl.</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5, gap: 15 }}>
<Text style={[styles.totalLabel, { flexShrink: 1 }]}>
{oneTimeNet > 0 ? 'Gesamtbetrag monatlich (brutto):' : 'Gesamtbetrag (brutto):'}
</Text>
<Text style={[styles.totalLabel, { minWidth: 90, textAlign: 'right' }]}>
{new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(monthlyGross)} / mtl.
</Text>
</View>
</View>
)}
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>Netto-Zwischensumme:</Text>
<Text>{formattedPrice(totalNet)}{isSubscription ? ' / mtl.' : ''}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 3 }}>
<Text>zzgl. {taxRate}% USt:</Text>
<Text>{formattedPrice(totalTax)}{isSubscription ? ' / mtl.' : ''}</Text>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 5, borderTopWidth: 1, borderTopColor: '#000', paddingTop: 5 }}>
<Text style={styles.totalLabel}>
Gesamtbetrag (brutto):
</Text>
<Text style={styles.totalLabel}>
{formattedPrice(totalGross)}{isSubscription ? ' / mtl.' : ''}
</Text>
</View>
</View>
</View>
{isSubscription ? (
<View style={styles.sepaBox}>
<Text style={{ fontWeight: 'bold', marginBottom: 4 }}>SEPA-Lastschriftmandat</Text>
<Text style={{ fontSize: 8, color: '#555' }}>
Ich ermächtige die CASPOS GmbH, Zahlungen von meinem Konto mittels Lastschrift einzuziehen. Zugleich weise ich mein Kreditinstitut an, die von der CASPOS GmbH auf mein Konto gezogenen Lastschriften einzulösen.
</Text>
{customer.bank_iban && (
<Text style={{ marginTop: 4, fontSize: 8, fontWeight: 'bold' }}>
IBAN: {customer.bank_iban.replace(/(.{4})/g, '$1 ')} · Inhaber: {customer.bank_owner || 'Kunde'}
</Text>
)}
</View>
) : (
<View style={{ marginTop: 20 }}>
<Text style={{ fontWeight: 'bold' }}>Zahlungsziel</Text>
<Text style={{ color: '#555' }}>
Zahlbar innerhalb von 14 Tagen nach Erhalt dieser Rechnung ohne Abzug.
</Text>
</View>
)}
<View style={styles.footer}>
<Text>Vielen Dank für Ihre Bestellung!</Text>
<Text>Dies ist ein automatisch generiertes Dokument.</Text>
<Text>CASPOS Computerabrechnungssysteme GmbH · Amtsgericht Zweibrücken HRB 12345</Text>
<Text>Dies ist ein automatisch erzeugtes Dokument.</Text>
</View>
</Page>
</Document>