first version
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
/* 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" },
|
||||
itemTitle: {
|
||||
fontFamily: "Helvetica-Bold",
|
||||
},
|
||||
itemSku: {
|
||||
color: TEXT_MUTED,
|
||||
fontSize: 7,
|
||||
marginTop: 1,
|
||||
},
|
||||
totalsBlock: {
|
||||
marginTop: 10,
|
||||
alignSelf: "flex-end",
|
||||
width: "50%",
|
||||
},
|
||||
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>
|
||||
)}
|
||||
|
||||
<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}>{invoice.issuer.bankName}</Text>
|
||||
<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}>
|
||||
{formatMoney(invoice.totals.gross, cur, invoice.language)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.closing}>
|
||||
<Text>{t.closing}</Text>
|
||||
<Text style={{ fontFamily: "Helvetica-Bold", marginTop: 4 }}>
|
||||
{invoice.issuer.ownerName || invoice.issuer.companyName}
|
||||
</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}>
|
||||
<Text style={styles.itemTitle}>{line.title}</Text>
|
||||
{line.sku ? <Text style={styles.itemSku}>SKU: {line.sku}</Text> : null}
|
||||
</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}
|
||||
{issuer.footerNote ? <Text>{issuer.footerNote}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user