docs(spec): per-document custom-field print selection (offers/issued orders/invoices)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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_<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:
|
||||
|
||||
```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_<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`
|
||||
Reference in New Issue
Block a user