517 lines
16 KiB
TypeScript
517 lines
16 KiB
TypeScript
/* eslint-disable react/no-unknown-property */
|
|
import {
|
|
Document,
|
|
Image,
|
|
Page,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from "@react-pdf/renderer";
|
|
import React from "react";
|
|
|
|
import { formatDate, formatMoney, formatQuantity, formatTaxRate } from "../format";
|
|
import { getStrings } from "../i18n";
|
|
import type { InvoiceLanguage } from "../i18n";
|
|
import type { InvoiceViewModel, InvoiceLine, IssuerData, RecipientData } from "../types";
|
|
|
|
// Brand blue chosen to roughly match the reference invoice. This is not
|
|
// pixel-perfect; merchants can tweak via a future setting if needed.
|
|
const BRAND_BLUE = "#1E8FCD";
|
|
const TEXT_DARK = "#1F2933";
|
|
const TEXT_MUTED = "#6B7280";
|
|
const TABLE_BORDER = "#E5E7EB";
|
|
|
|
const styles = StyleSheet.create({
|
|
page: {
|
|
paddingTop: 40,
|
|
paddingBottom: 110, // leaves room for fixed footer
|
|
paddingHorizontal: 40,
|
|
fontSize: 9,
|
|
fontFamily: "Helvetica",
|
|
color: TEXT_DARK,
|
|
lineHeight: 1.4,
|
|
},
|
|
headerRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "flex-start",
|
|
marginBottom: 24,
|
|
},
|
|
logo: {
|
|
maxHeight: 50,
|
|
maxWidth: 180,
|
|
objectFit: "contain",
|
|
},
|
|
senderLine: {
|
|
fontSize: 7,
|
|
color: TEXT_MUTED,
|
|
marginBottom: 4,
|
|
textDecoration: "underline",
|
|
},
|
|
recipientBlock: {
|
|
width: "55%",
|
|
},
|
|
recipientName: {
|
|
fontFamily: "Helvetica-Bold",
|
|
fontSize: 10,
|
|
},
|
|
metaBlock: {
|
|
width: "40%",
|
|
},
|
|
metaTable: {
|
|
flexDirection: "column",
|
|
},
|
|
metaRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
marginBottom: 2,
|
|
},
|
|
metaLabel: {
|
|
color: TEXT_MUTED,
|
|
},
|
|
metaValue: {
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
invoiceNumberBig: {
|
|
color: BRAND_BLUE,
|
|
fontFamily: "Helvetica-Bold",
|
|
fontSize: 14,
|
|
},
|
|
title: {
|
|
color: BRAND_BLUE,
|
|
fontFamily: "Helvetica-Bold",
|
|
fontSize: 18,
|
|
marginTop: 20,
|
|
marginBottom: 12,
|
|
},
|
|
paragraph: {
|
|
marginBottom: 6,
|
|
},
|
|
table: {
|
|
marginTop: 10,
|
|
borderTopWidth: 0,
|
|
},
|
|
tableHeader: {
|
|
flexDirection: "row",
|
|
backgroundColor: BRAND_BLUE,
|
|
color: "#FFFFFF",
|
|
fontFamily: "Helvetica-Bold",
|
|
paddingVertical: 6,
|
|
paddingHorizontal: 4,
|
|
},
|
|
tableRow: {
|
|
flexDirection: "row",
|
|
borderBottomWidth: 0.5,
|
|
borderBottomColor: TABLE_BORDER,
|
|
paddingVertical: 6,
|
|
paddingHorizontal: 4,
|
|
},
|
|
colPos: { width: "8%" },
|
|
colDescription: { width: "44%" },
|
|
colQty: { width: "16%", textAlign: "right" },
|
|
colUnit: { width: "16%", textAlign: "right" },
|
|
colTotal: { width: "16%", textAlign: "right" },
|
|
descriptionCell: {
|
|
flexDirection: "row",
|
|
alignItems: "flex-start",
|
|
gap: 6,
|
|
},
|
|
productIcon: {
|
|
width: 28,
|
|
height: 28,
|
|
objectFit: "contain",
|
|
borderWidth: 0.5,
|
|
borderColor: TABLE_BORDER,
|
|
borderRadius: 2,
|
|
},
|
|
productIconPlaceholder: {
|
|
width: 28,
|
|
height: 28,
|
|
},
|
|
descriptionText: {
|
|
flex: 1,
|
|
},
|
|
itemTitle: {
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
itemSku: {
|
|
color: TEXT_MUTED,
|
|
fontSize: 7,
|
|
marginTop: 1,
|
|
},
|
|
totalsBlock: {
|
|
marginTop: 10,
|
|
alignSelf: "flex-end",
|
|
width: "50%",
|
|
// Match the table rows' horizontal padding so the right-aligned amounts
|
|
// line up perfectly with the "Total" column above.
|
|
paddingHorizontal: 4,
|
|
},
|
|
totalRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
paddingVertical: 3,
|
|
},
|
|
totalLabel: {
|
|
color: TEXT_DARK,
|
|
},
|
|
totalLabelBlue: {
|
|
color: BRAND_BLUE,
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
totalValue: {
|
|
textAlign: "right",
|
|
},
|
|
totalValueBoldBlue: {
|
|
textAlign: "right",
|
|
color: BRAND_BLUE,
|
|
fontFamily: "Helvetica-Bold",
|
|
},
|
|
noticeBlock: {
|
|
marginTop: 10,
|
|
padding: 6,
|
|
backgroundColor: "#F3F4F6",
|
|
fontSize: 8,
|
|
},
|
|
giroBlock: {
|
|
marginTop: 20,
|
|
flexDirection: "row",
|
|
alignItems: "flex-start",
|
|
gap: 10,
|
|
},
|
|
giroImage: {
|
|
width: 90,
|
|
height: 90,
|
|
},
|
|
giroCaption: {
|
|
fontFamily: "Helvetica-Bold",
|
|
color: BRAND_BLUE,
|
|
fontSize: 9,
|
|
marginBottom: 4,
|
|
},
|
|
giroDetails: {
|
|
fontSize: 8,
|
|
color: TEXT_DARK,
|
|
lineHeight: 1.4,
|
|
},
|
|
closing: {
|
|
marginTop: 24,
|
|
},
|
|
footer: {
|
|
position: "absolute",
|
|
bottom: 30,
|
|
left: 40,
|
|
right: 40,
|
|
borderTopWidth: 0.5,
|
|
borderTopColor: BRAND_BLUE,
|
|
paddingTop: 6,
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
fontSize: 7,
|
|
color: TEXT_DARK,
|
|
},
|
|
footerCol: { width: "23%" },
|
|
footerHeading: {
|
|
fontFamily: "Helvetica-Bold",
|
|
color: BRAND_BLUE,
|
|
fontSize: 7,
|
|
marginBottom: 3,
|
|
},
|
|
pageIndicator: {
|
|
position: "absolute",
|
|
bottom: 12,
|
|
right: 40,
|
|
fontSize: 7,
|
|
color: TEXT_MUTED,
|
|
},
|
|
stornoBanner: {
|
|
backgroundColor: "#B91C1C",
|
|
color: "#FFFFFF",
|
|
fontFamily: "Helvetica-Bold",
|
|
padding: 6,
|
|
marginBottom: 10,
|
|
fontSize: 11,
|
|
textAlign: "center",
|
|
},
|
|
});
|
|
|
|
interface DocProps {
|
|
invoice: InvoiceViewModel;
|
|
}
|
|
|
|
export function InvoiceDocument({ invoice }: DocProps) {
|
|
const t = getStrings(invoice.language);
|
|
const cur = invoice.currency;
|
|
|
|
return (
|
|
<Document
|
|
title={`${t.invoice} ${invoice.number}`}
|
|
author={invoice.issuer.companyName}
|
|
creator="LinumIQ Invoice"
|
|
>
|
|
<Page size="A4" style={styles.page}>
|
|
{invoice.kind === "storno" && (
|
|
<Text style={styles.stornoBanner}>
|
|
{t.stornoInvoice}
|
|
{invoice.cancelsNumber ? ` — ${t.stornoReference(invoice.cancelsNumber)}` : ""}
|
|
</Text>
|
|
)}
|
|
|
|
<Header issuer={invoice.issuer} />
|
|
|
|
<View style={styles.headerRow}>
|
|
<View style={styles.recipientBlock}>
|
|
<Text style={styles.senderLine}>{senderInline(invoice.issuer)}</Text>
|
|
<Recipient recipient={invoice.recipient} />
|
|
</View>
|
|
<View style={styles.metaBlock}>
|
|
<View style={styles.metaTable}>
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{t.invoiceNumber}</Text>
|
|
<Text style={styles.invoiceNumberBig}>{invoice.number}</Text>
|
|
</View>
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{t.invoiceDate}</Text>
|
|
<Text style={styles.metaValue}>{formatDate(invoice.invoiceDate, invoice.language)}</Text>
|
|
</View>
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{t.deliveryDate}</Text>
|
|
<Text style={styles.metaValue}>{formatDate(invoice.deliveryDate, invoice.language)}</Text>
|
|
</View>
|
|
{invoice.recipientVatId ? (
|
|
<View style={styles.metaRow}>
|
|
<Text style={styles.metaLabel}>{t.customerVatId}</Text>
|
|
<Text style={styles.metaValue}>{invoice.recipientVatId}</Text>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
|
|
<Text style={styles.title}>
|
|
{invoice.kind === "storno" ? t.stornoInvoice : t.invoice} Nr. {invoice.number}
|
|
</Text>
|
|
|
|
<Text style={styles.paragraph}>{t.salutationGeneric}</Text>
|
|
<Text style={styles.paragraph}>{t.thankYouLine}</Text>
|
|
|
|
<View style={styles.table}>
|
|
<View style={styles.tableHeader}>
|
|
<Text style={styles.colPos}>{t.position}</Text>
|
|
<Text style={styles.colDescription}>{t.description}</Text>
|
|
<Text style={styles.colQty}>{t.quantity}</Text>
|
|
<Text style={styles.colUnit}>{t.unitPrice}</Text>
|
|
<Text style={styles.colTotal}>{t.totalPrice}</Text>
|
|
</View>
|
|
{invoice.lines.map((line) => (
|
|
<LineRow key={line.position} line={line} language={invoice.language} currency={cur} />
|
|
))}
|
|
</View>
|
|
|
|
<View style={styles.totalsBlock}>
|
|
<View style={styles.totalRow}>
|
|
<Text style={styles.totalLabelBlue}>{t.netTotal}</Text>
|
|
<Text style={[styles.totalValue, { color: BRAND_BLUE, fontFamily: "Helvetica-Bold" }]}>
|
|
{formatMoney(invoice.totals.net, cur, invoice.language)}
|
|
</Text>
|
|
</View>
|
|
{invoice.totals.vatBreakdown.map((v) => (
|
|
<View key={`vat-${v.ratePct}`} style={styles.totalRow}>
|
|
<Text style={styles.totalLabel}>{t.vatLine(formatTaxRate(v.ratePct, invoice.language))}</Text>
|
|
<Text style={styles.totalValue}>{formatMoney(v.tax, cur, invoice.language)}</Text>
|
|
</View>
|
|
))}
|
|
<View style={[styles.totalRow, { borderTopWidth: 0.5, borderTopColor: TABLE_BORDER, marginTop: 4, paddingTop: 4 }]}>
|
|
<Text style={styles.totalLabelBlue}>{t.grossTotal}</Text>
|
|
<Text style={styles.totalValueBoldBlue}>
|
|
{formatMoney(invoice.totals.gross, cur, invoice.language)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{invoice.notices.length > 0 && (
|
|
<View style={styles.noticeBlock}>
|
|
{invoice.notices.map((n) => (
|
|
<Text key={n.kind}>
|
|
{n.kind === "reverseCharge" && t.reverseChargeNotice}
|
|
{n.kind === "export" && t.exportNotice}
|
|
{n.kind === "kleinunternehmer" && t.kleinunternehmerNotice}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
{!invoice.paid && (
|
|
<Text style={[styles.paragraph, { marginTop: 16 }]}>
|
|
{invoice.dueDate
|
|
? t.paymentTerms(
|
|
Math.max(0, Math.round((invoice.dueDate.getTime() - invoice.invoiceDate.getTime()) / 86400000)),
|
|
formatDate(invoice.dueDate, invoice.language),
|
|
)
|
|
: t.paymentTermsImmediate}
|
|
</Text>
|
|
)}
|
|
|
|
{invoice.giroCodePngDataUrl && !invoice.paid && (
|
|
<View style={styles.giroBlock}>
|
|
<Image src={invoice.giroCodePngDataUrl} style={styles.giroImage} />
|
|
<View>
|
|
<Text style={styles.giroCaption}>{t.giroCodeCaption}</Text>
|
|
<Text style={styles.giroDetails}>
|
|
{t.recipientLabel}: {[invoice.issuer.companyName, invoice.issuer.legalForm].filter(Boolean).join(" ")}
|
|
</Text>
|
|
{invoice.issuer.bankName ? (
|
|
<Text style={styles.giroDetails}>{t.bankLabel}: {invoice.issuer.bankName}</Text>
|
|
) : null}
|
|
<Text style={styles.giroDetails}>{t.ibanLabel}: {invoice.issuer.iban}</Text>
|
|
{invoice.issuer.bic ? (
|
|
<Text style={styles.giroDetails}>{t.bicLabel}: {invoice.issuer.bic}</Text>
|
|
) : null}
|
|
<Text style={styles.giroDetails}>
|
|
{t.amountLabel}: {formatMoney(invoice.totals.gross, cur, invoice.language)}
|
|
</Text>
|
|
<Text style={styles.giroDetails}>
|
|
{t.referenceLabel}: {invoice.invoiceNumber}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
<View style={styles.closing}>
|
|
<Text>{t.closing}</Text>
|
|
</View>
|
|
|
|
<Footer issuer={invoice.issuer} language={invoice.language} />
|
|
|
|
<Text
|
|
style={styles.pageIndicator}
|
|
render={({ pageNumber, totalPages }) => `${pageNumber}/${totalPages}`}
|
|
fixed
|
|
/>
|
|
</Page>
|
|
</Document>
|
|
);
|
|
}
|
|
|
|
function senderInline(issuer: IssuerData): string {
|
|
return [
|
|
[issuer.companyName, issuer.legalForm].filter(Boolean).join(" "),
|
|
issuer.addressLine1,
|
|
[issuer.postalCode, issuer.city].filter(Boolean).join(" "),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" - ");
|
|
}
|
|
|
|
function Header({ issuer }: { issuer: IssuerData }) {
|
|
return (
|
|
<View style={styles.headerRow}>
|
|
<View>{/* spacer; logo is right-aligned */}</View>
|
|
{issuer.logoDataUrl ? <Image src={issuer.logoDataUrl} style={styles.logo} /> : <View />}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function Recipient({ recipient }: { recipient: RecipientData }) {
|
|
const lines: string[] = [];
|
|
if (recipient.company) lines.push(recipient.company);
|
|
if (recipient.name && recipient.name !== recipient.company) lines.push(recipient.name);
|
|
if (recipient.addressLine1) lines.push(recipient.addressLine1);
|
|
if (recipient.addressLine2) lines.push(recipient.addressLine2);
|
|
const cityLine = [recipient.postalCode, recipient.city].filter(Boolean).join(" ");
|
|
if (cityLine) lines.push(cityLine);
|
|
if (recipient.countryCode) lines.push(recipient.countryCode);
|
|
|
|
return (
|
|
<View>
|
|
{lines.map((l, i) => (
|
|
<Text key={i} style={i === 0 ? styles.recipientName : undefined}>
|
|
{l}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function LineRow({
|
|
line,
|
|
language,
|
|
currency,
|
|
}: {
|
|
line: InvoiceLine;
|
|
language: InvoiceLanguage;
|
|
currency: string;
|
|
}) {
|
|
const t = getStrings(language);
|
|
return (
|
|
<View style={styles.tableRow}>
|
|
<Text style={styles.colPos}>{line.position}</Text>
|
|
<View style={[styles.colDescription, styles.descriptionCell]}>
|
|
{line.imageDataUrl ? (
|
|
<Image src={line.imageDataUrl} style={styles.productIcon} />
|
|
) : (
|
|
<View style={styles.productIconPlaceholder} />
|
|
)}
|
|
<View style={styles.descriptionText}>
|
|
<Text style={styles.itemTitle}>{line.title}</Text>
|
|
{line.sku ? <Text style={styles.itemSku}>SKU: {line.sku}</Text> : null}
|
|
</View>
|
|
</View>
|
|
<Text style={styles.colQty}>{formatQuantity(line.quantity, t.pieceUnit, language)}</Text>
|
|
<Text style={styles.colUnit}>{formatMoney(line.unitPriceNet, currency, language)}</Text>
|
|
<Text style={styles.colTotal}>{formatMoney(line.totalNet, currency, language)}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function Footer({ issuer, language }: { issuer: IssuerData; language: InvoiceLanguage }) {
|
|
const t = getStrings(language);
|
|
return (
|
|
<View style={styles.footer} fixed>
|
|
<View style={styles.footerCol}>
|
|
<Text style={styles.footerHeading}>{t.addressHeading}</Text>
|
|
<Text>{[issuer.companyName, issuer.legalForm].filter(Boolean).join(" ")}</Text>
|
|
{issuer.addressLine1 ? <Text>{issuer.addressLine1}</Text> : null}
|
|
{issuer.addressLine2 ? <Text>{issuer.addressLine2}</Text> : null}
|
|
<Text>{[issuer.postalCode, issuer.city].filter(Boolean).join(" ")}</Text>
|
|
{issuer.countryCode ? <Text>{issuer.countryCode}</Text> : null}
|
|
</View>
|
|
<View style={styles.footerCol}>
|
|
<Text style={styles.footerHeading}>{t.contactHeading}</Text>
|
|
{issuer.phone ? <Text>{t.phoneLabel}: {issuer.phone}</Text> : null}
|
|
{issuer.email ? <Text>{t.emailLabel}: {issuer.email}</Text> : null}
|
|
{issuer.website ? <Text>{t.webLabel}: {issuer.website}</Text> : null}
|
|
</View>
|
|
<View style={styles.footerCol}>
|
|
<Text style={styles.footerHeading}>{t.legalHeading}</Text>
|
|
{issuer.registrationCourt ? (
|
|
<Text>{t.legalCourtLabel}: {issuer.registrationCourt}</Text>
|
|
) : null}
|
|
{issuer.registrationNo ? <Text>{t.fnLabel}: {issuer.registrationNo}</Text> : null}
|
|
{issuer.vatId ? <Text>{t.vatIdLabel}: {issuer.vatId}</Text> : null}
|
|
{issuer.taxNumber ? <Text>{t.taxNumberLabel}: {issuer.taxNumber}</Text> : null}
|
|
{issuer.ownerName ? <Text>{t.ownerLabel}: {issuer.ownerName}</Text> : null}
|
|
</View>
|
|
<View style={styles.footerCol}>
|
|
<Text style={styles.footerHeading}>{t.bankHeading}</Text>
|
|
{issuer.bankName ? <Text>{t.bankLabel}: {issuer.bankName}</Text> : null}
|
|
{issuer.iban ? <Text>{t.ibanLabel}: {issuer.iban}</Text> : null}
|
|
{issuer.bic ? <Text>{t.bicLabel}: {issuer.bic}</Text> : null}
|
|
{pickFooterNote(issuer, language) ? <Text>{pickFooterNote(issuer, language)}</Text> : null}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Picks the footer note for the rendered language. English falls back to the
|
|
* German `footerNote` when `footerNoteEn` is empty (so existing single-language
|
|
* setups keep working). German always uses `footerNote`.
|
|
*/
|
|
function pickFooterNote(issuer: { footerNote: string; footerNoteEn: string }, language: InvoiceLanguage): string {
|
|
if (language === "en") {
|
|
return issuer.footerNoteEn?.trim() || issuer.footerNote || "";
|
|
}
|
|
return issuer.footerNote || "";
|
|
}
|