9.6 KiB
Per-document custom-field print selection
Date: 2026-06-16 Status: Approved design — ready for implementation plan Scope: offers (quotations), issued orders, invoices
Problem
Company custom fields are defined in company settings (company_settings.custom_fields,
a JSON blob of {name, value, showLabel}[] plus a field_order). They render in the
sender/company block of document PDFs via buildAddressLines(settings, …).
Today every custom field with a value prints on every document PDF (offer, issued
order, invoice). The only existing per-field control is the company-settings checkbox
"Zobrazit název v PDF" (showLabel), which decides whether a printed field shows as
Name: Value or just Value — it does not control whether the field appears at all.
The user wants per-document control: on each offer / issued order / invoice detail page, tick which company custom fields actually print on that specific document's PDF.
Goals
- Each offer, issued order, and invoice independently selects which company custom fields print in its PDF sender block.
- Selection lives on the document and is editable on its detail page.
- Default is none selected — existing documents and new drafts print no custom fields until fields are deliberately ticked.
- The company-settings
showLabel("Zobrazit název v PDF") checkbox is unchanged.
Non-goals
- Order confirmations ("orders" /
orders-pdf.ts) are out of scope — they keep current behavior (print all custom fields). - No change to how custom fields are defined in company settings.
- No stable per-field IDs — selection is positional (see Decisions).
Decisions (locked)
- Default = none selected. Existing rows get
NULL; new documents start empty. A document prints custom fields only for the indices it explicitly stores. - Positional identity. Selection stores field positions (
0,1,2…) matching the existingcustom_<i>keys in the PDF code. No settings-structure change, no backfill. Accepted caveat: reordering/deleting a custom field in company settings can shift what a saved document's stored indices point at. Low risk — company fields rarely change, and finalized PDFs are archived on NAS (served as-is, not re-rendered). The user accepted this over the more robust stable-ID approach. - Scope = 3 document types (offers, issued orders, invoices). Order confirmations excluded.
showLabelretained in company settings, untouched.
Design
1. Data storage
Add one nullable column to each of quotations, issued_orders, invoices:
| Column | Type | Meaning |
|---|---|---|
selected_custom_fields |
VARCHAR |
JSON array of selected indices, e.g. "[0,2]"; NULL = none |
Stored as JSON-in-text, consistent with the existing company_settings.custom_fields /
customers.custom_fields pattern. NULL/empty ⇒ no custom fields print.
One tracked migration per the project's non-interactive prisma migrate diff recipe
(CLAUDE.md). Apply to app_test as well (DATABASE_URL=<app_test> npx prisma migrate deploy) or the suite breaks. Commit schema.prisma + migration folder together.
2. Backend — schemas
Add to each document's Create and Update Zod schema:
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
Files:
src/schemas/offers.schema.ts—CreateQuotationSchema(Update derives via.partial().omit()).src/schemas/issued-orders.schema.ts—CreateIssuedOrderSchema(Update derives via.partial().omit()).src/schemas/invoices.schema.ts— bothCreateInvoiceSchemaandUpdateInvoiceSchema(invoices define the two schemas separately).
3. Backend — services
In each create/update service, persist the field inside the existing transaction:
- On write:
selected_custom_fields: Array.isArray(body.selected_custom_fields) && body.selected_custom_fields.length > 0 ? JSON.stringify(body.selected_custom_fields) : null - No other logic changes; sits alongside the existing header column assignments.
Files: src/services/offers.service.ts, src/services/issued-orders.service.ts,
src/services/invoices.service.ts.
4. Backend — detail responses
The detail endpoints spread the document row. Decode the stored string back into a
number[] so the frontend receives a clean array (default [] when NULL/malformed —
malformed JSON degrades gracefully with a logged warning, per the error convention). Add
selected_custom_fields: number[] to the corresponding detail interfaces in
src/admin/lib/queries/{offers,issued-orders,invoices}.ts.
5. PDF rendering
In each of offers-pdf.ts, issued-orders-pdf.ts, invoices-pdf.ts, extend the
company-block buildAddressLines() with an optional parameter:
buildAddressLines(entity, isSupplier, t, selectedCustomFields?: number[] | null)
When building fieldMap, only emit a custom_<i> entry when selectedCustomFields
includes i. Behavior:
null/undefined/ empty array ⇒ no custom lines (the new default).- Standard address lines (name, street, city/postal, country, IČO/
company_id, DIČ/vat_id) are unaffected — always built as today. - For the custom fields that are selected, the existing
showLabel(Name: ValuevsValue) andfield_orderordering logic is preserved unchanged.
Only the company/sender block is filtered (that's where company custom fields render).
The customer block (offers/invoices) and supplier block (issued orders) keep their own
custom-field behavior unchanged — those come from customers/sklad_suppliers, not company
settings, and are not in scope.
The PDF route reads the document's selected_custom_fields column, parses it to number[],
and passes it into buildAddressLines(settings, …, selected).
Archived-PDF note: finalized/numbered documents serve their archived NAS copy and won't change retroactively. Drafts (and any forced re-render) reflect the current selection.
6. Frontend — shared picker component
New shared component under src/admin/components/document/ (e.g.
CustomFieldsPrintPicker.tsx), reused by all three detail pages (extend the shared module,
don't fork three copies — per CLAUDE.md document-module conventions).
Props (shape): the parsed company custom fields ({name, value}[] from
companySettings.custom_fields), the current selected indices, an onChange, and a
disabled flag.
Behavior:
- Renders a checkbox per company custom field; label shows the field's name (and/or value) so the user knows what each is.
- Bound to local state on the detail page, seeded from the document's
selected_custom_fields. - Hidden/empty when the company has no custom fields defined.
- Respects edit-lock and editable-status rules: read-only (
disabled) when the document isn't editable or is locked by another user, matching how the rest of the header fields behave on that page. - Included in the page's save payload as
selected_custom_fields: number[].
Pages: src/admin/pages/OfferDetail.tsx, IssuedOrderDetail.tsx, InvoiceDetail.tsx —
each already loads companySettingsOptions(), so the field definitions are available with
no new query.
Placement: in the editable header/meta area of each detail page, near the other document-level settings (currency/language/etc.).
Data flow
Company settings: custom_fields = [{name:"Tel.",…}, {name:"Web",…}, {name:"Datová schránka",…}]
idx 0 idx 1 idx 2
Offer detail page → user ticks "Tel." and "Datová schránka"
→ save payload selected_custom_fields: [0, 2]
→ service stores quotations.selected_custom_fields = "[0,2]"
Offer PDF render
→ read column "[0,2]" → [0,2]
→ buildAddressLines(settings, …, [0,2])
→ fieldMap emits custom_0 ("Tel.: …") and custom_2 ("Datová schránka: …"), skips custom_1
→ sender block prints Tel. and Datová schránka only
Testing
Server-side Vitest against app_test (no Prisma mocking; mock Puppeteer/NAS only):
- Create/update each document with
selected_custom_fields→ column persists the JSON string; empty/omitted →NULL. - Detail response decodes to
number[](and[]forNULL). - Schema: non-integer / negative entries rejected; omitted is valid.
- PDF: with a stubbed
html-to-pdf, assert the rendered company block includes only the selected custom field lines and still includes standard address lines; empty selection ⇒ no custom lines; standard lines unaffected. - Regression: customer/supplier custom fields still render regardless of company selection.
Migration & rollout
- One migration adding the three columns (all default
NULL), applied to devappandapp_test. - No data backfill (none-selected default is the intended post-ship state).
- No production PDF changes for already-archived documents.
Affected files (reference)
prisma/schema.prisma(+ new migration folder)src/schemas/offers.schema.ts,issued-orders.schema.ts,invoices.schema.tssrc/services/offers.service.ts,issued-orders.service.ts,invoices.service.tssrc/routes/admin/offers-pdf.ts,issued-orders-pdf.ts,invoices-pdf.ts- (detail route handlers, if decoding is done there rather than in the service)
src/admin/lib/queries/offers.ts,issued-orders.ts,invoices.tssrc/admin/components/document/CustomFieldsPrintPicker.tsx(new)src/admin/pages/OfferDetail.tsx,IssuedOrderDetail.tsx,InvoiceDetail.tsx