Compare commits
12 Commits
4674ff3389
...
v2.4.39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae4a51bf7d | ||
|
|
fb480d886d | ||
|
|
59f555059b | ||
|
|
3d784adf5d | ||
|
|
44cfea22ca | ||
|
|
bce0280846 | ||
|
|
0d9b5e9570 | ||
|
|
2b3266a1cc | ||
|
|
2f084174df | ||
|
|
5551786da2 | ||
|
|
09f8dc63bf | ||
|
|
8dec785d13 |
@@ -0,0 +1,789 @@
|
||||
# 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.
|
||||
@@ -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`
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.38",
|
||||
"version": "2.4.39",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.38",
|
||||
"version": "2.4.39",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.38",
|
||||
"version": "2.4.39",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `invoices` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `issued_orders` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `quotations` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
|
||||
|
||||
@@ -239,6 +239,7 @@ model invoices {
|
||||
billing_text String? @db.VarChar(500)
|
||||
language String? @default("cs") @db.VarChar(5)
|
||||
internal_notes String? @db.Text
|
||||
selected_custom_fields String? @db.VarChar(255)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
invoice_items invoice_items[]
|
||||
@@ -393,6 +394,7 @@ model issued_orders {
|
||||
language String? @default("cs") @db.VarChar(5)
|
||||
order_text String? @db.VarChar(500)
|
||||
internal_notes String? @db.Text
|
||||
selected_custom_fields String? @db.VarChar(255)
|
||||
locked_by Int?
|
||||
locked_at DateTime? @db.DateTime(0)
|
||||
created_at DateTime? @default(now()) @db.DateTime(0)
|
||||
@@ -527,6 +529,7 @@ model quotations {
|
||||
status String @default("active") @db.VarChar(20)
|
||||
scope_title String? @db.VarChar(500)
|
||||
scope_description String? @db.Text
|
||||
selected_custom_fields String? @db.VarChar(255)
|
||||
locked_by Int?
|
||||
locked_at DateTime? @db.DateTime(0)
|
||||
modified_at DateTime? @db.DateTime(0)
|
||||
|
||||
129
src/__tests__/selected-custom-fields.test.ts
Normal file
129
src/__tests__/selected-custom-fields.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
encodeSelectedCustomFields,
|
||||
parseSelectedCustomFields,
|
||||
} from "../utils/custom-fields";
|
||||
import { afterAll } from "vitest";
|
||||
import { createOffer, getOffer } from "../services/offers.service";
|
||||
import { renderOfferHtml } from "../routes/admin/offers-pdf";
|
||||
import type { OfferForPdf } from "../routes/admin/offers-pdf";
|
||||
import type { company_settings } from "@prisma/client";
|
||||
import prisma from "../config/database";
|
||||
|
||||
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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("offer PDF prints only selected company custom fields", () => {
|
||||
// Two company custom fields; the document selects only index 0.
|
||||
const settings = {
|
||||
company_name: "Test s.r.o.",
|
||||
custom_fields: JSON.stringify({
|
||||
fields: [
|
||||
{ name: "Email", value: "selected@example.com", showLabel: false },
|
||||
{ name: "Web", value: "notselected.example.com", showLabel: false },
|
||||
],
|
||||
field_order: [],
|
||||
}),
|
||||
logo_data: null,
|
||||
} as unknown as company_settings;
|
||||
|
||||
const baseQuotation = {
|
||||
quotation_number: "TEST-2098-001",
|
||||
language: "EN",
|
||||
currency: "EUR",
|
||||
valid_until: new Date("2098-01-01"),
|
||||
project_code: null,
|
||||
created_at: new Date("2098-01-01"),
|
||||
customers: { name: "Cust" },
|
||||
quotation_items: [],
|
||||
scope_sections: [],
|
||||
};
|
||||
|
||||
it("renders the selected custom field and omits the non-selected one", () => {
|
||||
const html = renderOfferHtml(
|
||||
{
|
||||
...baseQuotation,
|
||||
selected_custom_fields: "[0]",
|
||||
} as unknown as OfferForPdf,
|
||||
settings,
|
||||
);
|
||||
expect(html).toContain("selected@example.com");
|
||||
expect(html).not.toContain("notselected.example.com");
|
||||
});
|
||||
|
||||
it("prints NO company custom fields when the document selects none", () => {
|
||||
// The company call site always passes a parsed array; a null column
|
||||
// decodes to [] → empty selection → no custom fields on the document.
|
||||
const html = renderOfferHtml(
|
||||
{
|
||||
...baseQuotation,
|
||||
selected_custom_fields: null,
|
||||
} as unknown as OfferForPdf,
|
||||
settings,
|
||||
);
|
||||
expect(html).not.toContain("selected@example.com");
|
||||
expect(html).not.toContain("notselected.example.com");
|
||||
});
|
||||
});
|
||||
60
src/admin/components/document/CustomFieldsPrintPicker.tsx
Normal file
60
src/admin/components/document/CustomFieldsPrintPicker.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
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 printable 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>
|
||||
);
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export interface InvoiceDetail {
|
||||
bank_account_id?: number | null;
|
||||
items?: InvoiceItem[];
|
||||
sections?: InvoiceSection[];
|
||||
selected_custom_fields?: number[];
|
||||
subtotal: number;
|
||||
vat_amount: number;
|
||||
total: number;
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface IssuedOrderDetail extends IssuedOrder {
|
||||
sections: IssuedOrderSection[];
|
||||
supplier: Record<string, unknown> | null;
|
||||
valid_transitions: string[];
|
||||
selected_custom_fields: number[];
|
||||
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
|
||||
locked_by: {
|
||||
user_id: number;
|
||||
|
||||
@@ -184,6 +184,7 @@ export interface OfferDetailData {
|
||||
locked_by: OfferLockInfo | null;
|
||||
/** Legal next statuses, computed server-side (same contract as issued orders). */
|
||||
valid_transitions: string[];
|
||||
selected_custom_fields: number[];
|
||||
}
|
||||
|
||||
export const offerDetailOptions = (id: string | undefined) =>
|
||||
|
||||
@@ -27,6 +27,7 @@ import RichEditor from "../components/RichEditor";
|
||||
import SectionsEditor, {
|
||||
type DocumentSection,
|
||||
} from "../components/document/SectionsEditor";
|
||||
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -611,6 +612,9 @@ export default function InvoiceDetail() {
|
||||
// Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF —
|
||||
// shared editor with offers/issued orders).
|
||||
const [sections, setSections] = useState<DocumentSection[]>([]);
|
||||
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
|
||||
[],
|
||||
);
|
||||
const [items, setItems] = useState<InvoiceItem[]>(() => [
|
||||
{
|
||||
_key: "inv-1",
|
||||
@@ -821,11 +825,15 @@ export default function InvoiceDetail() {
|
||||
: [];
|
||||
setSections(mappedSections);
|
||||
|
||||
const mappedCustomFields = inv.selected_custom_fields ?? [];
|
||||
setSelectedCustomFields(mappedCustomFields);
|
||||
|
||||
// Capture initial snapshot for dirty-checking
|
||||
initialSnapshotRef.current = JSON.stringify({
|
||||
form: formData,
|
||||
items: mappedItems,
|
||||
sections: mappedSections,
|
||||
selectedCustomFields: mappedCustomFields,
|
||||
});
|
||||
|
||||
setDataReady(true);
|
||||
@@ -923,15 +931,21 @@ export default function InvoiceDetail() {
|
||||
// Edit mode: captured inside the sync effect from raw query data.
|
||||
// Create mode: captured on the first render after sync effects populate the form.
|
||||
if (dataReady && !initialSnapshotRef.current) {
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
|
||||
initialSnapshotRef.current = JSON.stringify({
|
||||
form,
|
||||
items,
|
||||
sections,
|
||||
selectedCustomFields,
|
||||
});
|
||||
}
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!initialSnapshotRef.current) return false;
|
||||
return (
|
||||
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
|
||||
JSON.stringify({ form, items, sections, selectedCustomFields }) !==
|
||||
initialSnapshotRef.current
|
||||
);
|
||||
}, [form, items, sections]);
|
||||
}, [form, items, sections, selectedCustomFields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
@@ -1095,6 +1109,7 @@ export default function InvoiceDetail() {
|
||||
content: s.content,
|
||||
position: i,
|
||||
})),
|
||||
selected_custom_fields: selectedCustomFields,
|
||||
};
|
||||
// Only set status when a target is given (create-as-draft / create-as-live
|
||||
// / finalize). Editing a live invoice sends no status — the backend keeps
|
||||
@@ -1107,7 +1122,12 @@ export default function InvoiceDetail() {
|
||||
|
||||
const data = await saveMutation.mutateAsync(payload);
|
||||
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
|
||||
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
|
||||
initialSnapshotRef.current = JSON.stringify({
|
||||
form,
|
||||
items,
|
||||
sections,
|
||||
selectedCustomFields,
|
||||
});
|
||||
if (!isEdit) {
|
||||
navigate(`/invoices/${data.invoice_id}`);
|
||||
}
|
||||
@@ -2209,6 +2229,15 @@ export default function InvoiceDetail() {
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<CustomFieldsPrintPicker
|
||||
fields={companySettings?.custom_fields ?? []}
|
||||
selected={selectedCustomFields}
|
||||
disabled={false}
|
||||
onChange={setSelectedCustomFields}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Items */}
|
||||
|
||||
@@ -23,6 +23,7 @@ import SectionsEditor, {
|
||||
type DocumentSection,
|
||||
} from "../components/document/SectionsEditor";
|
||||
import LockBanner from "../components/document/LockBanner";
|
||||
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
|
||||
import { useDocumentLock } from "../hooks/useDocumentLock";
|
||||
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
|
||||
import { useDocumentPdf } from "../hooks/useDocumentPdf";
|
||||
@@ -137,6 +138,7 @@ interface IssuedOrderSavePayload {
|
||||
internal_notes: string;
|
||||
items: IssuedOrderItemPayload[];
|
||||
sections: IssuedOrderSectionPayload[];
|
||||
selected_custom_fields: number[];
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -164,6 +166,9 @@ export default function IssuedOrderDetail() {
|
||||
emptyDocumentItem(),
|
||||
]);
|
||||
const [sections, setSections] = useState<DocumentSection[]>([]);
|
||||
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
|
||||
[],
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Which save action is in flight ("draft" | the live status | "save"), so
|
||||
@@ -255,7 +260,7 @@ export default function IssuedOrderDetail() {
|
||||
const canExport = hasPermission("orders.view");
|
||||
|
||||
const { isDirty, markClean } = useUnsavedChangesGuard(
|
||||
{ form, items, sections },
|
||||
{ form, items, sections, selectedCustomFields },
|
||||
!isEdit || dataReady,
|
||||
);
|
||||
|
||||
@@ -308,6 +313,7 @@ export default function IssuedOrderDetail() {
|
||||
}))
|
||||
: [];
|
||||
setSections(mappedSections);
|
||||
setSelectedCustomFields(d.selected_custom_fields ?? []);
|
||||
|
||||
setLockedBy(d.locked_by ?? null);
|
||||
// Acquire the edit lock when nobody holds it, the order is still
|
||||
@@ -430,6 +436,7 @@ export default function IssuedOrderDetail() {
|
||||
content: s.content,
|
||||
position: i,
|
||||
})),
|
||||
selected_custom_fields: selectedCustomFields,
|
||||
};
|
||||
// Only set status when a target is given (create-as-draft / create-as-live
|
||||
// / finalize). Editing a live order sends no status — the backend keeps
|
||||
@@ -874,6 +881,15 @@ export default function IssuedOrderDetail() {
|
||||
placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<CustomFieldsPrintPicker
|
||||
fields={companySettings?.custom_fields ?? []}
|
||||
selected={selectedCustomFields}
|
||||
disabled={!editable}
|
||||
onChange={setSelectedCustomFields}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Items */}
|
||||
|
||||
@@ -33,6 +33,7 @@ import SectionsEditor, {
|
||||
type DocumentSection,
|
||||
} from "../components/document/SectionsEditor";
|
||||
import LockBanner from "../components/document/LockBanner";
|
||||
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
|
||||
import { useDocumentLock } from "../hooks/useDocumentLock";
|
||||
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
|
||||
import { useDocumentPdf } from "../hooks/useDocumentPdf";
|
||||
@@ -114,6 +115,7 @@ interface OfferSavePayload {
|
||||
language: string;
|
||||
items: OfferItemPayload[];
|
||||
sections: OfferSectionPayload[];
|
||||
selected_custom_fields: number[];
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -178,6 +180,9 @@ export default function OfferDetail() {
|
||||
emptyDocumentItem(),
|
||||
]);
|
||||
const [sections, setSections] = useState<DocumentSection[]>([]);
|
||||
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
|
||||
[],
|
||||
);
|
||||
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
|
||||
const [offerStatus, setOfferStatus] = useState<string>("");
|
||||
const [quotationNumber, setQuotationNumber] = useState("");
|
||||
@@ -270,7 +275,7 @@ export default function OfferDetail() {
|
||||
const canDelete = isEdit && !orderInfo && !isInvalidated && !isLockedByOther;
|
||||
|
||||
const { markClean } = useUnsavedChangesGuard(
|
||||
{ form, items, sections },
|
||||
{ form, items, sections, selectedCustomFields },
|
||||
!loading,
|
||||
);
|
||||
|
||||
@@ -318,6 +323,7 @@ export default function OfferDetail() {
|
||||
}))
|
||||
: [];
|
||||
setSections(mappedSections);
|
||||
setSelectedCustomFields(d.selected_custom_fields ?? []);
|
||||
markClean({ form: formData, items: mappedItems, sections: mappedSections });
|
||||
setOfferStatus(d.status || "");
|
||||
setOrderInfo(d.order ?? null);
|
||||
@@ -445,6 +451,7 @@ export default function OfferDetail() {
|
||||
content: s.content,
|
||||
position: i,
|
||||
})),
|
||||
selected_custom_fields: selectedCustomFields,
|
||||
};
|
||||
// Only set status when a target is given (create-as-draft / create-as-live
|
||||
// / finalize). Editing a live offer sends no status — the backend keeps
|
||||
@@ -929,6 +936,15 @@ export default function OfferDetail() {
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<CustomFieldsPrintPicker
|
||||
fields={companySettings?.custom_fields ?? []}
|
||||
selected={selectedCustomFields}
|
||||
disabled={readOnly}
|
||||
onChange={setSelectedCustomFields}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Items (drag-and-drop, offers carry the "V ceně" column) */}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getRate } from "../../services/exchange-rates";
|
||||
import { localDateStr } from "../../utils/date";
|
||||
import { parseId, success } from "../../utils/response";
|
||||
import { lineNet } from "../../utils/money";
|
||||
import { parseSelectedCustomFields } from "../../utils/custom-fields";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
@@ -34,6 +35,7 @@ function buildAddressLines(
|
||||
entity: Record<string, unknown> | null,
|
||||
isSupplier: boolean,
|
||||
tObj: Record<string, string>,
|
||||
selectedCustomFields?: number[],
|
||||
): AddressResult {
|
||||
if (!entity) return { name: "", lines: [] };
|
||||
|
||||
@@ -89,7 +91,9 @@ function buildAddressLines(
|
||||
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
|
||||
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
|
||||
|
||||
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;
|
||||
@@ -454,7 +458,15 @@ export default async function invoicesPdfRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const supp = buildAddressLines(settings, true, t);
|
||||
const supp = buildAddressLines(
|
||||
settings,
|
||||
true,
|
||||
t,
|
||||
parseSelectedCustomFields(
|
||||
(invoice as { selected_custom_fields?: unknown })
|
||||
.selected_custom_fields,
|
||||
),
|
||||
);
|
||||
const cust = buildAddressLines(customer, false, t);
|
||||
|
||||
const suppLinesHtml = supp.lines
|
||||
|
||||
@@ -4,6 +4,7 @@ import { requirePermission } from "../../middleware/auth";
|
||||
import { parseId, success } from "../../utils/response";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { nasOrdersManager } from "../../services/nas-financials-manager";
|
||||
import { parseSelectedCustomFields } from "../../utils/custom-fields";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
@@ -31,6 +32,7 @@ function buildAddressLines(
|
||||
entity: Record<string, unknown> | null,
|
||||
isCompany: boolean,
|
||||
tObj: Record<string, string>,
|
||||
selectedCustomFields?: number[],
|
||||
): AddressResult {
|
||||
if (!entity) return { name: "", lines: [] };
|
||||
|
||||
@@ -86,7 +88,9 @@ function buildAddressLines(
|
||||
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
|
||||
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
|
||||
|
||||
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;
|
||||
@@ -313,7 +317,14 @@ export function renderIssuedOrderHtml(
|
||||
|
||||
// PO direction: our company (settings) = Odběratel (buyer);
|
||||
// the sklad_suppliers record = Dodavatel (supplier).
|
||||
const buyer = buildAddressLines(settings, true, t); // company → Odběratel
|
||||
const buyer = buildAddressLines(
|
||||
settings,
|
||||
true,
|
||||
t,
|
||||
parseSelectedCustomFields(
|
||||
(order as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||
),
|
||||
); // company → Odběratel
|
||||
const supplierAddr = buildSupplierLines(supplier, t); // supplier → Dodavatel
|
||||
|
||||
const buyerLinesHtml = buyer.lines
|
||||
|
||||
@@ -6,6 +6,7 @@ import { nasOffersManager } from "../../services/nas-offers-manager";
|
||||
import { htmlToPdf } from "../../utils/html-to-pdf";
|
||||
import { parseId, success } from "../../utils/response";
|
||||
import { lineNet } from "../../utils/money";
|
||||
import { parseSelectedCustomFields } from "../../utils/custom-fields";
|
||||
import {
|
||||
formatDate,
|
||||
formatNum,
|
||||
@@ -29,6 +30,7 @@ function buildAddressLines(
|
||||
entity: Record<string, unknown> | null,
|
||||
isSupplier: boolean,
|
||||
t: (key: string) => string,
|
||||
selectedCustomFields?: number[],
|
||||
): AddressResult {
|
||||
if (!entity) return { name: "", lines: [] };
|
||||
|
||||
@@ -85,7 +87,9 @@ function buildAddressLines(
|
||||
fieldMap.company_id = `${t("ico")}: ${entity.company_id}`;
|
||||
if (entity.vat_id) fieldMap.vat_id = `${t("dic")}: ${entity.vat_id}`;
|
||||
|
||||
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;
|
||||
@@ -210,6 +214,10 @@ export function renderOfferHtml(
|
||||
settings as unknown as Record<string, unknown>,
|
||||
true,
|
||||
t,
|
||||
parseSelectedCustomFields(
|
||||
(quotation as { selected_custom_fields?: unknown })
|
||||
.selected_custom_fields,
|
||||
),
|
||||
);
|
||||
|
||||
const custLinesHtml = cust.lines
|
||||
|
||||
@@ -50,6 +50,11 @@ export const CreateInvoiceSchema = z.object({
|
||||
// Rich-text CZ/EN "Obsah" sections rendered after the items on the PDF
|
||||
// (mirrors issued orders — shared DocumentSectionSchema, DB-aligned limits).
|
||||
sections: z.array(DocumentSectionSchema).optional(),
|
||||
// Positional indices of company custom fields to print on this document's PDF.
|
||||
selected_custom_fields: z
|
||||
.array(z.number().int().nonnegative())
|
||||
.max(50)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const UpdateInvoiceSchema = z.object({
|
||||
@@ -74,6 +79,10 @@ export const UpdateInvoiceSchema = z.object({
|
||||
paid_date: nullableIsoDateString,
|
||||
items: z.array(InvoiceItemSchema).optional(),
|
||||
sections: z.array(DocumentSectionSchema).optional(),
|
||||
selected_custom_fields: z
|
||||
.array(z.number().int().nonnegative())
|
||||
.max(50)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;
|
||||
|
||||
@@ -41,6 +41,12 @@ export const CreateIssuedOrderSchema = z.object({
|
||||
// Rich-text CZ/EN sections rendered on their own PDF page (mirrors offers'
|
||||
// scope_sections — shared DocumentSectionSchema, DB-aligned limits).
|
||||
sections: z.array(DocumentSectionSchema).optional(),
|
||||
// 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(50)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Update = partial create MINUS the number: PO numbers are immutable
|
||||
|
||||
@@ -48,6 +48,12 @@ export const CreateQuotationSchema = z.object({
|
||||
scope_description: z.string().max(8000).nullish(),
|
||||
items: z.array(QuotationItemSchema).optional(),
|
||||
sections: z.array(ScopeSectionSchema).optional(),
|
||||
// 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(50)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// Update = partial create MINUS the number: quotation numbers are immutable
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
releaseInvoiceNumber,
|
||||
assignInvoiceNumber,
|
||||
} from "./numbering.service";
|
||||
import {
|
||||
encodeSelectedCustomFields,
|
||||
parseSelectedCustomFields,
|
||||
} from "../utils/custom-fields";
|
||||
|
||||
// Status transition rules matching PHP.
|
||||
// draft -> issued is the finalize step (consumes + assigns the invoice number).
|
||||
@@ -512,6 +516,9 @@ export async function getInvoice(id: number) {
|
||||
customer_name: invoice.customers?.name || null,
|
||||
order_number: invoice.orders?.order_number || null,
|
||||
valid_transitions: VALID_TRANSITIONS[invoice.status as string] || [],
|
||||
selected_custom_fields: parseSelectedCustomFields(
|
||||
rest.selected_custom_fields,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -561,6 +568,9 @@ export async function createInvoice(body: InvoiceInput) {
|
||||
internal_notes: body.internal_notes
|
||||
? String(body.internal_notes)
|
||||
: null,
|
||||
selected_custom_fields: encodeSelectedCustomFields(
|
||||
body.selected_custom_fields,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -652,6 +662,10 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
|
||||
data.due_date = body.due_date ? new Date(String(body.due_date)) : null;
|
||||
if (body.tax_date !== undefined)
|
||||
data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
|
||||
if (body.selected_custom_fields !== undefined)
|
||||
data.selected_custom_fields = encodeSelectedCustomFields(
|
||||
body.selected_custom_fields,
|
||||
);
|
||||
}
|
||||
|
||||
// Internal notes editable in draft/issued/overdue (never printed)
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
isIssuedOrderNumberTaken,
|
||||
} from "./numbering.service";
|
||||
import { nasOrdersManager } from "./nas-financials-manager";
|
||||
import {
|
||||
encodeSelectedCustomFields,
|
||||
parseSelectedCustomFields,
|
||||
} from "../utils/custom-fields";
|
||||
|
||||
export interface IssuedOrderItemInput {
|
||||
description?: string | null;
|
||||
@@ -125,6 +129,7 @@ const NON_STATUS_UPDATE_FIELDS = [
|
||||
"language",
|
||||
"order_text",
|
||||
"internal_notes",
|
||||
"selected_custom_fields",
|
||||
"items",
|
||||
"sections",
|
||||
] as const;
|
||||
@@ -253,6 +258,9 @@ export async function getIssuedOrder(id: number) {
|
||||
supplier: order.suppliers,
|
||||
supplier_name: order.suppliers?.name || null,
|
||||
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
|
||||
selected_custom_fields: parseSelectedCustomFields(
|
||||
rest.selected_custom_fields,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -319,6 +327,9 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
|
||||
internal_notes: body.internal_notes
|
||||
? String(body.internal_notes)
|
||||
: null,
|
||||
selected_custom_fields: encodeSelectedCustomFields(
|
||||
body.selected_custom_fields,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -424,6 +435,10 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
|
||||
data.internal_notes = body.internal_notes
|
||||
? String(body.internal_notes)
|
||||
: null;
|
||||
if (body.selected_custom_fields !== undefined)
|
||||
data.selected_custom_fields = encodeSelectedCustomFields(
|
||||
body.selected_custom_fields,
|
||||
);
|
||||
}
|
||||
|
||||
if (body.status !== undefined) data.status = String(body.status);
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
assignOfferNumber,
|
||||
} from "./numbering.service";
|
||||
import { nasOffersManager } from "./nas-offers-manager";
|
||||
import {
|
||||
encodeSelectedCustomFields,
|
||||
parseSelectedCustomFields,
|
||||
} from "../utils/custom-fields";
|
||||
|
||||
interface QuotationItemInput {
|
||||
description?: string;
|
||||
@@ -64,6 +68,7 @@ const NON_STATUS_UPDATE_FIELDS = [
|
||||
"language",
|
||||
"scope_title",
|
||||
"scope_description",
|
||||
"selected_custom_fields",
|
||||
"items",
|
||||
"sections",
|
||||
] as const;
|
||||
@@ -283,6 +288,9 @@ export async function getOffer(id: number) {
|
||||
// Computed server-side so the frontend renders only legal status buttons
|
||||
// (same contract as getIssuedOrder).
|
||||
valid_transitions: VALID_TRANSITIONS[quotation.status] || [],
|
||||
selected_custom_fields: parseSelectedCustomFields(
|
||||
rest.selected_custom_fields,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -342,6 +350,9 @@ export async function createOffer(body: Record<string, unknown>) {
|
||||
scope_description: body.scope_description
|
||||
? String(body.scope_description)
|
||||
: null,
|
||||
selected_custom_fields: encodeSelectedCustomFields(
|
||||
body.selected_custom_fields,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -472,6 +483,10 @@ export async function updateOffer(id: number, body: Record<string, unknown>) {
|
||||
? String(body.scope_description)
|
||||
: null
|
||||
: undefined,
|
||||
selected_custom_fields:
|
||||
body.selected_custom_fields !== undefined
|
||||
? encodeSelectedCustomFields(body.selected_custom_fields)
|
||||
: undefined,
|
||||
modified_at: new Date(),
|
||||
};
|
||||
|
||||
|
||||
@@ -46,3 +46,36 @@ export function decodeCustomFields(raw: string | null): {
|
||||
return { custom_fields: [], field_order: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user