43944d8cc8
Bug fixes:
- Navbar login/logout state now updates immediately (custom ep-auth-changed
event) instead of requiring a hard reload
- Status-change email links were broken by an un-encoded "#" in the order
number; fixed for all 4 status emails
- Cart discount code: manual input field restored (was removed entirely)
- Quote-label underline now scales with the label's actual text width
- Number Ranges admin list now shows the invoice prefix/counter columns
Pricing & VAT:
- Prices show the real per-product VAT rate ("inkl. X% MwSt.") instead of
a generic disclosure
- Cart/checkout/confirmation totals show the actual € amount of VAT
included, broken down per rate when a cart spans more than one
(new lib/taxBreakdown.ts, shared with the invoice PDF's own math)
- Account order pages gained product thumbnails and the same VAT breakdown
Low-stock warning: a "Nur noch wenige verfügbar" badge/hint across the
shop grid, spotlight, and add-to-cart variant pickers, driven by the
existing lowStockThreshold field (still never exposes raw stock counts).
Invoice PDFs: product thumbnails on every line item, a plain "Netto"
label (rate was redundant, already stated on the MwSt. line below), no
more duplicate USt-IdNr. in the header, and — for a Stornorechnung
specifically — an explicit "Versand" line that was previously only
folded silently into the tax totals.
Checkout:
- Optional deviating shipping address (separate from the billing address
used for the invoice), with its own toggle + address form
- Full checkout draft persistence (name/address/shipping/payment
selections) survives navigating away and back, via localStorage
- Invoice PDF shows a third "Lieferadresse" block when the shipping
address differs from billing
Mobile navigation: fullscreen panel with a circular reveal animation from
the hamburger's corner, replacing the old in-flow accordion drawer; no
login CTA inside it (redundant with the always-visible header icon).
Admin-facing (Payload backend, mirrored where the frontend has a ported
copy of the same renderer): dashboard rebuilt as individual cards, split
into 3 task queues (received/processing/returns) instead of 2, revenue
and order counts now exclude cancelled/returned orders immediately, and
the low-stock alert links to the specific affected product(s) instead of
the unfiltered list. A new immediate email notifies the shop owner the
moment an order comes in, instead of only via the daily digest.
Testimonials admin list now groups by page instead of interleaving all
three grids' entries. ~45 English admin field descriptions translated to
German for consistency.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
376 lines
17 KiB
TypeScript
376 lines
17 KiB
TypeScript
import React from "react";
|
||
import { Document, Page, View, Text, Image, StyleSheet, renderToBuffer } from "@react-pdf/renderer";
|
||
import { formatDate } from "./format";
|
||
import { computeTaxBreakdown } from "./taxBreakdown";
|
||
|
||
// Generated synchronously in the checkout request (see app/lib/orderEmail.ts)
|
||
// and attached to the order-confirmation email, plus available on-demand via
|
||
// app/api/account/orders/[orderNumber]/invoice/route.ts — same render
|
||
// function both times, so a re-download always matches what was emailed.
|
||
//
|
||
// Built-in Helvetica, not a registered web font — this renders inside the
|
||
// checkout request's own fire-and-forget email step; a font-fetch failure
|
||
// there is one more way to lose the invoice attachment for no real design
|
||
// benefit. Same choice as the Payload-side correction-invoice PDF
|
||
// (src/lib/correctionInvoicePdf.tsx in the backend repo, kept visually in
|
||
// sync with this file by eye — not shared code, two separate deployments).
|
||
const BRAND = "#f6a701";
|
||
const TEXT_MUTED = "#6b6b69";
|
||
const BORDER = "#e5e0d8";
|
||
const BG_MUTED = "#f8f5f1";
|
||
const SUCCESS = "#2f8f4e";
|
||
const SUCCESS_TINT = "#e7f5eb";
|
||
|
||
const styles = StyleSheet.create({
|
||
page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" },
|
||
// A rule, not a filled band — a bold brand-colored line with a thin
|
||
// muted second line underneath, rather than a plain 1pt gray divider.
|
||
headerBand: {
|
||
padding: 32,
|
||
paddingBottom: 24,
|
||
flexDirection: "row",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
borderBottomWidth: 3,
|
||
borderBottomColor: BRAND,
|
||
},
|
||
headerRuleThin: { height: 1, backgroundColor: BORDER, marginHorizontal: 32 },
|
||
wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 },
|
||
kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 },
|
||
body: { padding: 32, paddingBottom: 90 },
|
||
addressRow: { flexDirection: "row", flexWrap: "wrap", justifyContent: "space-between", rowGap: 12, marginBottom: 24 },
|
||
addressBlock: { width: "45%" },
|
||
// Narrower variant for when a 3rd (shipping) block joins Von/An — three
|
||
// of these plus the row's own space-between still fit a Page's width
|
||
// without any block cramping its text.
|
||
addressBlockThird: { width: "30%" },
|
||
addressLabel: { fontSize: 8, color: TEXT_MUTED, marginBottom: 4, textTransform: "uppercase" },
|
||
addressLine: { fontSize: 10, lineHeight: 1.5 },
|
||
metaRow: { flexDirection: "row", gap: 10, marginBottom: 16, flexWrap: "wrap" },
|
||
metaBox: { borderWidth: 1, borderColor: BORDER, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 12 },
|
||
metaLabel: { fontSize: 7, color: TEXT_MUTED, textTransform: "uppercase", marginBottom: 2 },
|
||
metaValue: { fontSize: 10, fontFamily: "Helvetica-Bold" },
|
||
paidBadge: { backgroundColor: SUCCESS_TINT, borderRadius: 6, paddingVertical: 8, paddingHorizontal: 12, justifyContent: "center" },
|
||
paidBadgeText: { fontSize: 10, fontFamily: "Helvetica-Bold", color: SUCCESS },
|
||
table: { borderRadius: 6, overflow: "hidden", borderWidth: 1, borderColor: BORDER, marginTop: 8, marginBottom: 16 },
|
||
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 },
|
||
itemImage: { width: 28, height: 28, borderRadius: 3 },
|
||
colName: { flex: 3 },
|
||
colQty: { flex: 1, textAlign: "right" },
|
||
colPrice: { flex: 1, textAlign: "right" },
|
||
colTotal: { flex: 1, textAlign: "right" },
|
||
headerCell: { fontSize: 8, color: TEXT_MUTED, textTransform: "uppercase" },
|
||
bundleLine: { fontSize: 8, color: TEXT_MUTED, marginTop: 2 },
|
||
summary: { alignItems: "flex-end", marginBottom: 24 },
|
||
summaryBox: { width: 240, backgroundColor: BG_MUTED, borderRadius: 6, padding: 14 },
|
||
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" },
|
||
// `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.
|
||
footer: {
|
||
position: "absolute",
|
||
bottom: 32,
|
||
left: 32,
|
||
right: 32,
|
||
borderTopWidth: 1,
|
||
borderTopColor: BORDER,
|
||
paddingTop: 12,
|
||
fontSize: 8,
|
||
color: TEXT_MUTED,
|
||
},
|
||
});
|
||
|
||
function formatPrice(amount: number): string {
|
||
return new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(amount);
|
||
}
|
||
|
||
export type InvoiceItem = {
|
||
productName: string;
|
||
quantity: number;
|
||
unitPrice: number;
|
||
taxRatePercent: number;
|
||
bundleContents?: string | null;
|
||
variantName?: string | null;
|
||
imageUrl?: string | null;
|
||
};
|
||
|
||
export type InvoiceOrder = {
|
||
orderNumber: string;
|
||
invoiceNumber: string;
|
||
invoiceIssuedAt: string;
|
||
customerFirstName: string;
|
||
customerLastName: string;
|
||
deliveryMethod: "address" | "packstation";
|
||
street?: string | null;
|
||
packstationNumber?: string | null;
|
||
postNumber?: string | null;
|
||
zip: string;
|
||
city: string;
|
||
country: string;
|
||
// Optional package destination distinct from the "An" recipient above —
|
||
// when set, the invoice shows both addresses (billing stays "An", this
|
||
// becomes its own "Lieferadresse" block) instead of implying the order
|
||
// shipped to the billing address, which is only true when this is unset.
|
||
hasDifferentShippingAddress?: boolean;
|
||
shippingFirstName?: string | null;
|
||
shippingLastName?: string | null;
|
||
shippingDeliveryMethod?: "address" | "packstation" | null;
|
||
shippingStreet?: string | null;
|
||
shippingPackstationNumber?: string | null;
|
||
shippingPostNumber?: string | null;
|
||
shippingZip?: string | null;
|
||
shippingCity?: string | null;
|
||
shippingCountry?: string | null;
|
||
paymentMethodTitle: string;
|
||
items: InvoiceItem[];
|
||
subtotal: number;
|
||
shippingCost: number;
|
||
discountAmount: number;
|
||
discountCode: string | null;
|
||
total: number;
|
||
};
|
||
|
||
export type InvoiceSeller = {
|
||
sellerName: string;
|
||
sellerStreet: string;
|
||
sellerZip: string;
|
||
sellerCity: string;
|
||
sellerCountry: string;
|
||
sellerEmail: string;
|
||
vatId: string;
|
||
taxRatePercent: number;
|
||
bankDetails?: string | null;
|
||
// Pflichtangaben in Geschäftsbriefen for registered legal forms (§37a
|
||
// HGB / §35a GmbHG) — optional because a sole proprietorship (the
|
||
// default legalForm in company-settings) has neither. See
|
||
// buildLegalFooterLines() in emailTemplates.ts for the same fields'
|
||
// equivalent treatment in the email footer.
|
||
registerCourt?: string | null;
|
||
registerNumber?: string | null;
|
||
managingDirector?: string | null;
|
||
};
|
||
|
||
// Used only by /company-settings-preview's Live Preview — a fixed sample
|
||
// order so the admin sees a realistic-looking invoice while editing
|
||
// company-settings fields, without depending on any real order existing.
|
||
export const SAMPLE_INVOICE_ORDER: InvoiceOrder = {
|
||
orderNumber: "#EP-0001-A7K2",
|
||
invoiceNumber: "RE-0001",
|
||
invoiceIssuedAt: new Date().toISOString(),
|
||
customerFirstName: "Max",
|
||
customerLastName: "Mustermann",
|
||
deliveryMethod: "address",
|
||
street: "Musterweg 5",
|
||
zip: "10115",
|
||
city: "Berlin",
|
||
country: "Deutschland",
|
||
paymentMethodTitle: "Kreditkarte",
|
||
items: [
|
||
{ productName: "ToDo-Karten – Set", quantity: 1, unitPrice: 12.9, taxRatePercent: 19, bundleContents: null },
|
||
{ productName: "Wochenplaner – Überblick", quantity: 2, unitPrice: 14.9, taxRatePercent: 19, bundleContents: null },
|
||
],
|
||
subtotal: 42.7,
|
||
shippingCost: 0,
|
||
discountAmount: 5,
|
||
discountCode: "WILLKOMMEN10",
|
||
total: 37.7,
|
||
};
|
||
|
||
// "Überweisung" (bank transfer) is the only payment method on this shop
|
||
// that ISN'T settled immediately — Kreditkarte/PayPal both capture at
|
||
// checkout. Rather than hardcode a list of "immediate" method titles
|
||
// (fragile the moment a new one is added in Payload's payment-methods
|
||
// collection), the only method that's ever NOT immediate is named
|
||
// explicitly — everything else defaults to "paid already".
|
||
function isPaidImmediately(paymentMethodTitle: string): boolean {
|
||
return paymentMethodTitle !== "Überweisung";
|
||
}
|
||
|
||
// Distributes the order-level discount/shipping proportionally across each
|
||
// item's gross line total before computing that line's net/tax — so the
|
||
// per-rate summary still reconciles exactly to `order.total` even when a
|
||
// discount or shipping cost is present alongside items taxed at different
|
||
// rates. Falls back to the seller's default rate for any line that
|
||
// predates this field (older orders had no per-item snapshot).
|
||
function groupByTaxRate(order: InvoiceOrder, defaultRate: number): { rate: number; net: number; tax: number; gross: number }[] {
|
||
return computeTaxBreakdown(
|
||
order.items.map((item) => ({ quantity: item.quantity, unitPrice: item.unitPrice, taxRatePercent: item.taxRatePercent ?? defaultRate })),
|
||
order.subtotal,
|
||
order.discountAmount,
|
||
order.shippingCost,
|
||
);
|
||
}
|
||
|
||
// 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
|
||
// rendered PDF that re-renders as the admin edits company-settings fields
|
||
// via Payload's postMessage-based useLivePreview(), no server round-trip
|
||
// needed for each keystroke the way an HTML preview would.
|
||
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 (
|
||
<Document>
|
||
<Page size="A4" style={styles.page}>
|
||
<View style={styles.headerBand}>
|
||
<Text style={styles.wordmark}>einfach produktiv.</Text>
|
||
<Text style={styles.kindLabel}>RECHNUNG</Text>
|
||
</View>
|
||
|
||
<View style={styles.body}>
|
||
<View style={styles.addressRow}>
|
||
<View style={addressBlockStyle}>
|
||
<Text style={styles.addressLabel}>Von</Text>
|
||
<Text style={styles.addressLine}>{seller.sellerName}</Text>
|
||
<Text style={styles.addressLine}>{seller.sellerStreet}</Text>
|
||
<Text style={styles.addressLine}>
|
||
{seller.sellerZip} {seller.sellerCity}
|
||
</Text>
|
||
<Text style={styles.addressLine}>{seller.sellerCountry}</Text>
|
||
</View>
|
||
<View style={addressBlockStyle}>
|
||
<Text style={styles.addressLabel}>An</Text>
|
||
<Text style={styles.addressLine}>
|
||
{order.customerFirstName} {order.customerLastName}
|
||
</Text>
|
||
<Text style={styles.addressLine}>{deliveryLine}</Text>
|
||
<Text style={styles.addressLine}>
|
||
{order.zip} {order.city}
|
||
</Text>
|
||
<Text style={styles.addressLine}>{order.country}</Text>
|
||
</View>
|
||
{order.hasDifferentShippingAddress && (
|
||
<View style={addressBlockStyle}>
|
||
<Text style={styles.addressLabel}>Lieferadresse</Text>
|
||
<Text style={styles.addressLine}>
|
||
{order.shippingFirstName} {order.shippingLastName}
|
||
</Text>
|
||
<Text style={styles.addressLine}>{shippingLine}</Text>
|
||
<Text style={styles.addressLine}>
|
||
{order.shippingZip} {order.shippingCity}
|
||
</Text>
|
||
<Text style={styles.addressLine}>{order.shippingCountry}</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
<View style={styles.metaRow}>
|
||
<View style={styles.metaBox}>
|
||
<Text style={styles.metaLabel}>Rechnungs-Nr.</Text>
|
||
<Text style={styles.metaValue}>{order.invoiceNumber}</Text>
|
||
</View>
|
||
<View style={styles.metaBox}>
|
||
<Text style={styles.metaLabel}>Datum</Text>
|
||
<Text style={styles.metaValue}>{formatDate(order.invoiceIssuedAt)}</Text>
|
||
</View>
|
||
<View style={styles.metaBox}>
|
||
<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}>
|
||
<View style={styles.tableHeader}>
|
||
<View style={styles.colImage} />
|
||
<Text style={[styles.colName, styles.headerCell]}>Artikel</Text>
|
||
<Text style={[styles.colQty, styles.headerCell]}>Menge</Text>
|
||
<Text style={[styles.colPrice, styles.headerCell]}>Einzelpreis</Text>
|
||
<Text style={[styles.colTotal, styles.headerCell]}>Betrag</Text>
|
||
</View>
|
||
{order.items.map((item, i) => (
|
||
<View style={[styles.tableRow, i % 2 === 1 ? styles.tableRowAlt : {}]} key={i}>
|
||
<View style={styles.colImage}>
|
||
{item.imageUrl && <Image src={item.imageUrl} style={styles.itemImage} />}
|
||
</View>
|
||
<View style={styles.colName}>
|
||
<Text>
|
||
{item.productName}
|
||
{item.variantName ? ` (${item.variantName})` : ""}
|
||
</Text>
|
||
{item.bundleContents ? <Text style={styles.bundleLine}>{item.bundleContents}</Text> : null}
|
||
</View>
|
||
<Text style={styles.colQty}>{item.quantity}</Text>
|
||
<Text style={styles.colPrice}>{formatPrice(item.unitPrice)}</Text>
|
||
<Text style={styles.colTotal}>{formatPrice(item.quantity * item.unitPrice)}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
|
||
<View style={styles.summary}>
|
||
<View style={styles.summaryBox}>
|
||
{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}>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.summaryRow}>
|
||
<Text style={styles.summaryLabel}>Netto</Text>
|
||
<Text style={styles.summaryValue}>{formatPrice(g.net)}</Text>
|
||
</View>
|
||
<View style={styles.summaryRow}>
|
||
<Text style={styles.summaryLabel}>zzgl. {g.rate}% MwSt.</Text>
|
||
<Text style={styles.summaryValue}>{formatPrice(g.tax)}</Text>
|
||
</View>
|
||
</React.Fragment>
|
||
))}
|
||
<View style={styles.grandTotalRow}>
|
||
<Text style={styles.grandTotalLabel}>Gesamt</Text>
|
||
<Text style={styles.grandTotalValue}>{formatPrice(order.total)}</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
</View>
|
||
|
||
<View style={styles.footer} fixed>
|
||
<Text>
|
||
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
|
||
{seller.vatId}
|
||
{seller.registerCourt && seller.registerNumber ? ` · ${seller.registerCourt} · ${seller.registerNumber}` : ""}
|
||
{seller.managingDirector ? ` · Geschäftsführung: ${seller.managingDirector}` : ""}
|
||
</Text>
|
||
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
|
||
</View>
|
||
</Page>
|
||
</Document>
|
||
);
|
||
}
|
||
|
||
export async function renderInvoicePdf(order: InvoiceOrder, seller: InvoiceSeller): Promise<Buffer> {
|
||
return renderToBuffer(<InvoiceDocument order={order} seller={seller} />);
|
||
}
|
||
|
||
// Exported for unit testing (see app/lib/__tests__/invoicePdf.test.ts) —
|
||
// the actual money math and payment-status logic, independent of PDF
|
||
// rendering.
|
||
export const __testables = { isPaidImmediately, groupByTaxRate };
|