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
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:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user