Compare commits

...

17 Commits

Author SHA1 Message Date
BOHA
81293ae543 chore(release): v2.4.40 — optional line items on issued orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:03:54 +02:00
BOHA
237ebf3ef8 feat(issued-orders): make line items optional (issue by sections alone)
Items are no longer required on issued orders — an order can be issued
purely from its rich-text sections (Obsah).

- DocumentItemsEditor: opt-in allowEmpty prop lets the list go to zero
  rows (default false keeps the offers/invoices at-least-one-item rule).
- IssuedOrderDetail: allowEmpty on the editor; a saved order with no
  items reopens empty (not a blank starter row); submit guard now
  requires at least one item OR a non-empty section, not an item.
- PDF: with no items the billing heading, items table and total row are
  omitted entirely — a sections-only order shows only its Obsah.
- Service: finalize (draft->sent) and create-as-non-draft reject a
  completely blank order (no items AND no sections) with a Czech 400
  (empty_document); drafts may still be saved empty as WIP.
- Tests: sections-only finalize + blank-order rejection + sections-only
  PDF render; existing finalize fixtures given minimal content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:36:35 +02:00
BOHA
983a1408f1 docs(release): clarify nginx reload is only for nginx config changes, not every deploy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:44:47 +02:00
BOHA
ae4a51bf7d chore(release): v2.4.39 — per-document custom-field print selection
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:40:16 +02:00
BOHA
fb480d886d fix(documents): gate selected_custom_fields behind editable guard; bound index cap to column width
Addresses review: add selected_custom_fields to offers/issued-orders
NON_STATUS_UPDATE_FIELDS so a non-editable document rejects selection
edits (status-only transitions still pass); tighten Zod cap 200->50 to
stay within VARCHAR(255).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:31:58 +02:00
BOHA
59f555059b fix(documents): include custom-field selection in offer/issued-order unsaved-changes guard
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:22:36 +02:00
BOHA
3d784adf5d feat(documents): per-document custom-field print picker on offer/issued-order/invoice detail
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:20:51 +02:00
BOHA
44cfea22ca feat(documents): shared CustomFieldsPrintPicker + detail query types
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:17:23 +02:00
BOHA
bce0280846 feat(documents): PDF prints only the document's selected company custom fields
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:15:26 +02:00
BOHA
0d9b5e9570 feat(documents): persist & expose selected_custom_fields in services
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:11:10 +02:00
BOHA
2b3266a1cc feat(documents): accept selected_custom_fields in document schemas
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:08:44 +02:00
BOHA
2f084174df feat(documents): selected-custom-fields encode/decode helpers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:07:35 +02:00
BOHA
5551786da2 feat(documents): add selected_custom_fields column to quotations/issued_orders/invoices
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:06:10 +02:00
BOHA
09f8dc63bf docs(plan): per-document custom-field print selection implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:52:03 +02:00
BOHA
8dec785d13 docs(spec): per-document custom-field print selection (offers/issued orders/invoices)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:44:32 +02:00
BOHA
4674ff3389 fix(dashboard): equal-height cards — stretch + fill instead of ragged
The dashboard content grids used alignItems:"start", so cards in a row sized to
their own content (ragged bottoms). Switch to alignItems:"stretch" so each row
shares one height, and make the wrapped cards actually fill the cell:

- DashProfile / DashSessions: motion-div + Card height:100% (the inner card was
  shorter than its stretched cell).
- Right column (Aktivní projekty + Nabídky): cards flex:1 so the stacked pair
  fills the column to match the neighbouring single cards.

CSS-only; no logic change. tsc clean, lint 0, 669 tests pass. Verified live via
Playwright (3-col row cells equal at 607; bottom 2-col cards equal at 257).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:34:17 +02:00
BOHA
4bb7de7247 feat(app): detect new deploy and prompt refresh (+ lazy-chunk recovery)
When a new version is deployed, open tabs no longer silently run stale code
until a manual F5.

- Server stamps every response with X-App-Version (src/middleware/version.ts,
  onSend hook in server.ts).
- Client bakes its build version via Vite define (__APP_VERSION__); apiFetch
  compares the header to it (no polling — piggybacks existing traffic) and
  latches a mismatch in a small store (appVersion.ts).
- UpdateBanner: a persistent bottom Snackbar "Je k dispozici nová verze
  aplikace. — Obnovit" → location.reload(). User-initiated, so an in-progress
  form is never interrupted.
- lazyWithReload: every React.lazy page reloads once if its hashed chunk 404s
  after a deploy (sessionStorage-guarded against loops), fixing the "old chunk
  gone" error screen.

Approach chosen after researching current SPA practice (version header + banner
beats polling / a service worker for a non-PWA admin app).

Tests: +4 (server header, client store latch/notify). Full suite 669 pass,
tsc -b clean, lint 0. Verified live via Playwright (header emitted; banner
appears on a simulated mismatch). Detection applies to deploys from the NEXT
release onward (current clients lack the detection code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:19:46 +02:00
42 changed files with 1928 additions and 82 deletions

View File

@@ -46,6 +46,17 @@ without explicit user confirmation.**
errors from prior pids). errors from prior pids).
9. Clean up tarballs locally and in `/tmp/` on the server. 9. Clean up tarballs locally and in `/tmp/` on the server.
**nginx is NOT part of a normal release.** nginx is only a reverse proxy in
front of the pm2 app (`proxy_pass` → `127.0.0.1:3001`); a code release changes
app code, which `pm2 restart app-ts` picks up — nginx has nothing new to read.
Do **not** run `sudo nginx -t && sudo systemctl reload nginx` every deploy. Run
it ONLY when you actually edit nginx config (`/etc/nginx/…` — `server_name`,
ports, `proxy_pass` upstream, TLS cert paths, locations/redirects/headers,
`client_max_body_size`, rate limits, etc.). When you do change it, always
`nginx -t` first (validates syntax, changes nothing) THEN `systemctl reload
nginx` (graceful re-read, keeps active connections; a bad config on reload is
rejected and the old one keeps running).
Risky releases (framework jumps, FK/constraint-altering migrations) get a Risky releases (framework jumps, FK/constraint-altering migrations) get a
read-only pre-flight first: `prisma migrate status` on prod, verify FK read-only pre-flight first: `prisma migrate status` on prod, verify FK
constraint names the migration DROPs, check no data violates new constraint names the migration DROPs, check no data violates new

View File

@@ -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.

View File

@@ -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
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.36", "version": "2.4.40",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.4.36", "version": "2.4.40",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.102.0", "@anthropic-ai/sdk": "^0.102.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.36", "version": "2.4.40",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

View File

@@ -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;

View File

@@ -239,6 +239,7 @@ model invoices {
billing_text String? @db.VarChar(500) billing_text String? @db.VarChar(500)
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
internal_notes String? @db.Text internal_notes String? @db.Text
selected_custom_fields String? @db.VarChar(255)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
invoice_items invoice_items[] invoice_items invoice_items[]
@@ -393,6 +394,7 @@ model issued_orders {
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
order_text String? @db.VarChar(500) order_text String? @db.VarChar(500)
internal_notes String? @db.Text internal_notes String? @db.Text
selected_custom_fields String? @db.VarChar(255)
locked_by Int? locked_by Int?
locked_at DateTime? @db.DateTime(0) locked_at DateTime? @db.DateTime(0)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
@@ -527,6 +529,7 @@ model quotations {
status String @default("active") @db.VarChar(20) status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500) scope_title String? @db.VarChar(500)
scope_description String? @db.Text scope_description String? @db.Text
selected_custom_fields String? @db.VarChar(255)
locked_by Int? locked_by Int?
locked_at DateTime? @db.DateTime(0) locked_at DateTime? @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
reportServerVersion,
subscribeNewVersion,
getNewVersion,
BUILD_VERSION,
__resetVersionStateForTest,
} from "../admin/utils/appVersion";
/**
* Client store backing the "new version available" banner. The server stamps
* X-App-Version on every response; apiFetch feeds it here. A version that
* differs from the one baked into THIS bundle (BUILD_VERSION) means a deploy
* happened → latch it and notify subscribers (the banner).
*/
describe("appVersion store", () => {
beforeEach(() => __resetVersionStateForTest());
it("ignores the same version, empty, or null", () => {
reportServerVersion(BUILD_VERSION);
reportServerVersion("");
reportServerVersion(null);
reportServerVersion(undefined);
expect(getNewVersion()).toBeNull();
});
it("latches + notifies once when the server reports a different version", () => {
let notified = 0;
const unsub = subscribeNewVersion(() => {
notified++;
});
reportServerVersion(`${BUILD_VERSION}-next`);
expect(getNewVersion()).toBe(`${BUILD_VERSION}-next`);
expect(notified).toBe(1);
// Idempotent: once a new version is known, further reports don't re-notify
// or overwrite (the banner is already showing).
reportServerVersion("99.0.0");
expect(getNewVersion()).toBe(`${BUILD_VERSION}-next`);
expect(notified).toBe(1);
unsub();
});
});

View File

@@ -87,7 +87,9 @@ describe("issued-order drafts — deferred numbering", () => {
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => { it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
const expected = (await previewIssuedOrderNumber()).number; const expected = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({}); const draft = await createIssuedOrder({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
if ("error" in draft) if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`); throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id); issuedOrderIds.push(draft.id);
@@ -104,7 +106,9 @@ describe("issued-order drafts — deferred numbering", () => {
}); });
it("finalizing is idempotent — re-finalizing does not re-number", async () => { it("finalizing is idempotent — re-finalizing does not re-number", async () => {
const draft = await createIssuedOrder({}); const draft = await createIssuedOrder({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
if ("error" in draft) if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`); throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id); issuedOrderIds.push(draft.id);

View File

@@ -169,7 +169,10 @@ describe("createIssuedOrder", () => {
it("numbers immediately when created already-finalized (status sent)", async () => { it("numbers immediately when created already-finalized (status sent)", async () => {
const before = (await previewIssuedOrderNumber()).number; const before = (await previewIssuedOrderNumber()).number;
const order = await mkIssued({ status: "sent" }); const order = await mkIssued({
status: "sent",
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
expect(order.po_number).toBe(before); expect(order.po_number).toBe(before);
expect(order.status).toBe("sent"); expect(order.status).toBe("sent");
}); });
@@ -199,7 +202,9 @@ describe("createIssuedOrder", () => {
describe("updateIssuedOrder status transitions", () => { describe("updateIssuedOrder status transitions", () => {
it("allows draft -> sent and rejects draft -> completed", async () => { it("allows draft -> sent and rejects draft -> completed", async () => {
const order = await mkIssued({}); const order = await mkIssued({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
const ok = await updateIssuedOrder(order.id, { status: "sent" }); const ok = await updateIssuedOrder(order.id, { status: "sent" });
expect("error" in ok).toBe(false); expect("error" in ok).toBe(false);
const bad = await updateIssuedOrder(order.id, { status: "completed" }); const bad = await updateIssuedOrder(order.id, { status: "completed" });
@@ -226,7 +231,9 @@ describe("updateIssuedOrder status transitions", () => {
}); });
it("status-only transition payloads still work when not editable", async () => { it("status-only transition payloads still work when not editable", async () => {
const order = await mkIssued({}); const order = await mkIssued({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" }); await updateIssuedOrder(order.id, { status: "confirmed" });
const res = await updateIssuedOrder(order.id, { status: "completed" }); const res = await updateIssuedOrder(order.id, { status: "completed" });
@@ -242,6 +249,44 @@ describe("updateIssuedOrder status transitions", () => {
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 }); const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
expect("error" in res && res.error).toBe("supplier_not_found"); expect("error" in res && res.error).toBe("supplier_not_found");
}); });
it("finalizes a sections-only order (no items) successfully", async () => {
const order = await mkIssued({});
const res = await updateIssuedOrder(order.id, {
status: "sent",
items: [],
sections: [
{ title: "Scope", title_cz: "Rozsah prací", content: "<p>Detail</p>" },
],
});
expect("error" in res).toBe(false);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.status).toBe("sent");
expect(row!.po_number).toBeTruthy();
});
it("rejects finalizing a completely blank order (no items, no sections)", async () => {
const order = await mkIssued({});
const res = await updateIssuedOrder(order.id, {
status: "sent",
items: [],
sections: [],
});
expect("error" in res && res.error).toBe("empty_document");
// Stays a draft — the finalize was rejected before any write.
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.status).toBe("draft");
expect(row!.po_number).toBeNull();
});
it("rejects creating a non-draft order with no content", async () => {
const res = await createIssuedOrder({ status: "sent" });
expect("error" in res && res.error).toBe("empty_document");
});
}); });
describe("getIssuedOrder", () => { describe("getIssuedOrder", () => {
@@ -525,7 +570,9 @@ describe("POST /api/admin/issued-orders legacy dropped fields", () => {
describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => { describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
it("400s a field edit on a confirmed order with the explicit Czech message", async () => { it("400s a field edit on a confirmed order with the explicit Czech message", async () => {
const order = await mkIssued({}); const order = await mkIssued({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" }); await updateIssuedOrder(order.id, { status: "confirmed" });
@@ -543,7 +590,9 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
// The IssuedOrderDetail transition flushes unsaved edits by sending the // The IssuedOrderDetail transition flushes unsaved edits by sending the
// full payload + status in ONE PUT while the order is still editable // full payload + status in ONE PUT while the order is still editable
// (sent). The server must apply the items AND the transition together. // (sent). The server must apply the items AND the transition together.
const order = await mkIssued({}); const order = await mkIssued({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "sent" });
const res = await app!.inject({ const res = await app!.inject({
@@ -574,7 +623,9 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
}); });
it("a status-only transition payload keeps working (confirmed -> completed)", async () => { it("a status-only transition payload keeps working (confirmed -> completed)", async () => {
const order = await mkIssued({}); const order = await mkIssued({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" }); await updateIssuedOrder(order.id, { status: "confirmed" });
@@ -733,6 +784,23 @@ describe("renderIssuedOrderHtml", () => {
expect(html).toContain("Odběratel"); expect(html).toContain("Odběratel");
}); });
it("omits the items table and total when there are no items (sections-only order)", () => {
const html = renderIssuedOrderHtml(
order,
[],
supplier,
{ company_name: "Naše firma" },
"cs",
issuer,
[{ title: "Scope", title_cz: "Rozsah prací", content: "<p>Detail</p>" }],
);
// The section renders…
expect(html).toContain("Rozsah prací");
// …but the items table, the billing heading and the total row do not.
expect(html).not.toContain('class="items"');
expect(html).not.toContain("Celkem bez DPH");
});
it("renders the structured supplier address plus IČO and DIČ", () => { it("renders the structured supplier address plus IČO and DIČ", () => {
const html = renderIssuedOrderHtml( const html = renderIssuedOrderHtml(
order, order,

View 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");
});
});

View File

@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import Fastify from "fastify";
import { registerVersionHeader, readAppVersion } from "../middleware/version";
/**
* The server stamps every response with X-App-Version so the SPA can detect a
* deploy (the client compares it to the version baked into its bundle).
*/
describe("registerVersionHeader", () => {
it("adds X-App-Version to responses", async () => {
const app = Fastify({ logger: false });
registerVersionHeader(app, "9.9.9");
app.get("/ping", async () => ({ ok: true }));
const res = await app.inject({ method: "GET", url: "/ping" });
expect(res.headers["x-app-version"]).toBe("9.9.9");
await app.close();
});
it("defaults to the package.json version (semver string)", () => {
expect(readAppVersion()).toMatch(/^\d+\.\d+\.\d+/);
});
});

View File

@@ -7,65 +7,97 @@ import { queryClient } from "./lib/queryClient";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import AppShell from "./ui/AppShell"; import AppShell from "./ui/AppShell";
import AlertContainer from "./components/AlertContainer"; import AlertContainer from "./components/AlertContainer";
import UpdateBanner from "./components/UpdateBanner";
import { lazyWithReload } from "./utils/lazyWithReload";
import MuiProvider from "./ui/MuiProvider"; import MuiProvider from "./ui/MuiProvider";
import { LoadingState } from "./ui"; import { LoadingState } from "./ui";
import Login from "./pages/Login"; import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
const Odin = lazy(() => import("./pages/Odin")); const Odin = lazyWithReload(() => import("./pages/Odin"));
const Users = lazy(() => import("./pages/Users")); const Users = lazyWithReload(() => import("./pages/Users"));
const Attendance = lazy(() => import("./pages/Attendance")); const Attendance = lazyWithReload(() => import("./pages/Attendance"));
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory")); const AttendanceHistory = lazyWithReload(
const AttendanceAdmin = lazy(() => import("./pages/AttendanceAdmin")); () => import("./pages/AttendanceHistory"),
const AttendanceBalances = lazy(() => import("./pages/AttendanceBalances")); );
const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate")); const AttendanceAdmin = lazyWithReload(() => import("./pages/AttendanceAdmin"));
const LeaveRequests = lazy(() => import("./pages/LeaveRequests")); const AttendanceBalances = lazyWithReload(
const LeaveApproval = lazy(() => import("./pages/LeaveApproval")); () => import("./pages/AttendanceBalances"),
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation")); );
const PlanWork = lazy(() => import("./pages/PlanWork")); const AttendanceCreate = lazyWithReload(
const Trips = lazy(() => import("./pages/Trips")); () => import("./pages/AttendanceCreate"),
const TripsHistory = lazy(() => import("./pages/TripsHistory")); );
const TripsAdmin = lazy(() => import("./pages/TripsAdmin")); const LeaveRequests = lazyWithReload(() => import("./pages/LeaveRequests"));
const Vehicles = lazy(() => import("./pages/Vehicles")); const LeaveApproval = lazyWithReload(() => import("./pages/LeaveApproval"));
const Offers = lazy(() => import("./pages/Offers")); const AttendanceLocation = lazyWithReload(
const OfferDetail = lazy(() => import("./pages/OfferDetail")); () => import("./pages/AttendanceLocation"),
const OffersCustomers = lazy(() => import("./pages/OffersCustomers")); );
const OffersTemplates = lazy(() => import("./pages/OffersTemplates")); const PlanWork = lazyWithReload(() => import("./pages/PlanWork"));
const Orders = lazy(() => import("./pages/Orders")); const Trips = lazyWithReload(() => import("./pages/Trips"));
const OrderDetail = lazy(() => import("./pages/OrderDetail")); const TripsHistory = lazyWithReload(() => import("./pages/TripsHistory"));
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail")); const TripsAdmin = lazyWithReload(() => import("./pages/TripsAdmin"));
const Projects = lazy(() => import("./pages/Projects")); const Vehicles = lazyWithReload(() => import("./pages/Vehicles"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail")); const Offers = lazyWithReload(() => import("./pages/Offers"));
const Invoices = lazy(() => import("./pages/Invoices")); const OfferDetail = lazyWithReload(() => import("./pages/OfferDetail"));
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail")); const OffersCustomers = lazyWithReload(() => import("./pages/OffersCustomers"));
const Settings = lazy(() => import("./pages/Settings")); const OffersTemplates = lazyWithReload(() => import("./pages/OffersTemplates"));
const AuditLog = lazy(() => import("./pages/AuditLog")); const Orders = lazyWithReload(() => import("./pages/Orders"));
const NotFound = lazy(() => import("./pages/NotFound")); const OrderDetail = lazyWithReload(() => import("./pages/OrderDetail"));
const Warehouse = lazy(() => import("./pages/Warehouse")); const IssuedOrderDetail = lazyWithReload(
const WarehouseItems = lazy(() => import("./pages/WarehouseItems")); () => import("./pages/IssuedOrderDetail"),
const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail")); );
const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts")); const Projects = lazyWithReload(() => import("./pages/Projects"));
const WarehouseReceiptDetail = lazy( const ProjectDetail = lazyWithReload(() => import("./pages/ProjectDetail"));
const Invoices = lazyWithReload(() => import("./pages/Invoices"));
const InvoiceDetail = lazyWithReload(() => import("./pages/InvoiceDetail"));
const Settings = lazyWithReload(() => import("./pages/Settings"));
const AuditLog = lazyWithReload(() => import("./pages/AuditLog"));
const NotFound = lazyWithReload(() => import("./pages/NotFound"));
const Warehouse = lazyWithReload(() => import("./pages/Warehouse"));
const WarehouseItems = lazyWithReload(() => import("./pages/WarehouseItems"));
const WarehouseItemDetail = lazyWithReload(
() => import("./pages/WarehouseItemDetail"),
);
const WarehouseReceipts = lazyWithReload(
() => import("./pages/WarehouseReceipts"),
);
const WarehouseReceiptDetail = lazyWithReload(
() => import("./pages/WarehouseReceiptDetail"), () => import("./pages/WarehouseReceiptDetail"),
); );
const WarehouseReceiptForm = lazy(() => import("./pages/WarehouseReceiptForm")); const WarehouseReceiptForm = lazyWithReload(
const WarehouseIssues = lazy(() => import("./pages/WarehouseIssues")); () => import("./pages/WarehouseReceiptForm"),
const WarehouseIssueDetail = lazy(() => import("./pages/WarehouseIssueDetail")); );
const WarehouseIssueForm = lazy(() => import("./pages/WarehouseIssueForm")); const WarehouseIssues = lazyWithReload(() => import("./pages/WarehouseIssues"));
const WarehouseReservations = lazy( const WarehouseIssueDetail = lazyWithReload(
() => import("./pages/WarehouseIssueDetail"),
);
const WarehouseIssueForm = lazyWithReload(
() => import("./pages/WarehouseIssueForm"),
);
const WarehouseReservations = lazyWithReload(
() => import("./pages/WarehouseReservations"), () => import("./pages/WarehouseReservations"),
); );
const WarehouseInventory = lazy(() => import("./pages/WarehouseInventory")); const WarehouseInventory = lazyWithReload(
const WarehouseInventoryDetail = lazy( () => import("./pages/WarehouseInventory"),
);
const WarehouseInventoryDetail = lazyWithReload(
() => import("./pages/WarehouseInventoryDetail"), () => import("./pages/WarehouseInventoryDetail"),
); );
const WarehouseInventoryForm = lazy( const WarehouseInventoryForm = lazyWithReload(
() => import("./pages/WarehouseInventoryForm"), () => import("./pages/WarehouseInventoryForm"),
); );
const WarehouseReports = lazy(() => import("./pages/WarehouseReports")); const WarehouseReports = lazyWithReload(
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers")); () => import("./pages/WarehouseReports"),
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations")); );
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories")); const WarehouseSuppliers = lazyWithReload(
() => import("./pages/WarehouseSuppliers"),
);
const WarehouseLocations = lazyWithReload(
() => import("./pages/WarehouseLocations"),
);
const WarehouseCategories = lazyWithReload(
() => import("./pages/WarehouseCategories"),
);
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null; const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
export default function AdminApp() { export default function AdminApp() {
@@ -75,6 +107,7 @@ export default function AdminApp() {
<AlertProvider> <AlertProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AlertContainer /> <AlertContainer />
<UpdateBanner />
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<LoadingState />}> <Suspense fallback={<LoadingState />}>
<Routes> <Routes>

View File

@@ -0,0 +1,35 @@
import { useSyncExternalStore } from "react";
import Snackbar from "@mui/material/Snackbar";
import Button from "@mui/material/Button";
import { subscribeNewVersion, getNewVersion } from "../utils/appVersion";
/**
* Shows a persistent banner when the server reports a newer build than the one
* this tab is running (detected via the X-App-Version header in apiFetch). The
* reload is user-initiated — we never force it, so an in-progress form is safe.
*/
export default function UpdateBanner() {
const newVersion = useSyncExternalStore(
subscribeNewVersion,
getNewVersion,
() => null,
);
return (
<Snackbar
open={Boolean(newVersion)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
message="Je k dispozici nová verze aplikace."
action={
<Button
color="primary"
size="small"
variant="contained"
onClick={() => window.location.reload()}
>
Obnovit
</Button>
}
/>
);
}

View File

@@ -262,8 +262,9 @@ export default function DashProfile({
initial={reduce ? false : { opacity: 0, y: 12 }} initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }} animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }} transition={{ duration: 0.25, delay: 0.15 }}
style={{ height: "100%" }}
> >
<Card> <Card sx={{ height: "100%" }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",

View File

@@ -132,8 +132,9 @@ export default function DashSessions() {
initial={reduce ? false : { opacity: 0, y: 12 }} initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }} animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }} transition={{ duration: 0.25, delay: 0.15 }}
style={{ height: "100%" }}
> >
<Card sx={{ display: "flex", flexDirection: "column" }}> <Card sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",

View 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>
);
}

View File

@@ -492,6 +492,12 @@ interface DocumentItemsEditorProps {
itemDescriptionMaxLength?: number; itemDescriptionMaxLength?: number;
/** Optional page-specific control (e.g. item-template Select) next to the add button. */ /** Optional page-specific control (e.g. item-template Select) next to the add button. */
templatesSlot?: ReactNode; templatesSlot?: ReactNode;
/**
* Allow removing every row so the list can be empty (issued orders may be
* issued by sections alone). Default false keeps the offers/invoices rule of
* at least one item.
*/
allowEmpty?: boolean;
} }
/** /**
@@ -509,6 +515,7 @@ export default function DocumentItemsEditor({
showDiscount = false, showDiscount = false,
itemDescriptionMaxLength = 5000, itemDescriptionMaxLength = 5000,
templatesSlot, templatesSlot,
allowEmpty = false,
}: DocumentItemsEditorProps) { }: DocumentItemsEditorProps) {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
@@ -538,10 +545,13 @@ export default function DocumentItemsEditor({
const addItem = () => onChange([...items, emptyDocumentItem()]); const addItem = () => onChange([...items, emptyDocumentItem()]);
const removeItem = (index: number) => { const removeItem = (index: number) => {
if (items.length <= 1) return; if (!allowEmpty && items.length <= 1) return;
onChange(items.filter((_, i) => i !== index)); onChange(items.filter((_, i) => i !== index));
}; };
// When allowEmpty, even the last row is deletable (list may go to zero).
const canDeleteRow = allowEmpty || items.length > 1;
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return;
@@ -608,7 +618,7 @@ export default function DocumentItemsEditor({
index={index} index={index}
currency={currency} currency={currency}
readOnly={readOnly} readOnly={readOnly}
canDelete={items.length > 1} canDelete={canDeleteRow}
showIncludedInTotal={showIncludedInTotal} showIncludedInTotal={showIncludedInTotal}
showDiscount={showDiscount} showDiscount={showDiscount}
itemDescriptionMaxLength={itemDescriptionMaxLength} itemDescriptionMaxLength={itemDescriptionMaxLength}
@@ -666,7 +676,7 @@ export default function DocumentItemsEditor({
index={index} index={index}
currency={currency} currency={currency}
readOnly={readOnly} readOnly={readOnly}
canDelete={items.length > 1} canDelete={canDeleteRow}
showIncludedInTotal={showIncludedInTotal} showIncludedInTotal={showIncludedInTotal}
showDiscount={showDiscount} showDiscount={showDiscount}
itemDescriptionMaxLength={itemDescriptionMaxLength} itemDescriptionMaxLength={itemDescriptionMaxLength}

View File

@@ -85,6 +85,7 @@ export interface InvoiceDetail {
bank_account_id?: number | null; bank_account_id?: number | null;
items?: InvoiceItem[]; items?: InvoiceItem[];
sections?: InvoiceSection[]; sections?: InvoiceSection[];
selected_custom_fields?: number[];
subtotal: number; subtotal: number;
vat_amount: number; vat_amount: number;
total: number; total: number;

View File

@@ -60,6 +60,7 @@ export interface IssuedOrderDetail extends IssuedOrder {
sections: IssuedOrderSection[]; sections: IssuedOrderSection[];
supplier: Record<string, unknown> | null; supplier: Record<string, unknown> | null;
valid_transitions: string[]; valid_transitions: string[];
selected_custom_fields: number[];
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */ /** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
locked_by: { locked_by: {
user_id: number; user_id: number;

View File

@@ -184,6 +184,7 @@ export interface OfferDetailData {
locked_by: OfferLockInfo | null; locked_by: OfferLockInfo | null;
/** Legal next statuses, computed server-side (same contract as issued orders). */ /** Legal next statuses, computed server-side (same contract as issued orders). */
valid_transitions: string[]; valid_transitions: string[];
selected_custom_fields: number[];
} }
export const offerDetailOptions = (id: string | undefined) => export const offerDetailOptions = (id: string | undefined) =>

View File

@@ -348,7 +348,9 @@ export default function Dashboard() {
display: "grid", display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" }, gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
gap: 3, gap: 3,
alignItems: "start", // stretch (not start) so the cards in a row share one height —
// even bottoms instead of ragged content-height cards.
alignItems: "stretch",
mb: 3, mb: 3,
}} }}
> >
@@ -360,10 +362,18 @@ export default function Dashboard() {
<DashAttendanceToday attendance={dashData?.attendance ?? null} /> <DashAttendanceToday attendance={dashData?.attendance ?? null} />
)} )}
{/* Pravy sloupec: projekty + nabidky */} {/* Pravy sloupec: projekty + nabidky — both grow so the stacked
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}> cards fill the column to match the neighbouring cards' height. */}
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
height: "100%",
}}
>
{dashData?.projects && ( {dashData?.projects && (
<Card sx={{ display: "flex", flexDirection: "column" }}> <Card sx={{ display: "flex", flexDirection: "column", flex: 1 }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -421,7 +431,7 @@ export default function Dashboard() {
)} )}
{dashData?.offers && ( {dashData?.offers && (
<Card sx={{ display: "flex", flexDirection: "column" }}> <Card sx={{ display: "flex", flexDirection: "column", flex: 1 }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
@@ -498,7 +508,7 @@ export default function Dashboard() {
display: "grid", display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" }, gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" },
gap: 3, gap: 3,
alignItems: "start", alignItems: "stretch",
}} }}
> >
<DashProfile <DashProfile

View File

@@ -27,6 +27,7 @@ import RichEditor from "../components/RichEditor";
import SectionsEditor, { import SectionsEditor, {
type DocumentSection, type DocumentSection,
} from "../components/document/SectionsEditor"; } from "../components/document/SectionsEditor";
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -611,6 +612,9 @@ export default function InvoiceDetail() {
// Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF — // Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF —
// shared editor with offers/issued orders). // shared editor with offers/issued orders).
const [sections, setSections] = useState<DocumentSection[]>([]); const [sections, setSections] = useState<DocumentSection[]>([]);
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [items, setItems] = useState<InvoiceItem[]>(() => [ const [items, setItems] = useState<InvoiceItem[]>(() => [
{ {
_key: "inv-1", _key: "inv-1",
@@ -821,11 +825,15 @@ export default function InvoiceDetail() {
: []; : [];
setSections(mappedSections); setSections(mappedSections);
const mappedCustomFields = inv.selected_custom_fields ?? [];
setSelectedCustomFields(mappedCustomFields);
// Capture initial snapshot for dirty-checking // Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({ initialSnapshotRef.current = JSON.stringify({
form: formData, form: formData,
items: mappedItems, items: mappedItems,
sections: mappedSections, sections: mappedSections,
selectedCustomFields: mappedCustomFields,
}); });
setDataReady(true); setDataReady(true);
@@ -923,15 +931,21 @@ export default function InvoiceDetail() {
// Edit mode: captured inside the sync effect from raw query data. // Edit mode: captured inside the sync effect from raw query data.
// Create mode: captured on the first render after sync effects populate the form. // Create mode: captured on the first render after sync effects populate the form.
if (dataReady && !initialSnapshotRef.current) { if (dataReady && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections }); initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
selectedCustomFields,
});
} }
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false; if (!initialSnapshotRef.current) return false;
return ( return (
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current JSON.stringify({ form, items, sections, selectedCustomFields }) !==
initialSnapshotRef.current
); );
}, [form, items, sections]); }, [form, items, sections, selectedCustomFields]);
useEffect(() => { useEffect(() => {
if (!isDirty) return; if (!isDirty) return;
@@ -1095,6 +1109,7 @@ export default function InvoiceDetail() {
content: s.content, content: s.content,
position: i, position: i,
})), })),
selected_custom_fields: selectedCustomFields,
}; };
// Only set status when a target is given (create-as-draft / create-as-live // 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 // / 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); const data = await saveMutation.mutateAsync(payload);
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"); 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) { if (!isEdit) {
navigate(`/invoices/${data.invoice_id}`); navigate(`/invoices/${data.invoice_id}`);
} }
@@ -2209,6 +2229,15 @@ export default function InvoiceDetail() {
]} ]}
/> />
</Field> </Field>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={false}
onChange={setSelectedCustomFields}
/>
</Box>
</Card> </Card>
{/* Items */} {/* Items */}

View File

@@ -23,6 +23,7 @@ import SectionsEditor, {
type DocumentSection, type DocumentSection,
} from "../components/document/SectionsEditor"; } from "../components/document/SectionsEditor";
import LockBanner from "../components/document/LockBanner"; import LockBanner from "../components/document/LockBanner";
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import { useDocumentLock } from "../hooks/useDocumentLock"; import { useDocumentLock } from "../hooks/useDocumentLock";
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard"; import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
import { useDocumentPdf } from "../hooks/useDocumentPdf"; import { useDocumentPdf } from "../hooks/useDocumentPdf";
@@ -137,6 +138,7 @@ interface IssuedOrderSavePayload {
internal_notes: string; internal_notes: string;
items: IssuedOrderItemPayload[]; items: IssuedOrderItemPayload[];
sections: IssuedOrderSectionPayload[]; sections: IssuedOrderSectionPayload[];
selected_custom_fields: number[];
status?: string; status?: string;
} }
@@ -164,6 +166,9 @@ export default function IssuedOrderDetail() {
emptyDocumentItem(), emptyDocumentItem(),
]); ]);
const [sections, setSections] = useState<DocumentSection[]>([]); const [sections, setSections] = useState<DocumentSection[]>([]);
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Which save action is in flight ("draft" | the live status | "save"), so // 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 canExport = hasPermission("orders.view");
const { isDirty, markClean } = useUnsavedChangesGuard( const { isDirty, markClean } = useUnsavedChangesGuard(
{ form, items, sections }, { form, items, sections, selectedCustomFields },
!isEdit || dataReady, !isEdit || dataReady,
); );
@@ -296,7 +301,9 @@ export default function IssuedOrderDetail() {
// Issued orders have no Sleva column; keep the shared item shape happy. // Issued orders have no Sleva column; keep the shared item shape happy.
discount: 0, discount: 0,
})) }))
: [emptyDocumentItem()]; : // Issued orders may be issued by sections alone — a saved order with
// no items reopens with an empty list (not a blank starter row).
[];
setItems(mappedItems); setItems(mappedItems);
const mappedSections = const mappedSections =
@@ -308,6 +315,7 @@ export default function IssuedOrderDetail() {
})) }))
: []; : [];
setSections(mappedSections); setSections(mappedSections);
setSelectedCustomFields(d.selected_custom_fields ?? []);
setLockedBy(d.locked_by ?? null); setLockedBy(d.locked_by ?? null);
// Acquire the edit lock when nobody holds it, the order is still // Acquire the edit lock when nobody holds it, the order is still
@@ -395,8 +403,18 @@ export default function IssuedOrderDetail() {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele"; if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele";
if (!form.order_date) newErrors.order_date = "Zadejte datum"; if (!form.order_date) newErrors.order_date = "Zadejte datum";
if (items.length === 0 || items.every((i) => !i.description.trim())) { // Items are optional (an order may be issued by sections alone), but a
newErrors.items = "Přidejte alespoň jednu položku"; // completely blank order must not be finalizable: require at least one
// item with a description OR one non-empty section (title or content).
const hasItem = items.some((i) => i.description.trim());
const hasSection = sections.some(
(s) =>
(s.title_cz || "").trim() ||
(s.title || "").trim() ||
(s.content || "").replace(/<[^>]*>/g, "").trim(),
);
if (!hasItem && !hasSection) {
newErrors.items = "Přidejte alespoň jednu položku nebo obsah";
} }
setErrors(newErrors); setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return; if (Object.keys(newErrors).length > 0) return;
@@ -430,6 +448,7 @@ export default function IssuedOrderDetail() {
content: s.content, content: s.content,
position: i, position: i,
})), })),
selected_custom_fields: selectedCustomFields,
}; };
// Only set status when a target is given (create-as-draft / create-as-live // 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 // / finalize). Editing a live order sends no status — the backend keeps
@@ -874,6 +893,15 @@ export default function IssuedOrderDetail() {
placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)" placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)"
/> />
</Field> </Field>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={!editable}
onChange={setSelectedCustomFields}
/>
</Box>
</Card> </Card>
{/* Items */} {/* Items */}
@@ -883,6 +911,7 @@ export default function IssuedOrderDetail() {
currency={form.currency} currency={form.currency}
readOnly={!editable} readOnly={!editable}
error={errors.items} error={errors.items}
allowEmpty
/> />
{/* Rich-text PDF sections — the free-form PDF content lives here */} {/* Rich-text PDF sections — the free-form PDF content lives here */}

View File

@@ -33,6 +33,7 @@ import SectionsEditor, {
type DocumentSection, type DocumentSection,
} from "../components/document/SectionsEditor"; } from "../components/document/SectionsEditor";
import LockBanner from "../components/document/LockBanner"; import LockBanner from "../components/document/LockBanner";
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import { useDocumentLock } from "../hooks/useDocumentLock"; import { useDocumentLock } from "../hooks/useDocumentLock";
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard"; import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
import { useDocumentPdf } from "../hooks/useDocumentPdf"; import { useDocumentPdf } from "../hooks/useDocumentPdf";
@@ -114,6 +115,7 @@ interface OfferSavePayload {
language: string; language: string;
items: OfferItemPayload[]; items: OfferItemPayload[];
sections: OfferSectionPayload[]; sections: OfferSectionPayload[];
selected_custom_fields: number[];
status?: string; status?: string;
} }
@@ -178,6 +180,9 @@ export default function OfferDetail() {
emptyDocumentItem(), emptyDocumentItem(),
]); ]);
const [sections, setSections] = useState<DocumentSection[]>([]); const [sections, setSections] = useState<DocumentSection[]>([]);
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null); const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
const [offerStatus, setOfferStatus] = useState<string>(""); const [offerStatus, setOfferStatus] = useState<string>("");
const [quotationNumber, setQuotationNumber] = useState(""); const [quotationNumber, setQuotationNumber] = useState("");
@@ -270,7 +275,7 @@ export default function OfferDetail() {
const canDelete = isEdit && !orderInfo && !isInvalidated && !isLockedByOther; const canDelete = isEdit && !orderInfo && !isInvalidated && !isLockedByOther;
const { markClean } = useUnsavedChangesGuard( const { markClean } = useUnsavedChangesGuard(
{ form, items, sections }, { form, items, sections, selectedCustomFields },
!loading, !loading,
); );
@@ -318,6 +323,7 @@ export default function OfferDetail() {
})) }))
: []; : [];
setSections(mappedSections); setSections(mappedSections);
setSelectedCustomFields(d.selected_custom_fields ?? []);
markClean({ form: formData, items: mappedItems, sections: mappedSections }); markClean({ form: formData, items: mappedItems, sections: mappedSections });
setOfferStatus(d.status || ""); setOfferStatus(d.status || "");
setOrderInfo(d.order ?? null); setOrderInfo(d.order ?? null);
@@ -445,6 +451,7 @@ export default function OfferDetail() {
content: s.content, content: s.content,
position: i, position: i,
})), })),
selected_custom_fields: selectedCustomFields,
}; };
// Only set status when a target is given (create-as-draft / create-as-live // 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 // / finalize). Editing a live offer sends no status — the backend keeps
@@ -929,6 +936,15 @@ export default function OfferDetail() {
/> />
</Field> </Field>
</Box> </Box>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={readOnly}
onChange={setSelectedCustomFields}
/>
</Box>
</Card> </Card>
{/* Items (drag-and-drop, offers carry the "V ceně" column) */} {/* Items (drag-and-drop, offers carry the "V ceně" column) */}

View File

@@ -1,3 +1,5 @@
import { reportServerVersion } from "./appVersion";
class ApiState { class ApiState {
showSessionExpiredAlert = false; showSessionExpiredAlert = false;
showLogoutAlert = false; showLogoutAlert = false;
@@ -120,6 +122,8 @@ export const apiFetch = async (
headers, headers,
credentials: "include", credentials: "include",
}); });
// Detect a new deploy: compare the server's build version to ours.
reportServerVersion(response.headers.get("X-App-Version"));
if (response.status === 401 && state.refreshFn) { if (response.status === 401 && state.refreshFn) {
// If the caller already unmounted (TanStack Query aborted the request), // If the caller already unmounted (TanStack Query aborted the request),

View File

@@ -0,0 +1,48 @@
/**
* New-deploy detection for the SPA.
*
* The server stamps every response with `X-App-Version`. `apiFetch` feeds that
* header here via `reportServerVersion`. When it differs from the version baked
* into THIS bundle at build time (`BUILD_VERSION`), a deploy has happened — we
* latch it and notify subscribers (the refresh banner). We never force a
* reload; the user picks the moment (they may be mid-form).
*/
// Injected by Vite `define` at build time; falls back to "dev" in tests / when
// running unbuilt. `typeof` guard avoids a ReferenceError when undefined.
declare const __APP_VERSION__: string;
export const BUILD_VERSION: string =
typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "dev";
let newVersion: string | null = null;
const listeners = new Set<() => void>();
/**
* Compare a server-reported version against this bundle's build version. Latches
* the first genuine mismatch and notifies subscribers exactly once.
*/
export function reportServerVersion(
serverVersion: string | null | undefined,
): void {
if (newVersion) return; // already latched — banner is up
if (!serverVersion || serverVersion === BUILD_VERSION) return;
newVersion = serverVersion;
for (const l of listeners) l();
}
export function subscribeNewVersion(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getNewVersion(): string | null {
return newVersion;
}
/** Test-only: reset the module singleton between cases. */
export function __resetVersionStateForTest(): void {
newVersion = null;
listeners.clear();
}

View File

@@ -0,0 +1,35 @@
import { lazy } from "react";
import type { ComponentType } from "react";
const RELOAD_KEY = "spa-chunk-reloaded";
/**
* React.lazy that survives a deploy. If a page's dynamic import fails because
* its hashed chunk was removed by a newer build, reload once to fetch the fresh
* bundle (guarded by sessionStorage so a genuine load failure can't loop). On a
* successful load the guard is cleared so the next deploy can recover too.
*
* The generic mirrors React.lazy's own `ComponentType<any>` constraint — there
* is no non-any equivalent that satisfies it, so the `any` is unavoidable here.
*/
export function lazyWithReload<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends ComponentType<any>,
>(factory: () => Promise<{ default: T }>) {
return lazy(() =>
factory()
.then((mod) => {
sessionStorage.removeItem(RELOAD_KEY);
return mod;
})
.catch((err: unknown) => {
if (!sessionStorage.getItem(RELOAD_KEY)) {
sessionStorage.setItem(RELOAD_KEY, "1");
window.location.reload();
// Never resolves — the reload replaces the document.
return new Promise<{ default: T }>(() => {});
}
throw err;
}),
);
}

33
src/middleware/version.ts Normal file
View File

@@ -0,0 +1,33 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
/**
* The build version (from package.json), read once. A static `import` of the
* root package.json would pull a file outside `src` into the program and expand
* tsc's rootDir, so resolve it at runtime relative to the compiled file.
*/
export function readAppVersion(): string {
try {
const pkg = JSON.parse(
readFileSync(join(__dirname, "../../package.json"), "utf8"),
) as { version?: string };
return pkg.version ?? "0.0.0";
} catch {
return "0.0.0";
}
}
/**
* Stamp every response with `X-App-Version` so the SPA can detect a new deploy
* (the client compares it to the version baked into its own bundle and prompts
* a refresh on mismatch).
*/
export function registerVersionHeader(
app: FastifyInstance,
version: string = readAppVersion(),
): void {
app.addHook("onSend", async (_request, reply) => {
reply.header("X-App-Version", version);
});
}

View File

@@ -8,6 +8,7 @@ import { getRate } from "../../services/exchange-rates";
import { localDateStr } from "../../utils/date"; import { localDateStr } from "../../utils/date";
import { parseId, success } from "../../utils/response"; import { parseId, success } from "../../utils/response";
import { lineNet } from "../../utils/money"; import { lineNet } from "../../utils/money";
import { parseSelectedCustomFields } from "../../utils/custom-fields";
import { import {
formatDate, formatDate,
formatNum, formatNum,
@@ -34,6 +35,7 @@ function buildAddressLines(
entity: Record<string, unknown> | null, entity: Record<string, unknown> | null,
isSupplier: boolean, isSupplier: boolean,
tObj: Record<string, string>, tObj: Record<string, string>,
selectedCustomFields?: number[],
): AddressResult { ): AddressResult {
if (!entity) return { name: "", lines: [] }; if (!entity) return { name: "", lines: [] };
@@ -89,7 +91,9 @@ function buildAddressLines(
fieldMap.company_id = `${tObj.ico}${entity.company_id}`; fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`; if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
const filterCustom = Array.isArray(selectedCustomFields);
cfData.forEach((cf, i) => { cfData.forEach((cf, i) => {
if (filterCustom && !selectedCustomFields!.includes(i)) return;
const cfName = (cf.name || "").trim(); const cfName = (cf.name || "").trim();
const cfValue = (cf.value || "").trim(); const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false; 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 cust = buildAddressLines(customer, false, t);
const suppLinesHtml = supp.lines const suppLinesHtml = supp.lines

View File

@@ -4,6 +4,7 @@ import { requirePermission } from "../../middleware/auth";
import { parseId, success } from "../../utils/response"; import { parseId, success } from "../../utils/response";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { nasOrdersManager } from "../../services/nas-financials-manager"; import { nasOrdersManager } from "../../services/nas-financials-manager";
import { parseSelectedCustomFields } from "../../utils/custom-fields";
import { import {
formatDate, formatDate,
formatNum, formatNum,
@@ -31,6 +32,7 @@ function buildAddressLines(
entity: Record<string, unknown> | null, entity: Record<string, unknown> | null,
isCompany: boolean, isCompany: boolean,
tObj: Record<string, string>, tObj: Record<string, string>,
selectedCustomFields?: number[],
): AddressResult { ): AddressResult {
if (!entity) return { name: "", lines: [] }; if (!entity) return { name: "", lines: [] };
@@ -86,7 +88,9 @@ function buildAddressLines(
fieldMap.company_id = `${tObj.ico}${entity.company_id}`; fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`; if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
const filterCustom = Array.isArray(selectedCustomFields);
cfData.forEach((cf, i) => { cfData.forEach((cf, i) => {
if (filterCustom && !selectedCustomFields!.includes(i)) return;
const cfName = (cf.name || "").trim(); const cfName = (cf.name || "").trim();
const cfValue = (cf.value || "").trim(); const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false; const showLabel = cf.showLabel !== false;
@@ -313,7 +317,14 @@ export function renderIssuedOrderHtml(
// PO direction: our company (settings) = Odběratel (buyer); // PO direction: our company (settings) = Odběratel (buyer);
// the sklad_suppliers record = Dodavatel (supplier). // 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 supplierAddr = buildSupplierLines(supplier, t); // supplier → Dodavatel
const buyerLinesHtml = buyer.lines const buyerLinesHtml = buyer.lines
@@ -741,8 +752,11 @@ ${indentCSS}
</tr> </tr>
</table> </table>
<!-- Polozky --> <!-- Polozky — an order may be issued by sections alone; with no items the
<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div> heading, items table and total row are omitted entirely. -->
${
items.length > 0
? `<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
<table class="items"> <table class="items">
<thead> <thead>
<tr> <tr>
@@ -768,7 +782,9 @@ ${indentCSS}
<span class="value">${formatCurrency(total, currency)}</span> <span class="value">${formatCurrency(total, currency)}</span>
</div> </div>
</div> </div>
</div> </div>`
: ""
}
${scopeHtml} ${scopeHtml}

View File

@@ -366,6 +366,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
return error(reply, "Dodavatel nenalezen", 400); return error(reply, "Dodavatel nenalezen", 400);
if (order.error === "po_number_taken") if (order.error === "po_number_taken")
return error(reply, "Číslo objednávky je již použito", 409); return error(reply, "Číslo objednávky je již použito", 409);
if (order.error === "empty_document")
return error(reply, "Přidejte alespoň jednu položku nebo obsah", 400);
return error(reply, "Neznámá chyba", 500); return error(reply, "Neznámá chyba", 500);
} }
await logAudit({ await logAudit({
@@ -408,6 +410,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`, `Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
400, 400,
); );
if (result.error === "empty_document")
return error(reply, "Přidejte alespoň jednu položku nebo obsah", 400);
return error(reply, "Neznámá chyba", 500); return error(reply, "Neznámá chyba", 500);
} }
await logAudit({ await logAudit({

View File

@@ -6,6 +6,7 @@ import { nasOffersManager } from "../../services/nas-offers-manager";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId, success } from "../../utils/response"; import { parseId, success } from "../../utils/response";
import { lineNet } from "../../utils/money"; import { lineNet } from "../../utils/money";
import { parseSelectedCustomFields } from "../../utils/custom-fields";
import { import {
formatDate, formatDate,
formatNum, formatNum,
@@ -29,6 +30,7 @@ function buildAddressLines(
entity: Record<string, unknown> | null, entity: Record<string, unknown> | null,
isSupplier: boolean, isSupplier: boolean,
t: (key: string) => string, t: (key: string) => string,
selectedCustomFields?: number[],
): AddressResult { ): AddressResult {
if (!entity) return { name: "", lines: [] }; if (!entity) return { name: "", lines: [] };
@@ -85,7 +87,9 @@ function buildAddressLines(
fieldMap.company_id = `${t("ico")}: ${entity.company_id}`; fieldMap.company_id = `${t("ico")}: ${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${t("dic")}: ${entity.vat_id}`; if (entity.vat_id) fieldMap.vat_id = `${t("dic")}: ${entity.vat_id}`;
const filterCustom = Array.isArray(selectedCustomFields);
cfData.forEach((cf, i) => { cfData.forEach((cf, i) => {
if (filterCustom && !selectedCustomFields!.includes(i)) return;
const cfName = (cf.name || "").trim(); const cfName = (cf.name || "").trim();
const cfValue = (cf.value || "").trim(); const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false; const showLabel = cf.showLabel !== false;
@@ -210,6 +214,10 @@ export function renderOfferHtml(
settings as unknown as Record<string, unknown>, settings as unknown as Record<string, unknown>,
true, true,
t, t,
parseSelectedCustomFields(
(quotation as { selected_custom_fields?: unknown })
.selected_custom_fields,
),
); );
const custLinesHtml = cust.lines const custLinesHtml = cust.lines

View File

@@ -50,6 +50,11 @@ export const CreateInvoiceSchema = z.object({
// Rich-text CZ/EN "Obsah" sections rendered after the items on the PDF // Rich-text CZ/EN "Obsah" sections rendered after the items on the PDF
// (mirrors issued orders — shared DocumentSectionSchema, DB-aligned limits). // (mirrors issued orders — shared DocumentSectionSchema, DB-aligned limits).
sections: z.array(DocumentSectionSchema).optional(), 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({ export const UpdateInvoiceSchema = z.object({
@@ -74,6 +79,10 @@ export const UpdateInvoiceSchema = z.object({
paid_date: nullableIsoDateString, paid_date: nullableIsoDateString,
items: z.array(InvoiceItemSchema).optional(), items: z.array(InvoiceItemSchema).optional(),
sections: z.array(DocumentSectionSchema).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>; export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;

View File

@@ -41,6 +41,12 @@ export const CreateIssuedOrderSchema = z.object({
// Rich-text CZ/EN sections rendered on their own PDF page (mirrors offers' // Rich-text CZ/EN sections rendered on their own PDF page (mirrors offers'
// scope_sections — shared DocumentSectionSchema, DB-aligned limits). // scope_sections — shared DocumentSectionSchema, DB-aligned limits).
sections: z.array(DocumentSectionSchema).optional(), 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 // Update = partial create MINUS the number: PO numbers are immutable

View File

@@ -48,6 +48,12 @@ export const CreateQuotationSchema = z.object({
scope_description: z.string().max(8000).nullish(), scope_description: z.string().max(8000).nullish(),
items: z.array(QuotationItemSchema).optional(), items: z.array(QuotationItemSchema).optional(),
sections: z.array(ScopeSectionSchema).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 // Update = partial create MINUS the number: quotation numbers are immutable

View File

@@ -7,6 +7,7 @@ import rateLimit from "@fastify/rate-limit";
import path from "path"; import path from "path";
import { config } from "./config/env"; import { config } from "./config/env";
import { securityHeaders } from "./middleware/security"; import { securityHeaders } from "./middleware/security";
import { registerVersionHeader } from "./middleware/version";
import prisma from "./config/database"; import prisma from "./config/database";
import authRoutes from "./routes/admin/auth"; import authRoutes from "./routes/admin/auth";
@@ -100,6 +101,9 @@ async function start() {
// --- Security headers --- // --- Security headers ---
app.addHook("onRequest", securityHeaders); app.addHook("onRequest", securityHeaders);
// --- Version header: lets the SPA detect a new deploy and prompt a refresh ---
registerVersionHeader(app);
// --- Global error handler — consistent { success, error } format --- // --- Global error handler — consistent { success, error } format ---
app.setErrorHandler( app.setErrorHandler(
(err: Error & { statusCode?: number }, request, reply) => { (err: Error & { statusCode?: number }, request, reply) => {

View File

@@ -7,6 +7,10 @@ import {
releaseInvoiceNumber, releaseInvoiceNumber,
assignInvoiceNumber, assignInvoiceNumber,
} from "./numbering.service"; } from "./numbering.service";
import {
encodeSelectedCustomFields,
parseSelectedCustomFields,
} from "../utils/custom-fields";
// Status transition rules matching PHP. // Status transition rules matching PHP.
// draft -> issued is the finalize step (consumes + assigns the invoice number). // 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, customer_name: invoice.customers?.name || null,
order_number: invoice.orders?.order_number || null, order_number: invoice.orders?.order_number || null,
valid_transitions: VALID_TRANSITIONS[invoice.status as string] || [], 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 internal_notes: body.internal_notes
? String(body.internal_notes) ? String(body.internal_notes)
: null, : 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; data.due_date = body.due_date ? new Date(String(body.due_date)) : null;
if (body.tax_date !== undefined) if (body.tax_date !== undefined)
data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null; 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) // Internal notes editable in draft/issued/overdue (never printed)

View File

@@ -9,6 +9,10 @@ import {
isIssuedOrderNumberTaken, isIssuedOrderNumberTaken,
} from "./numbering.service"; } from "./numbering.service";
import { nasOrdersManager } from "./nas-financials-manager"; import { nasOrdersManager } from "./nas-financials-manager";
import {
encodeSelectedCustomFields,
parseSelectedCustomFields,
} from "../utils/custom-fields";
export interface IssuedOrderItemInput { export interface IssuedOrderItemInput {
description?: string | null; description?: string | null;
@@ -125,6 +129,7 @@ const NON_STATUS_UPDATE_FIELDS = [
"language", "language",
"order_text", "order_text",
"internal_notes", "internal_notes",
"selected_custom_fields",
"items", "items",
"sections", "sections",
] as const; ] as const;
@@ -143,6 +148,32 @@ export function computeIssuedOrderTotals(
return { total: Math.round(total * 100) / 100 }; return { total: Math.round(total * 100) / 100 };
} }
/**
* An issued order may be issued by sections alone (no line items), but a
* completely blank order must not be finalizable. Content = at least one item
* with a description OR one section with a title or stripped (non-tag) content.
* Mirrors the PDF visibility test and the frontend submit guard.
*/
function hasOrderContent(
items: Array<{ description?: string | null }> | undefined,
sections:
| Array<{
title?: string | null;
title_cz?: string | null;
content?: string | null;
}>
| undefined,
): boolean {
const anyItem = (items || []).some((it) => (it.description || "").trim());
const anySection = (sections || []).some(
(s) =>
(s.title_cz || "").trim() ||
(s.title || "").trim() ||
(s.content || "").replace(/<[^>]*>/g, "").trim(),
);
return anyItem || anySection;
}
export async function listIssuedOrders(params: ListIssuedOrdersParams) { export async function listIssuedOrders(params: ListIssuedOrdersParams) {
const { page, limit, skip, sort, order } = params; const { page, limit, skip, sort, order } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id"; const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
@@ -253,6 +284,9 @@ export async function getIssuedOrder(id: number) {
supplier: order.suppliers, supplier: order.suppliers,
supplier_name: order.suppliers?.name || null, supplier_name: order.suppliers?.name || null,
valid_transitions: VALID_TRANSITIONS[order.status as string] || [], valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
selected_custom_fields: parseSelectedCustomFields(
rest.selected_custom_fields,
),
}; };
} }
@@ -261,6 +295,12 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
return await prisma.$transaction(async (tx) => { return await prisma.$transaction(async (tx) => {
const status = body.status ? String(body.status) : "draft"; const status = body.status ? String(body.status) : "draft";
// A non-draft (created-as-finalized) order must not be blank — require an
// item or a non-empty section. Drafts may be saved empty (WIP).
if (status !== "draft" && !hasOrderContent(body.items, body.sections)) {
return { error: "empty_document" as const };
}
// Validate the referenced supplier exists BEFORE the insert — a dangling // Validate the referenced supplier exists BEFORE the insert — a dangling
// FK would otherwise surface as a P2003 500 instead of a clean 400. // FK would otherwise surface as a P2003 500 instead of a clean 400.
const supplierId = body.supplier_id ? Number(body.supplier_id) : null; const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
@@ -319,6 +359,9 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
internal_notes: body.internal_notes internal_notes: body.internal_notes
? String(body.internal_notes) ? String(body.internal_notes)
: null, : null,
selected_custom_fields: encodeSelectedCustomFields(
body.selected_custom_fields,
),
}, },
}); });
@@ -424,6 +467,10 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
data.internal_notes = body.internal_notes data.internal_notes = body.internal_notes
? String(body.internal_notes) ? String(body.internal_notes)
: null; : 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); if (body.status !== undefined) data.status = String(body.status);
@@ -437,6 +484,27 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
body.status !== undefined && body.status !== undefined &&
String(body.status) === "sent"; String(body.status) === "sent";
// Finalizing a blank order is rejected — require an item or a non-empty
// section. The finalize payload carries items/sections; fall back to the
// stored rows when a partial finalize omits them.
if (finalizing) {
const itemsForCheck = Array.isArray(body.items)
? body.items
: await prisma.issued_order_items.findMany({
where: { issued_order_id: id },
select: { description: true },
});
const sectionsForCheck = Array.isArray(body.sections)
? body.sections
: await prisma.issued_order_sections.findMany({
where: { issued_order_id: id },
select: { title: true, title_cz: true, content: true },
});
if (!hasOrderContent(itemsForCheck, sectionsForCheck)) {
return { error: "empty_document" as const };
}
}
// ONE transaction for the header write, the finalize numbering AND the // ONE transaction for the header write, the finalize numbering AND the
// items/sections full-replace. The items replace used to run in a SECOND // items/sections full-replace. The items replace used to run in a SECOND
// transaction after the header tx — a failure between the two left a torn // transaction after the header tx — a failure between the two left a torn

View File

@@ -8,6 +8,10 @@ import {
assignOfferNumber, assignOfferNumber,
} from "./numbering.service"; } from "./numbering.service";
import { nasOffersManager } from "./nas-offers-manager"; import { nasOffersManager } from "./nas-offers-manager";
import {
encodeSelectedCustomFields,
parseSelectedCustomFields,
} from "../utils/custom-fields";
interface QuotationItemInput { interface QuotationItemInput {
description?: string; description?: string;
@@ -64,6 +68,7 @@ const NON_STATUS_UPDATE_FIELDS = [
"language", "language",
"scope_title", "scope_title",
"scope_description", "scope_description",
"selected_custom_fields",
"items", "items",
"sections", "sections",
] as const; ] as const;
@@ -283,6 +288,9 @@ export async function getOffer(id: number) {
// Computed server-side so the frontend renders only legal status buttons // Computed server-side so the frontend renders only legal status buttons
// (same contract as getIssuedOrder). // (same contract as getIssuedOrder).
valid_transitions: VALID_TRANSITIONS[quotation.status] || [], 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 scope_description: body.scope_description
? String(body.scope_description) ? String(body.scope_description)
: null, : 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) ? String(body.scope_description)
: null : null
: undefined, : undefined,
selected_custom_fields:
body.selected_custom_fields !== undefined
? encodeSelectedCustomFields(body.selected_custom_fields)
: undefined,
modified_at: new Date(), modified_at: new Date(),
}; };

View File

@@ -46,3 +46,36 @@ export function decodeCustomFields(raw: string | null): {
return { custom_fields: [], field_order: [] }; 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);
}

View File

@@ -1,10 +1,20 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { readFileSync } from "node:fs";
// Bake the package.json version into the bundle so the running SPA can compare
// it to the server's X-App-Version header and detect a new deploy.
const appVersion = (
JSON.parse(readFileSync("./package.json", "utf8")) as { version: string }
).version;
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
root: ".", root: ".",
publicDir: "public", publicDir: "public",
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
server: { server: {
port: 3000, port: 3000,
}, },