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