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
+72 -32
View File
@@ -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