30 KiB
Per-document custom-field print selection — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let each offer, issued order, and invoice choose — on its detail page — which company custom fields print in its PDF sender block; default none selected.
Architecture: A new nullable selected_custom_fields column (JSON-array-in-VARCHAR) on quotations, issued_orders, invoices. A shared backend util encodes/decodes the index array. Create/update services persist it; detail services decode it to number[]. The PDF buildAddressLines() company-block builder gains an optional selected-indices filter. A shared React picker reused by the three detail pages drives the selection.
Tech Stack: Prisma 7 / MySQL, Fastify 5, Zod 4, Vitest (real app_test DB), React 19 + MUI v7 + React Query.
Spec: docs/superpowers/specs/2026-06-16-per-document-custom-field-selection-design.md
Conventions for this plan
- This shell is non-interactive —
prisma migrate devis forbidden. Use themigrate diffrecipe (Task 1). - Before the migration, ask the user to stop their dev server and wait for confirmation (it holds DB connections). Do not run the migration until they confirm.
- Server tests run against
app_testvia.env.test(npm test). Apply the migration toapp_testtoo or the suite breaks. npm run typecheck=tsc -b --noEmit.npm run lintmust stay at 0 errors.- Selection semantics in
buildAddressLines: param omitted/undefined⇒ show ALL custom fields (unchanged behavior — used for customer/supplier blocks). Param is an array (possibly empty) ⇒ show ONLY those indices (used for the company/sender block; empty ⇒ none).
File Structure
- Modify
prisma/schema.prisma— addselected_custom_fields String?toquotations,issued_orders,invoices. - Create
prisma/migrations/<ts>_add_selected_custom_fields/migration.sql. - Modify
src/utils/custom-fields.ts— addencodeSelectedCustomFields/parseSelectedCustomFields. - Create
src/__tests__/selected-custom-fields.test.ts— util + integration coverage. - Modify
src/schemas/offers.schema.ts,issued-orders.schema.ts,invoices.schema.ts— new optional array field. - Modify
src/services/offers.service.ts,issued-orders.service.ts,invoices.service.ts— persist on create/update, decode in detail. - Modify
src/routes/admin/offers-pdf.ts,issued-orders-pdf.ts,invoices-pdf.ts— filter company custom lines. - Modify
src/admin/lib/queries/offers.ts,issued-orders.ts,invoices.ts— addselected_custom_fields: number[]to detail interfaces. - Create
src/admin/components/document/CustomFieldsPrintPicker.tsx— shared picker. - Modify
src/admin/pages/OfferDetail.tsx,IssuedOrderDetail.tsx,InvoiceDetail.tsx— render picker, wire into save payload.
Task 1: Schema column + migration
Files:
-
Modify:
prisma/schema.prisma(modelsquotations,issued_orders,invoices) -
Create:
prisma/migrations/<yyyyMMddHHmmss>_add_selected_custom_fields/migration.sql -
Step 1: Ask the user to stop their dev server
Post: "Please stop your dev server so I can apply a migration, and tell me when it's stopped." Wait for confirmation before any later migrate deploy step.
- Step 2: Add the column to each model in
prisma/schema.prisma
Add this line to the quotations model (near other scalar columns, e.g. after language):
selected_custom_fields String? @db.VarChar(255)
Add the identical line to the issued_orders model and to the invoices model.
- Step 3: Generate the migration SQL
Run (Git Bash):
cd /d/cortex/boha-app-ts
TS=$(date +%Y%m%d%H%M%S)
mkdir -p "prisma/migrations/${TS}_add_selected_custom_fields"
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
> "prisma/migrations/${TS}_add_selected_custom_fields/migration.sql"
Expected: a migration.sql containing three ALTER TABLE … ADD COLUMN selected_custom_fields VARCHAR(255) NULL statements (one per table). Open it and confirm it touches ONLY quotations, issued_orders, invoices and adds nothing else (no BOM — if it was written via PowerShell, strip the BOM).
- Step 4: Apply to dev DB + regenerate client (after user confirmed server stopped)
npx prisma migrate deploy
npx prisma generate
Expected: "All migrations have been applied" and a regenerated client.
- Step 5: Apply to the test DB
DATABASE_URL="$(grep -m1 '^DATABASE_URL' .env.test | cut -d= -f2- | tr -d '"')" npx prisma migrate deploy
Expected: the same migration applied to app_test. (If the env parsing is awkward on this shell, temporarily set DATABASE_URL to the app_test URL from .env.test and run npx prisma migrate deploy.)
- Step 6: Typecheck
Run: npm run typecheck
Expected: PASS (the new Prisma field is now known to the client).
- Step 7: Commit
git add prisma/schema.prisma prisma/migrations
git commit -m "feat(documents): add selected_custom_fields column to quotations/issued_orders/invoices"
Task 2: Backend encode/decode util (TDD)
Files:
-
Modify:
src/utils/custom-fields.ts -
Test:
src/__tests__/selected-custom-fields.test.ts -
Step 1: Write the failing test
Create src/__tests__/selected-custom-fields.test.ts:
import { describe, it, expect } from "vitest";
import {
encodeSelectedCustomFields,
parseSelectedCustomFields,
} from "../utils/custom-fields";
describe("selected custom fields encode/decode", () => {
it("encodes a non-empty index array to a JSON string", () => {
expect(encodeSelectedCustomFields([0, 2])).toBe("[0,2]");
});
it("encodes empty / non-array to null", () => {
expect(encodeSelectedCustomFields([])).toBeNull();
expect(encodeSelectedCustomFields(undefined)).toBeNull();
expect(encodeSelectedCustomFields("nope")).toBeNull();
});
it("dedupes, sorts, and drops invalid entries before encoding", () => {
expect(encodeSelectedCustomFields([2, 0, 2, -1, 1.5, 3])).toBe("[0,2,3]");
});
it("parses a stored string back to a number array", () => {
expect(parseSelectedCustomFields("[0,2]")).toEqual([0, 2]);
});
it("parses null / malformed to an empty array", () => {
expect(parseSelectedCustomFields(null)).toEqual([]);
expect(parseSelectedCustomFields("")).toEqual([]);
expect(parseSelectedCustomFields("{garbage")).toEqual([]);
expect(parseSelectedCustomFields('"x"')).toEqual([]);
});
it("parses already-array input (defensive) and filters invalid", () => {
expect(parseSelectedCustomFields([0, "1", 2, -3] as unknown)).toEqual([
0, 2,
]);
});
});
- Step 2: Run it to confirm failure
Run: npm test -- selected-custom-fields
Expected: FAIL — encodeSelectedCustomFields is not a function.
- Step 3: Implement the helpers
Append to src/utils/custom-fields.ts:
/**
* Per-document selection of which COMPANY custom fields print on a PDF.
* Stored positionally (matching the `custom_<i>` keys the PDF builder emits)
* as a JSON array string, e.g. "[0,2]". Null/empty means "none selected".
*/
export function encodeSelectedCustomFields(indices: unknown): string | null {
const clean = normalizeIndices(indices);
return clean.length > 0 ? JSON.stringify(clean) : null;
}
/** Decode the stored selection (string OR defensive array) into a clean number[]. */
export function parseSelectedCustomFields(raw: unknown): number[] {
if (raw == null) return [];
if (Array.isArray(raw)) return normalizeIndices(raw);
if (typeof raw !== "string" || raw.trim() === "") return [];
try {
return normalizeIndices(JSON.parse(raw));
} catch {
// Malformed JSON in a selection column degrades to "none" (expected
// condition — a hand-edited/legacy row should never 500 a PDF render).
return [];
}
}
function normalizeIndices(input: unknown): number[] {
if (!Array.isArray(input)) return [];
const set = new Set<number>();
for (const v of input) {
if (typeof v === "number" && Number.isInteger(v) && v >= 0) set.add(v);
}
return [...set].sort((a, b) => a - b);
}
- Step 4: Run the test to confirm it passes
Run: npm test -- selected-custom-fields
Expected: PASS (all util cases).
- Step 5: Commit
git add src/utils/custom-fields.ts src/__tests__/selected-custom-fields.test.ts
git commit -m "feat(documents): selected-custom-fields encode/decode helpers"
Task 3: Zod schemas
Files:
-
Modify:
src/schemas/offers.schema.ts -
Modify:
src/schemas/issued-orders.schema.ts -
Modify:
src/schemas/invoices.schema.ts -
Step 1: Offers schema
In src/schemas/offers.schema.ts, inside CreateQuotationSchema, add after the sections line (line 50):
// Positional indices of company custom fields to print on this document's
// PDF. Omitted/empty ⇒ none. Update schema derives via .partial().
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
(UpdateQuotationSchema already derives from this via .partial().omit({ quotation_number: true }) — no change needed there.)
- Step 2: Issued-orders schema
In src/schemas/issued-orders.schema.ts, inside CreateIssuedOrderSchema, add after the sections field:
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
(UpdateIssuedOrderSchema derives via .partial() — no change.)
- Step 3: Invoices schema
In src/schemas/invoices.schema.ts, add the same line to BOTH CreateInvoiceSchema (after its sections field) and UpdateInvoiceSchema (after its sections field — invoices define the two schemas separately, so it must be added in both):
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
- Step 4: Typecheck
Run: npm run typecheck
Expected: PASS.
- Step 5: Commit
git add src/schemas/offers.schema.ts src/schemas/issued-orders.schema.ts src/schemas/invoices.schema.ts
git commit -m "feat(documents): accept selected_custom_fields in document schemas"
Task 4: Services — persist on write, decode in detail (TDD)
Files:
-
Modify:
src/services/offers.service.ts -
Modify:
src/services/issued-orders.service.ts -
Modify:
src/services/invoices.service.ts -
Test:
src/__tests__/selected-custom-fields.test.ts(extend) -
Step 1: Add the import to all three services
At the top of each of the three service files, add (or extend the existing import from ../utils/custom-fields):
import {
encodeSelectedCustomFields,
parseSelectedCustomFields,
} from "../utils/custom-fields";
- Step 2: Offers — persist on create
In createOffer (src/services/offers.service.ts), inside tx.quotations.create({ data: { … } }), add after scope_description:
selected_custom_fields: encodeSelectedCustomFields(
body.selected_custom_fields,
),
- Step 3: Offers — persist on update
In updateOffer, inside the const data = { … } object (after scope_description, before modified_at), add:
selected_custom_fields:
body.selected_custom_fields !== undefined
? encodeSelectedCustomFields(body.selected_custom_fields)
: undefined,
(undefined = "key absent in payload, leave column untouched" — matches the sibling header fields.)
- Step 4: Offers — decode in detail
In getOffer, in the returned object (after valid_transitions), add:
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
- Step 5: Issued orders — persist + decode
In src/services/issued-orders.service.ts:
-
In
createIssuedOrder, insidetx.issued_orders.create({ data: { … } }), add:selected_custom_fields: encodeSelectedCustomFields( body.selected_custom_fields, ), -
In
updateIssuedOrder, inside the headerdataobject passed toissued_orders.update, add:selected_custom_fields: body.selected_custom_fields !== undefined ? encodeSelectedCustomFields(body.selected_custom_fields) : undefined, -
In
getIssuedOrder, in the returned object (aftervalid_transitions), add:selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields), -
Step 6: Invoices — persist + decode
In src/services/invoices.service.ts:
-
In
createInvoice, insidetx.invoices.create({ data: { … } }), add afterinternal_notes:selected_custom_fields: encodeSelectedCustomFields( body.selected_custom_fields, ), -
In
updateInvoice, inside the firstif (editable) { … }block (after thetax_datehandling, still inside the block), add:if (body.selected_custom_fields !== undefined) data.selected_custom_fields = encodeSelectedCustomFields( body.selected_custom_fields, ); -
In
getInvoice, in the returned object (aftervalid_transitions), add:selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields), -
Step 7: Extend the test with an offers round-trip (real DB)
Append to src/__tests__/selected-custom-fields.test.ts. Match the existing suite's fixture style — import the offers service directly and clean up. Use a far-future placeholder where the suite does; if an existing offers test helper exists, prefer it. Minimal version:
import { createOffer, getOffer } from "../services/offers.service";
import { prisma } from "../config/prisma"; // adjust to the project's prisma export path
describe("offers selected_custom_fields round-trip", () => {
const created: number[] = [];
afterAll(async () => {
if (created.length)
await prisma.quotations.deleteMany({ where: { id: { in: created } } });
});
it("persists selection on create and decodes it on detail", async () => {
const res = (await createOffer({
status: "draft",
selected_custom_fields: [2, 0, 0],
})) as { id: number };
created.push(res.id);
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
expect(row?.selected_custom_fields).toBe("[0,2]");
const detail = await getOffer(res.id);
expect(detail?.selected_custom_fields).toEqual([0, 2]);
});
it("stores null when selection is empty", async () => {
const res = (await createOffer({
status: "draft",
selected_custom_fields: [],
})) as { id: number };
created.push(res.id);
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
expect(row?.selected_custom_fields).toBeNull();
});
});
Before running: confirm the prisma import path (
../config/prismavs../config/db— grep an existing test). Adjust the import to match.
- Step 8: Run tests
Run: npm test -- selected-custom-fields
Expected: PASS (util + offers round-trip). If FK constraints require a customer, the customer_id-less draft path above avoids them.
- Step 9: Typecheck + commit
npm run typecheck
git add src/services/offers.service.ts src/services/issued-orders.service.ts src/services/invoices.service.ts src/__tests__/selected-custom-fields.test.ts
git commit -m "feat(documents): persist & expose selected_custom_fields in services"
Task 5: PDF rendering — filter company custom lines (TDD)
Files:
-
Modify:
src/routes/admin/offers-pdf.ts -
Modify:
src/routes/admin/issued-orders-pdf.ts -
Modify:
src/routes/admin/invoices-pdf.ts -
Test:
src/__tests__/selected-custom-fields.test.ts(extend, if a PDF render is unit-testable; otherwise assert via the helper — see Step 6) -
Step 1: Offers PDF — add the filter param to
buildAddressLines
In src/routes/admin/offers-pdf.ts, change the signature (line 28-32):
function buildAddressLines(
entity: Record<string, unknown> | null,
isSupplier: boolean,
t: (key: string) => string,
selectedCustomFields?: number[],
): AddressResult {
Then change the custom-field loop (lines 88-96) to skip non-selected indices when a selection array is provided:
const filterCustom = Array.isArray(selectedCustomFields);
cfData.forEach((cf, i) => {
if (filterCustom && !selectedCustomFields!.includes(i)) return;
const cfName = (cf.name || "").trim();
const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false;
if (cfValue) {
fieldMap[`custom_${i}`] =
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
}
});
- Step 2: Offers PDF — pass the document's selection into the COMPANY call
The offer's sender/company block is supp (isSupplier: true on settings). Update that call (lines 209-213) to pass the parsed selection; leave the customer call (cust) unchanged so customer custom fields still show in full:
const supp = buildAddressLines(
settings as unknown as Record<string, unknown>,
true,
t,
parseSelectedCustomFields(
(quotation as { selected_custom_fields?: unknown }).selected_custom_fields,
),
);
Add the import at the top of the file:
import { parseSelectedCustomFields } from "../../utils/custom-fields";
Confirm
quotation(theOfferForPdfpayload the render function receives) is selected with the default field set soselected_custom_fieldsis present. It's a scalar column onquotations, so the defaultfindUnique/includereturns it — noselectnarrowing to adjust here.
- Step 3: Issued-orders PDF — same change on the COMPANY (buyer) block
In src/routes/admin/issued-orders-pdf.ts:
- Add
selectedCustomFields?: number[]as the last param ofbuildAddressLinesand apply the identicalfilterCustomguard in its custom-field loop. - The company block is
buyer = buildAddressLines(settings, true, t)(line 316). Change to:const buyer = buildAddressLines( settings, true, t, parseSelectedCustomFields( (order as { selected_custom_fields?: unknown }).selected_custom_fields, ), ); - Leave
buildSupplierLines(...)untouched (supplier fields keep showing in full). - Add
import { parseSelectedCustomFields } from "../../utils/custom-fields";.
Confirm the render function's
orderparam carriesselected_custom_fields. If the route fetches the order via a narrowselect, addselected_custom_fields: trueto it; if it usesinclude/default scalars, it's already present. Grep the route'sissued_orders.findUnique/findFirstbefore assuming.
- Step 4: Invoices PDF — same change on the COMPANY (supplier-of-invoice) block
In src/routes/admin/invoices-pdf.ts:
- Add
selectedCustomFields?: number[]as the last param ofbuildAddressLinesand apply the identicalfilterCustomguard. - The company block is
supp = buildAddressLines(settings, true, t)(line 457). Change to:const supp = buildAddressLines( settings, true, t, parseSelectedCustomFields( (invoice as { selected_custom_fields?: unknown }).selected_custom_fields, ), ); - Leave
cust = buildAddressLines(customer, false, t)untouched. - Note the separate
settings.custom_fieldsread lower down (the "supplier email/web" extraction near line 469) is a DIFFERENT concern (pulls an email for a header line) — do not filter that; leave it as-is. - Add
import { parseSelectedCustomFields } from "../../utils/custom-fields";.
Confirm
invoiceis fetched with default scalars (it is —prisma.invoices.findUniquewithout a narrowingselectin this route), soselected_custom_fieldsis present.
- Step 5: Typecheck + lint
Run: npm run typecheck && npm run lint
Expected: PASS, 0 lint errors.
- Step 6: Add a focused render assertion (extend the test file)
If the three PDF modules export their HTML render function (e.g. offers exports renderOfferHtml), add a test that renders with a stubbed settings object carrying two company custom fields and asserts only the selected one appears. Mock html-to-pdf per the suite convention; you're asserting on the returned HTML string, not a real PDF. Example for offers (adapt names to the actual export):
import { renderOfferHtml } from "../routes/admin/offers-pdf"; // confirm export name
it("offer PDF prints only selected company custom fields", () => {
const settings = {
name: "Naše Firma s.r.o.",
custom_fields: JSON.stringify({
fields: [
{ name: "Tel.", value: "123", showLabel: true },
{ name: "Web", value: "example.cz", showLabel: true },
],
field_order: [],
}),
};
const quotation = {
customers: null,
quotation_items: [],
scope_sections: [],
status: "draft",
currency: "CZK",
language: "cs",
selected_custom_fields: "[0]",
};
const html = renderOfferHtml(quotation as never, settings as never);
expect(html).toContain("Tel.: 123");
expect(html).not.toContain("example.cz");
});
If the render function is NOT exported / not unit-testable without a DB, SKIP this step rather than forcing it — the util test (Task 2) plus the service round-trip (Task 4) already cover the data path; note in the commit that PDF filtering was verified manually. Do not export internals solely to test them if that breaks the module's encapsulation; prefer a manual verification note.
- Step 7: Commit
git add src/routes/admin/offers-pdf.ts src/routes/admin/issued-orders-pdf.ts src/routes/admin/invoices-pdf.ts src/__tests__/selected-custom-fields.test.ts
git commit -m "feat(documents): PDF prints only the document's selected company custom fields"
Task 6: Frontend — detail query types + shared picker
Files:
-
Modify:
src/admin/lib/queries/offers.ts,issued-orders.ts,invoices.ts -
Create:
src/admin/components/document/CustomFieldsPrintPicker.tsx -
Step 1: Add the field to the three detail interfaces
-
src/admin/lib/queries/offers.ts— add toOfferDetailData:selected_custom_fields: number[]; -
src/admin/lib/queries/issued-orders.ts— add toIssuedOrderDetail:selected_custom_fields: number[]; -
src/admin/lib/queries/invoices.ts— add toInvoiceDetail:selected_custom_fields?: number[]; -
Step 2: Create the shared picker component
Create src/admin/components/document/CustomFieldsPrintPicker.tsx:
import { FormControlLabel, Checkbox, Box, Typography } from "@mui/material";
import type { CompanySettingsCustomField } from "../../lib/queries/settings";
interface Props {
/** Company custom field definitions, in positional order (index = print key). */
fields: CompanySettingsCustomField[];
/** Currently selected positional indices. */
selected: number[];
/** Read-only (document not editable / locked by another user). */
disabled?: boolean;
onChange: (next: number[]) => void;
}
/**
* Per-document picker: choose which COMPANY custom fields print on this
* document's PDF. Selection is positional (matches the PDF `custom_<i>` keys).
* Renders nothing when the company has defined no custom fields.
*/
export default function CustomFieldsPrintPicker({
fields,
selected,
disabled = false,
onChange,
}: Props) {
const printable = fields.filter((f) => (f.value || "").trim());
if (printable.length === 0) return null;
const toggle = (idx: number, checked: boolean) => {
const set = new Set(selected);
if (checked) set.add(idx);
else set.delete(idx);
onChange([...set].sort((a, b) => a - b));
};
return (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Vlastní pole na PDF
</Typography>
{fields.map((f, idx) => {
if (!(f.value || "").trim()) return null;
const label = (f.name || "").trim() ? `${f.name}: ${f.value}` : f.value;
return (
<FormControlLabel
key={idx}
control={
<Checkbox
size="small"
checked={selected.includes(idx)}
disabled={disabled}
onChange={(e) => toggle(idx, e.target.checked)}
/>
}
label={<Typography variant="body2">{label}</Typography>}
/>
);
})}
</Box>
);
}
Note: indices are keyed off the FULL
fieldsarray (not the filteredprintable) so they stay aligned with the PDF'scustom_<i>, which also enumerates the full array. Empty-value fields are skipped visually but still consume their index.
- Step 3: Typecheck
Run: npm run typecheck
Expected: PASS.
- Step 4: Commit
git add src/admin/lib/queries/offers.ts src/admin/lib/queries/issued-orders.ts src/admin/lib/queries/invoices.ts src/admin/components/document/CustomFieldsPrintPicker.tsx
git commit -m "feat(documents): shared CustomFieldsPrintPicker + detail query types"
Task 7: Frontend — wire the picker into the three detail pages
Files:
- Modify:
src/admin/pages/OfferDetail.tsx - Modify:
src/admin/pages/IssuedOrderDetail.tsx - Modify:
src/admin/pages/InvoiceDetail.tsx
For EACH page, the same four edits. Detail below uses OfferDetail; repeat the pattern for the other two (their company-settings query and edit-lock/editable flags already exist on the page — reuse them; do not add new queries).
- Step 1: Import the picker (all three pages)
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
- Step 2: Seed local state from the loaded document
Add state near the page's other editable-field state, and seed it when the document loads (follow the page's existing seeding pattern — if it copies server data into state in an effect or on query success, add this alongside; if it derives form state via useState initializers keyed on the query, match that):
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>([]);
// when the detail query resolves (same place other fields are seeded):
// setSelectedCustomFields(data.selected_custom_fields ?? []);
Respect Rules of Hooks: declare this useState with the other hooks, before any early return.
- Step 3: Render the picker in the editable header/meta area
Place near the other document-level settings (currency/language). companySettings is already loaded on the page via companySettingsOptions(). Use the page's existing "is this document editable / not locked by another user" boolean for disabled (e.g. !canEdit or the locked flag the page already computes):
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={!canEdit}
onChange={setSelectedCustomFields}
/>
Use whatever the page already calls its edit-gate (e.g.
canEdit,editable,isLockedByOther). Do NOT invent a new permission — reuse the page's existing flag so the picker locks exactly when the rest of the form does.
- Step 4: Include the selection in the save payload
Find where the page builds its update/create payload (the object passed to the save mutation) and add:
selected_custom_fields: selectedCustomFields,
- Step 5: Repeat Steps 1-4 for
IssuedOrderDetail.tsxandInvoiceDetail.tsx
Same edits; the company-settings query, edit-gate flag, and save payload all already exist on each page.
- Step 6: Typecheck + lint
Run: npm run typecheck && npm run lint
Expected: PASS, 0 lint errors (watch react-hooks/rules-of-hooks — the new useState must precede any early return).
- Step 7: Commit
git add src/admin/pages/OfferDetail.tsx src/admin/pages/IssuedOrderDetail.tsx src/admin/pages/InvoiceDetail.tsx
git commit -m "feat(documents): per-document custom-field print picker on offer/issued-order/invoice detail"
Task 8: Full verification
- Step 1: Run the whole server suite
Run: npm test
Expected: PASS (no regressions; new selected-custom-fields cases green).
- Step 2: Typecheck + lint + build
npm run typecheck
npm run lint
npm run build
Expected: all PASS; client builds.
- Step 3: Manual smoke (user-driven, dev server is theirs)
Ask the user to: open an offer detail with company custom fields defined → tick one field → save → open its PDF and confirm only that field prints in the company block; confirm an untouched/older document prints no custom fields. Repeat for an issued order and an invoice.
- Step 4: Final state check
git status
git log --oneline -8
Expected: clean tree, the feature commits present.
Self-review notes (addressed)
- Spec coverage: storage column (T1), encode/decode (T2), schemas (T3), service persist+decode (T4), PDF filter (T5), frontend types+picker (T6), detail-page wiring (T7), tests throughout + full verify (T8). Order confirmations deliberately untouched. ✅
- Default = none:
buildAddressLinesfilters only when passed an array; the company call always passes the parsed selection (default[]), so nothing prints until ticked. Customer/supplier calls omit the param ⇒ unchanged. ✅ - Positional identity: indices key off the full
fieldsarray on both render and picker sides (T5 note, T6 Step 2 note). ✅ - Type consistency:
encodeSelectedCustomFields/parseSelectedCustomFieldsused with identical signatures across services and PDF routes; detail interfaces exposenumber[]. ✅ - Open confirmations flagged inline (prisma import path in tests; whether each PDF route narrows its document
select) — each has a grep-first instruction rather than an assumption.