790 lines
30 KiB
Markdown
790 lines
30 KiB
Markdown
# 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 dev` is forbidden. Use the `migrate diff` recipe (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_test` via `.env.test` (`npm test`). Apply the migration to `app_test` too or the suite breaks.
|
|
- `npm run typecheck` = `tsc -b --noEmit`. `npm run lint` must 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` — add `selected_custom_fields String?` to `quotations`, `issued_orders`, `invoices`.
|
|
- **Create** `prisma/migrations/<ts>_add_selected_custom_fields/migration.sql`.
|
|
- **Modify** `src/utils/custom-fields.ts` — add `encodeSelectedCustomFields` / `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` — add `selected_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` (models `quotations`, `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`):
|
|
|
|
```prisma
|
|
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):
|
|
|
|
```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)**
|
|
|
|
```bash
|
|
npx prisma migrate deploy
|
|
npx prisma generate
|
|
```
|
|
|
|
Expected: "All migrations have been applied" and a regenerated client.
|
|
|
|
- [ ] **Step 5: Apply to the test DB**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```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`:
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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):
|
|
|
|
```ts
|
|
// 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:
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
|
```
|
|
|
|
- [ ] **Step 4: Typecheck**
|
|
|
|
Run: `npm run typecheck`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
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`):
|
|
|
|
```ts
|
|
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`:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
|
```
|
|
|
|
- [ ] **Step 5: Issued orders — persist + decode**
|
|
|
|
In `src/services/issued-orders.service.ts`:
|
|
|
|
- In `createIssuedOrder`, inside `tx.issued_orders.create({ data: { … } })`, add:
|
|
```ts
|
|
selected_custom_fields: encodeSelectedCustomFields(
|
|
body.selected_custom_fields,
|
|
),
|
|
```
|
|
- In `updateIssuedOrder`, inside the header `data` object passed to `issued_orders.update`, add:
|
|
```ts
|
|
selected_custom_fields:
|
|
body.selected_custom_fields !== undefined
|
|
? encodeSelectedCustomFields(body.selected_custom_fields)
|
|
: undefined,
|
|
```
|
|
- In `getIssuedOrder`, in the returned object (after `valid_transitions`), add:
|
|
|
|
```ts
|
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
|
```
|
|
|
|
- [ ] **Step 6: Invoices — persist + decode**
|
|
|
|
In `src/services/invoices.service.ts`:
|
|
|
|
- In `createInvoice`, inside `tx.invoices.create({ data: { … } })`, add after `internal_notes`:
|
|
```ts
|
|
selected_custom_fields: encodeSelectedCustomFields(
|
|
body.selected_custom_fields,
|
|
),
|
|
```
|
|
- In `updateInvoice`, inside the first `if (editable) { … }` block (after the `tax_date` handling, still inside the block), add:
|
|
```ts
|
|
if (body.selected_custom_fields !== undefined)
|
|
data.selected_custom_fields = encodeSelectedCustomFields(
|
|
body.selected_custom_fields,
|
|
);
|
|
```
|
|
- In `getInvoice`, in the returned object (after `valid_transitions`), add:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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/prisma` vs `../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**
|
|
|
|
```bash
|
|
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):
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
import { parseSelectedCustomFields } from "../../utils/custom-fields";
|
|
```
|
|
|
|
> Confirm `quotation` (the `OfferForPdf` payload the render function receives) is selected with the default field set so `selected_custom_fields` is present. It's a scalar column on `quotations`, so the default `findUnique`/`include` returns it — no `select` narrowing 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 of `buildAddressLines` and apply the identical `filterCustom` guard in its custom-field loop.
|
|
- The company block is `buyer = buildAddressLines(settings, true, t)` (line 316). Change to:
|
|
```ts
|
|
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 `order` param carries `selected_custom_fields`. If the route fetches the order via a narrow `select`, add `selected_custom_fields: true` to it; if it uses `include`/default scalars, it's already present. Grep the route's `issued_orders.findUnique`/`findFirst` before 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 of `buildAddressLines` and apply the identical `filterCustom` guard.
|
|
- The company block is `supp = buildAddressLines(settings, true, t)` (line 457). Change to:
|
|
```ts
|
|
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_fields` read 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 `invoice` is fetched with default scalars (it is — `prisma.invoices.findUnique` without a narrowing `select` in this route), so `selected_custom_fields` is 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):
|
|
|
|
```ts
|
|
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**
|
|
|
|
```bash
|
|
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 to `OfferDetailData`:
|
|
```ts
|
|
selected_custom_fields: number[];
|
|
```
|
|
- `src/admin/lib/queries/issued-orders.ts` — add to `IssuedOrderDetail`:
|
|
```ts
|
|
selected_custom_fields: number[];
|
|
```
|
|
- `src/admin/lib/queries/invoices.ts` — add to `InvoiceDetail`:
|
|
|
|
```ts
|
|
selected_custom_fields?: number[];
|
|
```
|
|
|
|
- [ ] **Step 2: Create the shared picker component**
|
|
|
|
Create `src/admin/components/document/CustomFieldsPrintPicker.tsx`:
|
|
|
|
```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 `fields` array (not the filtered `printable`) so they stay aligned with the PDF's `custom_<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**
|
|
|
|
```bash
|
|
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)**
|
|
|
|
```ts
|
|
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):
|
|
|
|
```ts
|
|
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):
|
|
|
|
```tsx
|
|
<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:
|
|
|
|
```ts
|
|
selected_custom_fields: selectedCustomFields,
|
|
```
|
|
|
|
- [ ] **Step 5: Repeat Steps 1-4 for `IssuedOrderDetail.tsx` and `InvoiceDetail.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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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:** `buildAddressLines` filters 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 `fields` array on both render and picker sides (T5 note, T6 Step 2 note). ✅
|
|
- **Type consistency:** `encodeSelectedCustomFields` / `parseSelectedCustomFields` used with identical signatures across services and PDF routes; detail interfaces expose `number[]`. ✅
|
|
- **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.
|