Files
app/docs/superpowers/specs/2026-06-16-per-document-custom-field-selection-design.md
2026-06-16 19:44:32 +02:00

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)

  1. Default = none selected. Existing rows get NULL; new documents start empty. A document prints custom fields only for the indices it explicitly stores.
  2. Positional identity. Selection stores field positions (0,1,2…) matching the existing custom_<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.
  3. Scope = 3 document types (offers, issued orders, invoices). Order confirmations excluded.
  4. showLabel retained 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.tsCreateQuotationSchema (Update derives via .partial().omit()).
  • src/schemas/issued-orders.schema.tsCreateIssuedOrderSchema (Update derives via .partial().omit()).
  • src/schemas/invoices.schema.ts — both CreateInvoiceSchema and UpdateInvoiceSchema (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: Value vs Value) and field_order ordering 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 [] for NULL).
  • 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 dev app and app_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.ts
  • src/services/offers.service.ts, issued-orders.service.ts, invoices.service.ts
  • src/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.ts
  • src/admin/components/document/CustomFieldsPrintPicker.tsx (new)
  • src/admin/pages/OfferDetail.tsx, IssuedOrderDetail.tsx, InvoiceDetail.tsx