From 6af96eb9b8adb3caf3fb610a37c1aeb788eca2f2 Mon Sep 17 00:00:00 2001 From: Marco Date: Thu, 23 Jul 2026 14:33:27 +0000 Subject: [PATCH] Invoice PDF layout: centered footer, clustered summary rows, more image/text spacing, relocated paid badge, stacked Packstation/Postnummer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- src/correctionInvoicePdf.tsx | 35 +++++++++++---- src/invoicePdf.tsx | 86 ++++++++++++++++++++++++++---------- 2 files changed, 89 insertions(+), 32 deletions(-) diff --git a/src/correctionInvoicePdf.tsx b/src/correctionInvoicePdf.tsx index e021804..49a27af 100644 --- a/src/correctionInvoicePdf.tsx +++ b/src/correctionInvoicePdf.tsx @@ -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 ( + <> + Packstation {packstationNumber} + Postnummer {postNumber} + + ); + } + return {street}; +} + 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 ( @@ -213,7 +232,7 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn {order.customerFirstName} {order.customerLastName} - {deliveryLine} + {order.zip} {order.city} @@ -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 && ( - + Versand -{formatPrice(order.shippingCost)} )} {rateGroups.map((g) => ( - + Netto -{formatPrice(g.net)} @@ -283,7 +302,7 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn zzgl. {g.rate}% MwSt. -{formatPrice(g.tax)} - + ))} Gesamt diff --git a/src/invoicePdf.tsx b/src/invoicePdf.tsx index acc65e0..68504d9 100644 --- a/src/invoicePdf.tsx +++ b/src/invoicePdf.tsx @@ -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 ( + <> + Packstation {packstationNumber} + Postnummer {postNumber} + + ); + } + return {street}; +} + // 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 — 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 {order.customerFirstName} {order.customerLastName} - {deliveryLine} + {order.zip} {order.city} @@ -238,7 +268,12 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller {order.shippingFirstName} {order.shippingLastName} - {shippingLine} + {order.shippingZip} {order.shippingCity} @@ -260,11 +295,6 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller Bestellnummer {order.orderNumber} - {paid && ( - - ✓ Bereits beglichen ({order.paymentMethodTitle}) - - )} @@ -296,18 +326,20 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller - {order.discountAmount > 0 && ( + + {order.discountAmount > 0 && ( + + Rabatt{order.discountCode ? ` (${order.discountCode})` : ""} + -{formatPrice(order.discountAmount)} + + )} - Rabatt{order.discountCode ? ` (${order.discountCode})` : ""} - -{formatPrice(order.discountAmount)} + Versand + {order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)} - )} - - Versand - {order.shippingCost === 0 ? "Kostenlos" : formatPrice(order.shippingCost)} {rateGroups.map((g) => ( - + Netto {formatPrice(g.net)} @@ -316,15 +348,21 @@ export function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller zzgl. {g.rate}% MwSt. {formatPrice(g.tax)} - + ))} Gesamt {formatPrice(order.total)} + {paid && ( + + + ✓ Bereits beglichen ({order.paymentMethodTitle}) + + + )} -