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:
@@ -43,8 +43,8 @@ Preview components should ever point somewhere else). `DISCOUNT_SERVICE_SECRET`
|
||||
must match the value set on the Payload backend). `ORDER_SERVICE_SECRET`
|
||||
(no safe default — required for `/api/checkout` to persist an order in
|
||||
Payload at all, for `/api/account/verify-email` to look up a customer
|
||||
by their verification token, and for `getInvoiceSettings()` to read the
|
||||
`invoice-settings` collection (seller data for invoice PDFs); must match
|
||||
by their verification token, and for `getCompanySettings()` to read the
|
||||
`company-settings` collection (seller data for invoice PDFs); must match
|
||||
the value set on the Payload backend — also used there for the same
|
||||
header). `SMTP_USER`/`SMTP_PASSWORD`
|
||||
(no safe default — required for `app/lib/alertAdmin.ts`'s critical-failure
|
||||
@@ -92,7 +92,7 @@ API directly and reuse Next.js's fetch cache instead of an extra round trip.
|
||||
Most reads are public (`access.read: () => true`); writes are always
|
||||
admin-gated in the Payload admin UI at `payload.mk360.de/admin`. A growing
|
||||
subset below is **not** public-read at all (`discount-codes`, `orders`,
|
||||
`customers`, `number-ranges`, `invoice-settings`) — each row says so and
|
||||
`customers`, `number-ranges`, `company-settings`) — each row says so and
|
||||
explains what does have access instead.
|
||||
|
||||
| Collection (slug) | Used for | Key fields |
|
||||
@@ -114,19 +114,20 @@ explains what does have access instead.
|
||||
| `customers` | Storefront accounts — register/login/order-history, a second `auth: true` collection separate from the Payload admin's own `users` login | `customerNumber`, `firstName`/`lastName`/`email`, one default address, `cart` (server-side mirror), `emailVerified` (non-blocking). **Not public-read** — see "Orders & customer accounts" below |
|
||||
| `number-ranges` | Admin-configurable prefix + running counter for customer/order/invoice numbers — one row per tenant | `customerPrefix`/`customerNext`/`customerPadding`, `orderPrefix`/`orderNext`/`orderPadding`, `invoicePrefix`/`invoiceNext`/`invoicePadding`. **Admin-only**, no frontend read at all — internal to the two `beforeChange` hooks that assign these numbers |
|
||||
| `email-templates` | Editable subject/heading/body/footer for all 6 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button |
|
||||
| `invoice-settings` | Structured seller data for invoice PDFs (see "Invoice PDFs" below) — one row per tenant | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankDetails`. **Not public-read** — admin or `ORDER_SERVICE_SECRET` |
|
||||
| `company-settings` | Structured business data for invoice PDFs *and* every email footer's company line (see "Invoice PDFs" below) — one row per tenant, own **Company** admin group (not Commerce — this is business identity, not a storefront concern) | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankDetails`. **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below |
|
||||
|
||||
All of the above (except `media`, `users`, `tenants`) are grouped in the
|
||||
Payload admin sidebar under **Commerce** (`products`, `discount-codes`,
|
||||
`orders`, `customers`, `number-ranges`, `email-templates`,
|
||||
`invoice-settings`, `shipping-methods`, `shipping-settings`,
|
||||
All of the above except `company-settings`, `media`, `users`, `tenants`
|
||||
are grouped in the Payload admin sidebar under **Commerce** (`products`,
|
||||
`discount-codes`, `orders`, `customers`, `number-ranges`,
|
||||
`email-templates`, `shipping-methods`, `shipping-settings`,
|
||||
`payment-methods`, `trust-badges`, `cart-trust-badges`) or **Content**
|
||||
(`posts`, `categories`, `legal-pages`, `werkzeuge-cards`, `testimonials`);
|
||||
`media`/`users`/`tenants` sit under **Platform** — `users` and `tenants`
|
||||
are hidden from non-super-admins' nav entirely, and every tenant-scoped
|
||||
collection's own "assigned tenant" field is hidden from non-super-admins
|
||||
in the edit view (though not yet the list view's column — see the infra
|
||||
README's Payload CMS section for why that one's a harder fix).
|
||||
(`posts`, `categories`, `legal-pages`, `werkzeuge-cards`, `testimonials`).
|
||||
`company-settings` sits in its own **Company** group; `media`/`users`/`tenants`
|
||||
sit under **Platform** — `users` and `tenants` are hidden from
|
||||
non-super-admins' nav entirely, and every tenant-scoped collection's own
|
||||
"assigned tenant" field is hidden from non-super-admins in the edit view
|
||||
(though not yet the list view's column — see the infra README's Payload
|
||||
CMS section for why that one's a harder fix).
|
||||
|
||||
**What an admin can actually configure without a code deploy, at a
|
||||
glance:** product/shipping/payment catalog data and `active` toggles
|
||||
@@ -135,7 +136,7 @@ discount codes, all page content (blog/legal/testimonials/trust badges),
|
||||
delivery-time disclosure (`shipping-settings`), order/customer/invoice
|
||||
numbering schemes (`number-ranges`), all 6 email wordings
|
||||
(`email-templates`, with Live Preview), and invoice seller data,
|
||||
bank details, and VAT rate (`invoice-settings` — also what every email's
|
||||
bank details, and VAT rate (`company-settings` — also what every email's
|
||||
footer company line is sourced from). What still requires a code change:
|
||||
adding a new *field* to any collection (needs a migration), payment
|
||||
processing itself (not built), and anything structural in
|
||||
@@ -150,7 +151,10 @@ which does need a deploy of the Payload service.
|
||||
|
||||
`posts`, `legal-pages`, and `testimonials` support Payload's Live Preview —
|
||||
opening a document in the Payload admin shows this app's real rendered page
|
||||
in an iframe, updating as you type, no save required.
|
||||
in an iframe, updating as you type, no save required. `email-templates`
|
||||
also has Live Preview, but against a synthetic page + sample data rather
|
||||
than one of these three's own real page — different enough to cover
|
||||
separately, see "Email templates & Live Preview" further below.
|
||||
|
||||
- **`app/api/preview/route.ts`** — validates `PAYLOAD_PREVIEW_SECRET`
|
||||
(matching value required on the Payload side too, or this route 401s) and
|
||||
@@ -272,10 +276,10 @@ Both resolved server-side in `/api/checkout/route.ts`, at the same point
|
||||
prices are already being re-derived from live Payload data (never trusted
|
||||
from the client):
|
||||
|
||||
- **Tax rate**: `product.taxRatePercent ?? invoiceSettings.taxRatePercent`
|
||||
- **Tax rate**: `product.taxRatePercent ?? companySettings.taxRatePercent`
|
||||
— a product's own override if set, otherwise the tenant-wide default
|
||||
from `invoice-settings` (fetched alongside the product catalog,
|
||||
`Promise.all([fetchProductsBySlug(), getInvoiceSettings()])`). Snapshotted
|
||||
from `company-settings` (fetched alongside the product catalog,
|
||||
`Promise.all([fetchProductsBySlug(), getCompanySettings()])`). Snapshotted
|
||||
onto `orders.items[].taxRatePercent` at order creation — see the Payload
|
||||
README's "Per-product tax rates" section for why this has to be a
|
||||
snapshot, not a live lookup.
|
||||
@@ -305,13 +309,16 @@ inbox, not only in `/konto/bestellungen`.
|
||||
failure there would be one more way to silently lose the attachment for
|
||||
no real design benefit; brand color/spacing still carries the visual
|
||||
identity via `StyleSheet`.
|
||||
- **Layout**: a brand-tinted header band (wordmark + "RECHNUNG" label,
|
||||
not a plain title line), seller/buyer addresses, invoice
|
||||
number/date/order-reference/USt-IdNr. shown as small bordered "meta
|
||||
boxes" rather than a plain text row, a rounded/bordered item table with
|
||||
alternating row shading, and a shaded summary card for the totals —
|
||||
deliberately closer to the site's own card-based UI language than a
|
||||
generic invoice template.
|
||||
- **Layout**: a header separated by a bold brand-colored rule (not a
|
||||
filled color band — a plain line reads cleaner than a solid block of
|
||||
color across the top) with the wordmark + "RECHNUNG" label, seller/buyer
|
||||
addresses, invoice number/date/order-reference/USt-IdNr. shown as small
|
||||
bordered "meta boxes" rather than a plain text row, a rounded/bordered
|
||||
item table with alternating row shading, and a shaded summary card for
|
||||
the totals — deliberately closer to the site's own card-based UI
|
||||
language than a generic invoice template. The footer is pinned to the
|
||||
bottom of the page (`position: absolute` + react-pdf's `fixed` prop),
|
||||
not just wherever the content flow happens to end.
|
||||
- **"Bereits beglichen" badge**: shown next to the meta boxes whenever
|
||||
`order.paymentMethodTitle` is anything other than `"Überweisung"` (bank
|
||||
transfer) — Kreditkarte and PayPal both settle at checkout, so the
|
||||
@@ -319,11 +326,11 @@ inbox, not only in `/konto/bestellungen`.
|
||||
"Überweisung" is the one method named explicitly as the exception,
|
||||
rather than hardcoding a list of "immediate" titles that would need
|
||||
updating every time a new payment method is added in Payload).
|
||||
- **Bank details**: `invoice-settings.bankDetails`, when set, prints in
|
||||
- **Bank details**: `company-settings.bankDetails`, when set, prints in
|
||||
the footer as "Bankverbindung (für Überweisung): …" — for the case a
|
||||
customer paid (or still needs to pay) by bank transfer and needs the
|
||||
account details to do so. Currently seeded with a placeholder IBAN/BIC,
|
||||
same caveat as the rest of `invoice-settings`' seller data below.
|
||||
same caveat as the rest of `company-settings`' seller data below.
|
||||
- **Per-tax-rate summary**: line items are grouped by their own
|
||||
snapshotted `taxRatePercent` (see the Payload README's "Per-product tax
|
||||
rates" section) and the summary prints one "Netto (X%)" / "zzgl. X%
|
||||
@@ -338,7 +345,7 @@ inbox, not only in `/konto/bestellungen`.
|
||||
the Payload README's "Product bundles" section).
|
||||
- **`app/lib/invoiceData.ts`** — `generateInvoicePdf(order, seller)` /
|
||||
`generateCorrectionInvoicePdf(kind, order, seller)`, the render
|
||||
entrypoints every caller below goes through. `seller` (`invoice-settings`
|
||||
entrypoints every caller below goes through. `seller` (`company-settings`
|
||||
data) is passed in rather than fetched inside these functions, so a
|
||||
caller that also needs it for something else in the same request (e.g.
|
||||
`orderEmail.ts`'s email footer, see "Configurable company data" below)
|
||||
@@ -372,9 +379,9 @@ inbox, not only in `/konto/bestellungen`.
|
||||
disk/S3/Media — the underlying data is already durable in Postgres, and
|
||||
deterministic regeneration needs no cleanup or storage cost, same
|
||||
reasoning already applied to the original invoice.
|
||||
- **`invoice-settings`** (Payload collection, structured seller data —
|
||||
- **`company-settings`** (Payload collection, structured seller data —
|
||||
name/address/`vatId`/`taxRatePercent`/`bankDetails`) is fetched via
|
||||
`getInvoiceSettings()`/`getSellerForInvoice()`, authenticated the same
|
||||
`getCompanySettings()`/`getSellerForInvoice()`, authenticated the same
|
||||
way as order creation (`x-order-service-secret` header,
|
||||
`ORDER_SERVICE_SECRET`) since it's not public-read (holds bank details)
|
||||
but does need to be reachable from this app's own server-side code, not
|
||||
@@ -391,6 +398,39 @@ inbox, not only in `/konto/bestellungen`.
|
||||
amount per rate, gross total — all on the PDF, not just the summary the
|
||||
confirmation email's HTML already shows.
|
||||
|
||||
### Company Settings & Live Preview
|
||||
|
||||
`company-settings` has a Live Preview button too, like `email-templates`
|
||||
— but instead of an HTML page, it's a **live, in-browser rendered PDF**:
|
||||
opening the document in the Payload admin shows the actual invoice layout
|
||||
updating as the admin edits `sellerName`/address/`taxRatePercent`/
|
||||
`bankDetails`, no save required.
|
||||
|
||||
- **`app/company-settings-preview/page.tsx`** + **`components/LiveCompanySettingsPreviewClient.tsx`**
|
||||
— same entrypoint pattern as `/email-preview/[type]` (Draft Mode via
|
||||
`app/api/preview/route.ts`, never a real visitor destination), but no
|
||||
`[type]` segment — there's only one kind of document here, unlike the 6
|
||||
email types.
|
||||
- **`@react-pdf/renderer`'s `<PDFViewer>`** (not `renderToBuffer()`) is
|
||||
what makes this a *live* preview rather than a static download — it's a
|
||||
browser-only component that renders a `Document` straight into an
|
||||
embedded PDF viewer `<iframe>`, re-rendering whenever its props change.
|
||||
Paired with `useLivePreview()`'s live `data` (same postMessage mechanism
|
||||
as the email templates preview), editing a field in the admin re-renders
|
||||
the actual PDF in real time — no server round-trip per keystroke.
|
||||
Dynamically imported with `{ ssr: false }` (`next/dynamic`) since it
|
||||
touches the DOM directly; the HTML-string email previews elsewhere don't
|
||||
need that since they're just `dangerouslySetInnerHTML`.
|
||||
- Renders `InvoiceDocument` (exported from `app/lib/invoicePdf.tsx`
|
||||
specifically for this — everywhere else only the async
|
||||
`renderInvoicePdf()` buffer-generator is used) against a fixed
|
||||
`SAMPLE_INVOICE_ORDER` — same "no real document to preview against
|
||||
generically" reasoning as `email-templates`' own `SAMPLE_ORDER`.
|
||||
- No draft/published distinction here, unlike `email-templates`:
|
||||
`company-settings` has no content-versioning concept, it's just the
|
||||
current row — the page's initial (pre-postMessage) fetch is the same
|
||||
live data `getCompanySettings()` always returns.
|
||||
|
||||
## Orders & customer accounts
|
||||
|
||||
An account is required to buy — there is no guest checkout. Registration
|
||||
@@ -600,7 +640,7 @@ mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from
|
||||
`<table>`s, no flexbox) — just matched by eye.
|
||||
- **Footer company line is configurable, not hardcoded.** `emailShell()`
|
||||
takes a required `companyLine` parameter ("<sellerName> · <sellerEmail>")
|
||||
— `orderEmail.ts` fetches `invoice-settings` once (`getSellerForInvoice()`)
|
||||
— `orderEmail.ts` fetches `company-settings` once (`getSellerForInvoice()`)
|
||||
and derives it from there, same admin-editable business data the invoice
|
||||
PDFs already use, rather than a literal `"einfach produktiv ·
|
||||
admin@mk360.de"` string. The Payload-side sends (password-reset, the 4
|
||||
|
||||
@@ -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