Invoice PDF layout: centered footer, clustered summary rows, more image/text spacing, relocated paid badge, stacked Packstation/Postnummer
Validate e-invoices / mustang (push) Successful in 22s

- Footer is centered, not left-aligned.
- Summary rows are grouped into visual clusters (Rabatt+Versand; each rate's own Netto/MwSt pair) with a bit of spacing between clusters, tight spacing within — makes it clearer which lines belong together.
- More space between the product image and the article text in each line item.
- "Bereits beglichen" badge moved from the top meta row to directly below "Gesamt", where it reads more naturally against the amount it confirms.
- Packstation number and Postnummer render as two separate lines instead of one "Packstation X · Postnummer Y" line.

Applied identically to both invoicePdf.tsx and correctionInvoicePdf.tsx (Stornorechnung/Gutschrift) so every generated invoice stays visually consistent — verified by rendering both to PDF and comparing.
This commit is contained in:
Marco
2026-07-23 14:33:27 +00:00
parent f6d8e9eaf5
commit 6af96eb9b8
2 changed files with 89 additions and 32 deletions
+27 -8
View File
@@ -65,7 +65,9 @@ const styles = StyleSheet.create({
tableHeader: { flexDirection: "row", backgroundColor: BG_MUTED, paddingVertical: 8, paddingHorizontal: 10 },
tableRow: { flexDirection: "row", paddingVertical: 8, paddingHorizontal: 10, borderTopWidth: 1, borderTopColor: BORDER },
tableRowAlt: { backgroundColor: BG_MUTED },
colImage: { width: 28 },
// marginRight (not a tableRow `gap`) — see invoicePdf.tsx's own comment
// on this same style, kept identical between the two documents.
colImage: { width: 28, marginRight: 10 },
itemImage: { width: 28, height: 28, borderRadius: 3 },
colName: { flex: 3 },
colQty: { flex: 1, textAlign: "right" },
@@ -75,6 +77,9 @@ const styles = StyleSheet.create({
bundleLine: { fontSize: 8, color: TEXT_MUTED, marginTop: 2 },
summary: { alignItems: "flex-end", marginBottom: 24 },
summaryBox: { width: 240, backgroundColor: BG_MUTED, borderRadius: 6, padding: 14 },
// See invoicePdf.tsx's own comment on this same style — groups rows that
// belong together with a bit of breathing room between groups.
summaryCluster: { marginBottom: 6 },
summaryRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2 },
summaryLabel: { fontSize: 10, color: TEXT_MUTED },
summaryValue: { fontSize: 10 },
@@ -85,7 +90,7 @@ const styles = StyleSheet.create({
// pinned to the bottom of the page regardless of content height above,
// same as invoicePdf.tsx's own footer. (One of the two pre-package
// copies this file replaces was missing `fixed` — see this package's
// README.)
// README.) Centered, same reasoning as invoicePdf.tsx's own footer.
footer: {
position: "absolute",
bottom: 32,
@@ -96,6 +101,7 @@ const styles = StyleSheet.create({
paddingTop: 12,
fontSize: 8,
color: TEXT_MUTED,
textAlign: "center",
},
});
@@ -173,6 +179,21 @@ function groupByTaxRate(
);
}
// Same split as invoicePdf.tsx's own DeliveryLines — Packstation number
// and Postnummer each get their own line rather than one combined
// "Packstation X · Postnummer Y" line.
function DeliveryLines({ deliveryMethod, street, packstationNumber, postNumber }: { deliveryMethod?: string | null; street?: string | null; packstationNumber?: string | null; postNumber?: string | null }) {
if (deliveryMethod === "packstation") {
return (
<>
<Text style={styles.addressLine}>Packstation {packstationNumber}</Text>
<Text style={styles.addressLine}>Postnummer {postNumber}</Text>
</>
);
}
return <Text style={styles.addressLine}>{street}</Text>;
}
function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionInvoiceKind; order: CorrectionInvoiceOrder; seller: InvoiceSeller }) {
const kindLabel = kind === "storno" ? "Stornorechnung" : "Gutschrift";
const lines = resolveLineItems(kind, order.items);
@@ -182,8 +203,6 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
kind === "storno"
? "vollständige Stornierung des ursprünglichen Rechnungsbetrags (inkl. Versand)."
: "Gutschrift für die zurückgesendeten Artikel — ohne Versandkosten, der ursprüngliche Rabatt bleibt unverändert bei den behaltenen Artikeln.";
const deliveryLine =
order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
return (
<Document>
@@ -213,7 +232,7 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
<Text style={styles.addressLine}>
{order.customerFirstName} {order.customerLastName}
</Text>
<Text style={styles.addressLine}>{deliveryLine}</Text>
<DeliveryLines deliveryMethod={order.deliveryMethod} street={order.street} packstationNumber={order.packstationNumber} postNumber={order.postNumber} />
<Text style={styles.addressLine}>
{order.zip} {order.city}
</Text>
@@ -268,13 +287,13 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
the original invoice's own Versand row. Gutschrift never
reverses shipping, so this never renders for it. */}
{kind === "storno" && order.shippingCost > 0 && (
<View style={styles.summaryRow}>
<View style={[styles.summaryRow, styles.summaryCluster]}>
<Text style={styles.summaryLabel}>Versand</Text>
<Text style={styles.summaryValue}>-{formatPrice(order.shippingCost)}</Text>
</View>
)}
{rateGroups.map((g) => (
<React.Fragment key={g.rate}>
<View style={styles.summaryCluster} key={g.rate}>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Netto</Text>
<Text style={styles.summaryValue}>-{formatPrice(g.net)}</Text>
@@ -283,7 +302,7 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
<Text style={styles.summaryLabel}>zzgl. {g.rate}% MwSt.</Text>
<Text style={styles.summaryValue}>-{formatPrice(g.tax)}</Text>
</View>
</React.Fragment>
</View>
))}
<View style={styles.grandTotalRow}>
<Text style={styles.grandTotalLabel}>Gesamt</Text>
+62 -24
View File
@@ -56,7 +56,11 @@ const styles = StyleSheet.create({
tableHeader: { flexDirection: "row", backgroundColor: BG_MUTED, paddingVertical: 8, paddingHorizontal: 10 },
tableRow: { flexDirection: "row", paddingVertical: 8, paddingHorizontal: 10, borderTopWidth: 1, borderTopColor: BORDER },
tableRowAlt: { backgroundColor: BG_MUTED },
colImage: { width: 28 },
// marginRight (not a tableRow `gap`) — explicit and unambiguous across
// react-pdf's flexbox implementation for a mixed Image/Text row, rather
// than relying on a row-level `gap` to apply consistently between an
// empty-when-no-image colImage and colName.
colImage: { width: 28, marginRight: 10 },
itemImage: { width: 28, height: 28, borderRadius: 3 },
colName: { flex: 3 },
colQty: { flex: 1, textAlign: "right" },
@@ -66,15 +70,29 @@ const styles = StyleSheet.create({
bundleLine: { fontSize: 8, color: TEXT_MUTED, marginTop: 2 },
summary: { alignItems: "flex-end", marginBottom: 24 },
summaryBox: { width: 240, backgroundColor: BG_MUTED, borderRadius: 6, padding: 14 },
// Groups rows that belong together (Rabatt+Versand; each rate's own
// Netto/MwSt pair) with a bit of breathing room between groups, while
// rows *within* a group stay at the original tight spacing — a visual
// cue that e.g. "Netto"/"zzgl. 19% MwSt." are one line item's two halves,
// not just an undifferentiated stack of numbers.
summaryCluster: { marginBottom: 6 },
summaryRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2 },
summaryLabel: { fontSize: 10, color: TEXT_MUTED },
summaryValue: { fontSize: 10 },
grandTotalRow: { flexDirection: "row", justifyContent: "space-between", paddingTop: 8, marginTop: 6, borderTopWidth: 1, borderTopColor: BORDER },
grandTotalLabel: { fontSize: 12, fontFamily: "Helvetica-Bold" },
grandTotalValue: { fontSize: 12, fontFamily: "Helvetica-Bold" },
// Moved here (below Gesamt) from the top meta row — reads more naturally
// right next to the amount it's confirming was paid, and keeps the meta
// row itself to just the three reference numbers.
paidBadgeRow: { alignItems: "flex-end", marginTop: 10 },
// `fixed` (below, on the element) + absolute positioning — always pinned
// to the bottom of the page regardless of how much content is above it,
// rather than just following wherever the content flow happens to end.
// Centered (not left-aligned) — reads as a formal closing block rather
// than a stray left-hanging line once the page's content above is itself
// centered around the summary box on the right and the table spanning
// full width.
footer: {
position: "absolute",
bottom: 32,
@@ -85,6 +103,7 @@ const styles = StyleSheet.create({
paddingTop: 12,
fontSize: 8,
color: TEXT_MUTED,
textAlign: "center",
},
});
@@ -185,6 +204,23 @@ function groupByTaxRate(order: InvoiceOrder, defaultRate: number): { rate: numbe
);
}
// Packstation deliveries show number and Postnummer on their own lines
// (not one combined "Packstation X · Postnummer Y" line) — each is looked
// up independently at a DHL Packstation, easier to scan stacked than
// split by a middle dot in running text. A plain street address stays a
// single line, same as before.
function DeliveryLines({ deliveryMethod, street, packstationNumber, postNumber }: { deliveryMethod?: string | null; street?: string | null; packstationNumber?: string | null; postNumber?: string | null }) {
if (deliveryMethod === "packstation") {
return (
<>
<Text style={styles.addressLine}>Packstation {packstationNumber}</Text>
<Text style={styles.addressLine}>Postnummer {postNumber}</Text>
</>
);
}
return <Text style={styles.addressLine}>{street}</Text>;
}
// Exported (not just used internally by renderInvoicePdf below) so
// /company-settings-preview's client component can mount it directly with
// @react-pdf/renderer's browser-side <PDFViewer> — a live, in-browser
@@ -194,12 +230,6 @@ function groupByTaxRate(order: InvoiceOrder, defaultRate: number): { rate: numbe
export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller: InvoiceSeller }) {
const rateGroups = groupByTaxRate(order, seller.taxRatePercent);
const paid = isPaidImmediately(order.paymentMethodTitle);
const deliveryLine =
order.deliveryMethod === "address" ? order.street : `Packstation ${order.packstationNumber} · Postnummer ${order.postNumber}`;
const shippingLine =
order.shippingDeliveryMethod === "packstation"
? `Packstation ${order.shippingPackstationNumber} · Postnummer ${order.shippingPostNumber}`
: order.shippingStreet;
const addressBlockStyle = order.hasDifferentShippingAddress ? styles.addressBlockThird : styles.addressBlock;
return (
@@ -226,7 +256,7 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
<Text style={styles.addressLine}>
{order.customerFirstName} {order.customerLastName}
</Text>
<Text style={styles.addressLine}>{deliveryLine}</Text>
<DeliveryLines deliveryMethod={order.deliveryMethod} street={order.street} packstationNumber={order.packstationNumber} postNumber={order.postNumber} />
<Text style={styles.addressLine}>
{order.zip} {order.city}
</Text>
@@ -238,7 +268,12 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
<Text style={styles.addressLine}>
{order.shippingFirstName} {order.shippingLastName}
</Text>
<Text style={styles.addressLine}>{shippingLine}</Text>
<DeliveryLines
deliveryMethod={order.shippingDeliveryMethod}
street={order.shippingStreet}
packstationNumber={order.shippingPackstationNumber}
postNumber={order.shippingPostNumber}
/>
<Text style={styles.addressLine}>
{order.shippingZip} {order.shippingCity}
</Text>
@@ -260,11 +295,6 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
<Text style={styles.metaLabel}>Bestellnummer</Text>
<Text style={styles.metaValue}>{order.orderNumber}</Text>
</View>
{paid && (
<View style={styles.paidBadge}>
<Text style={styles.paidBadgeText}> Bereits beglichen ({order.paymentMethodTitle})</Text>
</View>
)}
</View>
<View style={styles.table}>
@@ -296,18 +326,20 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
<View style={styles.summary}>
<View style={styles.summaryBox}>
{order.discountAmount > 0 && (
<View style={styles.summaryCluster}>
{order.discountAmount > 0 && (
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Rabatt{order.discountCode ? ` (${order.discountCode})` : ""}</Text>
<Text style={styles.summaryValue}>-{formatPrice(order.discountAmount)}</Text>
</View>
)}
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Rabatt{order.discountCode ? ` (${order.discountCode})` : ""}</Text>
<Text style={styles.summaryValue}>-{formatPrice(order.discountAmount)}</Text>
<Text style={styles.summaryLabel}>Versand</Text>
<Text style={styles.summaryValue}>{order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)}</Text>
</View>
)}
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Versand</Text>
<Text style={styles.summaryValue}>{order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)}</Text>
</View>
{rateGroups.map((g) => (
<React.Fragment key={g.rate}>
<View style={styles.summaryCluster} key={g.rate}>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Netto</Text>
<Text style={styles.summaryValue}>{formatPrice(g.net)}</Text>
@@ -316,15 +348,21 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller
<Text style={styles.summaryLabel}>zzgl. {g.rate}% MwSt.</Text>
<Text style={styles.summaryValue}>{formatPrice(g.tax)}</Text>
</View>
</React.Fragment>
</View>
))}
<View style={styles.grandTotalRow}>
<Text style={styles.grandTotalLabel}>Gesamt</Text>
<Text style={styles.grandTotalValue}>{formatPrice(order.total)}</Text>
</View>
{paid && (
<View style={styles.paidBadgeRow}>
<View style={styles.paidBadge}>
<Text style={styles.paidBadgeText}> Bereits beglichen ({order.paymentMethodTitle})</Text>
</View>
</View>
)}
</View>
</View>
</View>
<View style={styles.footer} fixed>