From 8dec785d13b31eb75c79597204fbc7dd61b7dcba Mon Sep 17 00:00:00 2001 From: BOHA Date: Tue, 16 Jun 2026 19:44:32 +0200 Subject: [PATCH] docs(spec): per-document custom-field print selection (offers/issued orders/invoices) Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-document-custom-field-selection-design.md | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-16-per-document-custom-field-selection-design.md diff --git a/docs/superpowers/specs/2026-06-16-per-document-custom-field-selection-design.md b/docs/superpowers/specs/2026-06-16-per-document-custom-field-selection-design.md new file mode 100644 index 0000000..71083b1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-per-document-custom-field-selection-design.md @@ -0,0 +1,211 @@ +# 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_` 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= 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: + +```ts +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` — 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: + +```ts +buildAddressLines(entity, isSupplier, t, selectedCustomFields?: number[] | null) +``` + +When building `fieldMap`, only emit a `custom_` 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`