mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 20:51:17 +00:00
198 lines
10 KiB
TypeScript
198 lines
10 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"
|
|
import { COMPANY } from "@/modules/shared/data/company"
|
|
|
|
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>
|
|
{COMPANY.smallBusiness && 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>
|
|
)
|
|
}
|