Rename invoice-settings to company-settings, add its own Live Preview, and refine invoice PDF layout

Company data now has its own Payload admin group and a live in-browser
PDF preview (react-pdf's PDFViewer) instead of just a plain settings
form. Invoice header is a brand-colored rule instead of a filled band,
and the footer is now pinned to the page bottom instead of following
content flow.
This commit is contained in:
Marco
2026-07-22 11:11:58 +00:00
parent f144ad25f2
commit e50d43ea44
8 changed files with 261 additions and 75 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import type { CartItem } from "../../lib/cart";
import { getShippingMethods, getPaymentMethods, getInvoiceSettings } from "../../lib/payload";
import { getShippingMethods, getPaymentMethods, getCompanySettings } from "../../lib/payload";
import { validateDiscountCode, redeemDiscountCode } from "../../lib/discountServer";
import { createOrder } from "../../lib/orderServer";
import { getSessionCustomer, registerCustomer, setSessionCookie, type CustomerSummary } from "../../lib/customerAuth";
@@ -102,8 +102,8 @@ export async function POST(request: Request) {
}
// Re-price everything server-side — never trust client-submitted prices.
const [productsBySlug, invoiceSettings] = await Promise.all([fetchProductsBySlug(), getInvoiceSettings()]);
const defaultTaxRate = invoiceSettings?.taxRatePercent ?? 19;
const [productsBySlug, companySettings] = await Promise.all([fetchProductsBySlug(), getCompanySettings()]);
const defaultTaxRate = companySettings?.taxRatePercent ?? 19;
const items: {
productId: number;
productName: string;
@@ -0,0 +1,37 @@
"use client";
import dynamic from "next/dynamic";
import { useLivePreview } from "@payloadcms/live-preview-react";
import { InvoiceDocument, SAMPLE_INVOICE_ORDER } from "../../lib/invoicePdf";
import type { CompanySettings } from "../../lib/payload";
const PAYLOAD_URL = process.env.NEXT_PUBLIC_PAYLOAD_URL || "https://payload.mk360.de";
// @react-pdf/renderer's PDFViewer renders into an <iframe> via direct DOM
// access — it has to be excluded from the server render pass entirely
// (ssr: false), unlike the HTML-string email previews elsewhere in this
// app, which can render server-side fine since they're just
// dangerouslySetInnerHTML.
const PDFViewer = dynamic(() => import("@react-pdf/renderer").then((mod) => mod.PDFViewer), { ssr: false });
// Same useLivePreview() mechanism as LiveEmailPreviewClient.tsx — connects
// to the Payload admin's iframe via postMessage and updates `data` as the
// admin edits company-settings fields, no save required. Renders through
// the exact same InvoiceDocument component the real invoice PDF uses
// (app/lib/invoicePdf.tsx), against a fixed sample order
// (SAMPLE_INVOICE_ORDER) — there's no "current" real order to preview
// against generically, same reasoning as the email-templates preview's
// own SAMPLE_ORDER.
export function LiveCompanySettingsPreviewClient({ initialSettings }: { initialSettings: CompanySettings }) {
const { data } = useLivePreview<CompanySettings>({
initialData: initialSettings,
serverURL: PAYLOAD_URL,
depth: 0,
});
return (
<PDFViewer style={{ width: "100%", height: "100vh", border: "none" }}>
<InvoiceDocument order={SAMPLE_INVOICE_ORDER} seller={data} />
</PDFViewer>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { draftMode } from "next/headers";
import { getCompanySettings, type CompanySettings } from "../lib/payload";
import { LiveCompanySettingsPreviewClient } from "./components/LiveCompanySettingsPreviewClient";
export const metadata: Metadata = {
title: "Firmendaten-Vorschau",
robots: { index: false, follow: false },
};
const FALLBACK: CompanySettings = {
sellerName: "",
sellerStreet: "",
sellerZip: "",
sellerCity: "",
sellerCountry: "",
sellerEmail: "",
vatId: "",
taxRatePercent: 19,
bankDetails: null,
};
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
// Payload-admin-only iframe target, see buildPreviewUrl()/api/preview) —
// not a page a real visitor would ever land on. Unlike email-templates
// there's no draft/published distinction here (company-settings has no
// content-versioning concept, it's just the current row) — the initial
// fetch is the same live data getCompanySettings() always returns,
// useLivePreview() takes over from there as the admin edits fields.
export default async function CompanySettingsPreviewPage() {
await draftMode();
const initialSettings = (await getCompanySettings()) ?? FALLBACK;
return <LiveCompanySettingsPreviewClient initialSettings={initialSettings} />;
}
+30 -11
View File
@@ -14,17 +14,26 @@ import { formatDate } from "./format";
// immutable once set, so re-rendering from the order's own stored data
// always reproduces the identical document.
const BRAND = "#f6a701";
const BRAND_TINT = "#fdf1d9";
const TEXT_MUTED = "#6b6b69";
const BORDER = "#e5e0d8";
const BG_MUTED = "#f8f5f1";
const styles = StyleSheet.create({
page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" },
headerBand: { backgroundColor: BRAND_TINT, padding: 32, flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
// A rule, not a filled band — a bold brand-colored line rather than a
// plain 1pt gray divider.
headerBand: {
padding: 32,
paddingBottom: 24,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
borderBottomWidth: 3,
borderBottomColor: BRAND,
},
wordmark: { fontFamily: "Helvetica-Bold", fontSize: 14 },
kindLabel: { fontFamily: "Helvetica-Bold", fontSize: 22, color: BRAND, letterSpacing: 1 },
body: { padding: 32 },
body: { padding: 32, paddingBottom: 90 },
refLine: { fontSize: 10, color: TEXT_MUTED, marginBottom: 24 },
addressRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 },
addressBlock: { width: "45%" },
@@ -52,7 +61,17 @@ const styles = StyleSheet.create({
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" },
footer: { borderTopWidth: 1, borderTopColor: BORDER, paddingTop: 12, fontSize: 8, color: TEXT_MUTED },
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 {
@@ -215,14 +234,14 @@ function CorrectionInvoiceDocument({ kind, order, seller }: { kind: CorrectionIn
</View>
</View>
</View>
</View>
<View style={styles.footer}>
<Text>
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
{seller.vatId}
</Text>
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
</View>
<View style={styles.footer} fixed>
<Text>
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
{seller.vatId}
</Text>
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
</View>
</Page>
</Document>
+10 -10
View File
@@ -1,15 +1,15 @@
import { getInvoiceSettings, type InvoiceSettings } from "./payload";
import { getCompanySettings, type CompanySettings } from "./payload";
import { renderInvoicePdf, type InvoiceOrder } from "./invoicePdf";
import { renderCorrectionInvoicePdf, type CorrectionInvoiceKind, type CorrectionInvoiceOrder } from "./correctionInvoicePdf";
export type { InvoiceSettings };
export type { CompanySettings };
// Fetched once by callers that need seller data for more than one purpose
// in the same request (e.g. orderEmail.ts also needs it for the email
// footer's companyLine) — avoids a second identical invoice-settings round
// trip, unlike calling getInvoiceSettings() again inside each generator.
export async function getSellerForInvoice(): Promise<InvoiceSettings | null> {
return getInvoiceSettings();
// footer's companyLine) — avoids a second identical company-settings round
// trip, unlike calling getCompanySettings() again inside each generator.
export async function getSellerForInvoice(): Promise<CompanySettings | null> {
return getCompanySettings();
}
// Shared by app/lib/orderEmail.ts (checkout — attaches to the confirmation
@@ -18,9 +18,9 @@ export async function getSellerForInvoice(): Promise<InvoiceSettings | null> {
// later always matches what they were emailed. `seller` is passed in
// rather than fetched here, so a caller that already has it (see above)
// doesn't fetch it twice.
export async function generateInvoicePdf(order: InvoiceOrder, seller: InvoiceSettings | null): Promise<Buffer | null> {
export async function generateInvoicePdf(order: InvoiceOrder, seller: CompanySettings | null): Promise<Buffer | null> {
if (!seller) {
console.error("generateInvoicePdf: no invoice-settings row found for tenant");
console.error("generateInvoicePdf: no company-settings row found for tenant");
return null;
}
return renderInvoicePdf(order, seller);
@@ -35,10 +35,10 @@ export async function generateInvoicePdf(order: InvoiceOrder, seller: InvoiceSet
export async function generateCorrectionInvoicePdf(
kind: CorrectionInvoiceKind,
order: CorrectionInvoiceOrder,
seller: InvoiceSettings | null,
seller: CompanySettings | null,
): Promise<Buffer | null> {
if (!seller) {
console.error("generateCorrectionInvoicePdf: no invoice-settings row found for tenant");
console.error("generateCorrectionInvoicePdf: no company-settings row found for tenant");
return null;
}
return renderCorrectionInvoicePdf(kind, order, seller);
+68 -12
View File
@@ -14,7 +14,6 @@ import { formatDate } from "./format";
// (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 BRAND_TINT = "#fdf1d9";
const TEXT_MUTED = "#6b6b69";
const BORDER = "#e5e0d8";
const BG_MUTED = "#f8f5f1";
@@ -23,10 +22,21 @@ const SUCCESS_TINT = "#e7f5eb";
const styles = StyleSheet.create({
page: { padding: 0, fontSize: 10, fontFamily: "Helvetica", color: "#1a1a18" },
headerBand: { backgroundColor: BRAND_TINT, padding: 32, flexDirection: "row", justifyContent: "space-between", alignItems: "center" },
// 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 },
body: { padding: 32, paddingBottom: 90 },
addressRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 },
addressBlock: { width: "45%" },
addressLabel: { fontSize: 8, color: TEXT_MUTED, marginBottom: 4, textTransform: "uppercase" },
@@ -55,7 +65,20 @@ const styles = StyleSheet.create({
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" },
footer: { borderTopWidth: 1, borderTopColor: BORDER, paddingTop: 12, fontSize: 8, color: TEXT_MUTED },
// `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 {
@@ -98,6 +121,32 @@ export type InvoiceSeller = {
bankDetails?: 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
@@ -131,7 +180,13 @@ function groupByTaxRate(order: InvoiceOrder, defaultRate: number): { rate: numbe
.sort((a, b) => b.rate - a.rate);
}
function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller: InvoiceSeller }) {
// 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 =
@@ -244,13 +299,14 @@ function InvoiceDocument({ order, seller }: { order: InvoiceOrder; seller: Invoi
</View>
</View>
<View style={styles.footer}>
<Text>
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
{seller.vatId}
</Text>
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
</View>
</View>
<View style={styles.footer} fixed>
<Text>
{seller.sellerName} · {seller.sellerStreet}, {seller.sellerZip} {seller.sellerCity} · {seller.sellerEmail} · USt-IdNr.{" "}
{seller.vatId}
</Text>
{seller.bankDetails ? <Text style={{ marginTop: 4 }}>Bankverbindung (für Überweisung): {seller.bankDetails}</Text> : null}
</View>
</Page>
</Document>
+7 -7
View File
@@ -643,7 +643,7 @@ export async function getEmailTemplate(
return data.docs?.[0] ?? null;
}
export type InvoiceSettings = {
export type CompanySettings = {
sellerName: string;
sellerStreet: string;
sellerZip: string;
@@ -658,19 +658,19 @@ export type InvoiceSettings = {
// Server-only in practice (only ever called from app/lib/invoiceData.ts),
// but kept in this file rather than a "use server"-only module since every
// other Payload fetcher lives here too — no client component imports it.
// invoice-settings' read access is admin-only plus this same service
// secret (see InvoiceSettings.ts) — it holds bank details, not something
// company-settings' read access is admin-only plus this same service
// secret (see CompanySettings.ts) — it holds bank details, not something
// to leave publicly readable like Products/ShippingSettings.
export async function getInvoiceSettings(): Promise<InvoiceSettings | null> {
export async function getCompanySettings(): Promise<CompanySettings | null> {
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
const res = await fetch(`${PAYLOAD_URL}/api/invoice-settings?${params}`, {
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
cache: "no-store",
});
if (!res.ok) {
console.error(`getInvoiceSettings: Payload returned ${res.status} ${res.statusText}`);
console.error(`getCompanySettings: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { docs?: InvoiceSettings[] } = await res.json();
const data: { docs?: CompanySettings[] } = await res.json();
return data.docs?.[0] ?? null;
}