since3-portal/modules/shared/pdf/invoice-document.tsx
2026-09-29 13:05:38 +02:00

212 lines
11 KiB
TypeScript

import { Document, Image as PdfImage, Link, Page, StyleSheet, Text, View } from "@react-pdf/renderer"
import type { InvoicePdfData } from "@/modules/shared/schemas/invoice"
import { formatAmount, formatCurrency, formatDate, invoiceTotals, lineNet } from "@/modules/shared/lib/invoice-math"
const COMPANY = {
name: "Since3 GbR",
street: "Sandweg 93",
city: "48565 Steinfurt",
phone: "+49 176 47601638",
email: "info@since3.de",
web: "www.since3.de",
taxNumber: "311/5963/2094",
accountHolder: "Since3",
iban: "DE35 4007 0211 0068 9224 00",
bic: "DEUTDE3BP10",
bank: "Postbank",
partners: "Ben Luis Dalhoff, Rajpreet Singh",
}
const INK = "#1a1a1a"
const MUTED = "#6e6e6e"
const LINK = "#1155cc"
const RULE = "#d9d9d9"
// Maße aus Rechnung_Template_Since3_.docx (twips / 20 = pt)
const ITEM_COLUMNS = [45, 225.3, 75, 75, 85]
const SUMMARY_COLUMNS = [85.3, 84, 84, 84, 84, 84]
const styles = StyleSheet.create({
// Ligaturen aus, sonst wird "tt"/"ff" beim Kopieren/Auslesen falsch extrahiert ("Bite" statt "Bitte").
page: { paddingTop: 35, paddingBottom: 95, paddingHorizontal: 45, fontFamily: "Google Sans", fontSize: 9, color: "#000", fontFeatureSettings: { liga: false, clig: false } },
gradient: { width: 499.5, height: 5.25 },
row: { flexDirection: "row" },
left: { width: 305.3 },
right: { width: 200, alignItems: "flex-end" },
senderLine: { fontSize: 7, color: MUTED, marginTop: 14, marginBottom: 6 },
recipient: { fontSize: 10.5, lineHeight: 1.35 },
bold: { fontWeight: 700 },
logo: { width: 122, height: 21.75, marginTop: 10 },
companyInfo: { fontSize: 8.5, textAlign: "right", lineHeight: 1.35 },
title: { fontSize: 20, fontWeight: 700, color: INK },
refBox: { width: 190.5, backgroundColor: "#f2f2f2", padding: 8, gap: 2 },
refHint: { fontSize: 8, fontWeight: 700, marginBottom: 2 },
refLine: { fontSize: 8.5, fontWeight: 700, color: MUTED },
itemHeader: { flexDirection: "row", backgroundColor: INK, color: "#fff", fontSize: 8, fontWeight: 700 },
itemRow: { flexDirection: "row", borderBottomWidth: 0.5, borderBottomColor: RULE, fontSize: 9 },
cell: { paddingVertical: 5, paddingHorizontal: 5 },
right_: { textAlign: "right" },
summaryHeader: { flexDirection: "row", borderTopWidth: 1, borderTopColor: INK, fontSize: 7.5, fontWeight: 700, color: MUTED },
summaryValues: { flexDirection: "row", fontSize: 9 },
total: { fontSize: 11, fontWeight: 700, color: INK, backgroundColor: "#f2f2f2" },
note: { fontSize: 8, color: MUTED, marginTop: 10 },
paymentBox: { borderWidth: 0.5, borderColor: RULE, backgroundColor: "#fafafa", padding: 10, marginTop: 16, gap: 2 },
paymentTitle: { fontSize: 9.5, fontWeight: 700, color: INK, marginBottom: 4 },
paymentText: { fontSize: 9, fontWeight: 700 },
footer: { position: "absolute", bottom: 35, left: 45, right: 45, gap: 4 },
footerText: { fontSize: 7, color: MUTED },
})
type PdfStyle = React.ComponentProps<typeof View>["style"]
function Cell({ width, align, children, style }: {
width: number
align?: "right"
children: React.ReactNode
style?: PdfStyle
}) {
return (
<View style={[styles.cell, { width }, style]}>
<Text style={align === "right" ? styles.right_ : {}}>{children}</Text>
</View>
)
}
export type InvoiceAssets = {
logo: { data: Buffer; format: "jpg" }
gradient: { data: Buffer; format: "png" }
}
function InvoicePage({ invoice, assets }: { invoice: InvoicePdfData; assets: InvoiceAssets }) {
const totals = invoiceTotals(invoice.items)
const showCountry = invoice.billingCountry.toUpperCase() !== "DE"
return (
<Page size="A4" style={styles.page}>
<PdfImage src={assets.gradient} style={styles.gradient} />
<View style={styles.row}>
<View style={styles.left}>
<Text style={styles.senderLine}>
{COMPANY.name} · {COMPANY.street} · {COMPANY.city}
</Text>
</View>
<View style={styles.right}>
<PdfImage src={assets.logo} style={styles.logo} />
</View>
</View>
<View style={[styles.row, { marginTop: 4 }]}>
<View style={[styles.left, styles.recipient]}>
<Text style={styles.bold}>{invoice.companyName}</Text>
<Text>{invoice.billingStreet}</Text>
<Text>{invoice.billingPostalCode} {invoice.billingCity}</Text>
{showCountry && <Text>{invoice.billingCountry.toUpperCase()}</Text>}
</View>
<View style={[styles.right, styles.companyInfo]}>
<Text style={styles.bold}>{COMPANY.name}</Text>
<Text>{COMPANY.street}</Text>
<Text>{COMPANY.city}</Text>
<Text>{COMPANY.phone}</Text>
<Link src={`mailto:${COMPANY.email}`} style={{ color: LINK, textDecoration: "none" }}>{COMPANY.email}</Link>
<Text>{COMPANY.web}</Text>
<Text>St.-Nr.: {COMPANY.taxNumber}</Text>
</View>
</View>
<View style={[styles.row, { marginTop: 28, marginBottom: 18, alignItems: "flex-end" }]}>
<View style={styles.left}>
<Text style={styles.title}>Rechnung</Text>
</View>
<View style={styles.right}>
<View style={styles.refBox}>
<Text style={styles.refHint}>Bitte bei Zahlung und Rückfragen angeben:</Text>
<Text style={styles.refLine}>Rechnungsnummer: {invoice.invoiceNumber}</Text>
<Text style={styles.refLine}>Rechnungsdatum: {formatDate(invoice.invoiceDate)}</Text>
<Text style={styles.refLine}>Leistungsdatum: {formatDate(invoice.invoiceDate)}</Text>
</View>
</View>
</View>
<View style={styles.itemHeader}>
<Cell width={ITEM_COLUMNS[0]}>Menge</Cell>
<Cell width={ITEM_COLUMNS[1]}>Bezeichnung</Cell>
<Cell width={ITEM_COLUMNS[2]} align="right">Preis/Einh. (€)</Cell>
<Cell width={ITEM_COLUMNS[3]} align="right">Gesamt (€)</Cell>
<Cell width={ITEM_COLUMNS[4]} align="right">MwSt (%)</Cell>
</View>
{invoice.items.map((item, index) => (
<View key={index} style={styles.itemRow} wrap={false}>
<Cell width={ITEM_COLUMNS[0]}>{item.quantity.toLocaleString("de-DE")}</Cell>
<Cell width={ITEM_COLUMNS[1]}>{item.description}</Cell>
<Cell width={ITEM_COLUMNS[2]} align="right">{formatAmount(item.unitPrice)}</Cell>
<Cell width={ITEM_COLUMNS[3]} align="right">{formatAmount(lineNet(item))}</Cell>
<Cell width={ITEM_COLUMNS[4]} align="right">{formatAmount(item.taxRate)}</Cell>
</View>
))}
<View wrap={false} style={{ marginTop: 18 }}>
<View style={styles.summaryHeader}>
<Cell width={SUMMARY_COLUMNS[0]}>Fälligkeit</Cell>
<Cell width={SUMMARY_COLUMNS[1]} align="right">Teilbetrag</Cell>
<Cell width={SUMMARY_COLUMNS[2]} align="right">MwSt</Cell>
<Cell width={SUMMARY_COLUMNS[3]} align="right">Betrag MwSt</Cell>
<Cell width={SUMMARY_COLUMNS[4]} align="right">Brutto</Cell>
<Cell width={SUMMARY_COLUMNS[5]} align="right">Gesamtbetrag</Cell>
</View>
<View style={styles.summaryValues}>
<Cell width={SUMMARY_COLUMNS[0]} style={styles.bold}>{formatDate(invoice.dueDate)}</Cell>
<Cell width={SUMMARY_COLUMNS[1]} align="right">{formatCurrency(totals.net)}</Cell>
<Cell width={SUMMARY_COLUMNS[2]} align="right">
{totals.uniformTaxRate === null ? "gemischt" : `${formatAmount(totals.uniformTaxRate)} %`}
</Cell>
<Cell width={SUMMARY_COLUMNS[3]} align="right">{formatCurrency(totals.tax)}</Cell>
<Cell width={SUMMARY_COLUMNS[4]} align="right">{formatCurrency(totals.gross)}</Cell>
<Cell width={SUMMARY_COLUMNS[5]} align="right" style={styles.total}>{formatCurrency(totals.gross)}</Cell>
</View>
{totals.tax === 0 && (
<Text style={styles.note}>Gemäß § 19 UStG enthält der Rechnungsbetrag keine Umsatzsteuer.</Text>
)}
</View>
<View wrap={false} style={styles.paymentBox}>
<Text style={styles.paymentTitle}>Zahlungsinformationen</Text>
<Text style={[styles.paymentText, { marginBottom: 4 }]}>
Bitte überweisen Sie den Gesamtbetrag bis zum {formatDate(invoice.dueDate)} auf folgendes Konto:
</Text>
<Text style={styles.paymentText}>Kontoinhaber: {COMPANY.accountHolder}</Text>
<Text style={styles.paymentText}>IBAN: {COMPANY.iban}</Text>
<Text style={styles.paymentText}>BIC: {COMPANY.bic}</Text>
<Text style={styles.paymentText}>Betrag: {formatCurrency(totals.gross)}</Text>
<Text style={styles.paymentText}>Verwendungszweck: {invoice.invoiceNumber}</Text>
</View>
<View wrap={false} style={{ marginTop: 14, gap: 10 }}>
<Text style={{ fontWeight: 700, color: LINK }}>
Hinweis: Bei Rückfragen kontaktieren Sie uns gerne unter {COMPANY.email}.
</Text>
<Text>Mit freundlichen Grüßen</Text>
<PdfImage src={assets.logo} style={{ width: 82.5, height: 14.25 }} />
</View>
<View style={styles.footer} fixed>
<PdfImage src={assets.gradient} style={styles.gradient} />
<Text style={styles.footerText}>Das Produkt bleibt bis zur vollständigen Bezahlung unser Eigentum.</Text>
<Text style={styles.footerText}>
{COMPANY.name}. Sitz der Gesellschaft: Steinfurt, Deutschland. Gesellschafter: {COMPANY.partners}.
</Text>
<Text style={styles.footerText}>
{COMPANY.bank}, Kontoinhaber: {COMPANY.accountHolder}, IBAN: {COMPANY.iban}, BIC: {COMPANY.bic}
</Text>
</View>
</Page>
)
}
export function InvoiceDocument({ invoice, assets }: { invoice: InvoicePdfData; assets: InvoiceAssets }) {
return (
<Document title={`Rechnung ${invoice.invoiceNumber}`} author={COMPANY.name}>
<InvoicePage invoice={invoice} assets={assets} />
</Document>
)
}