Compare commits
19 Commits
v2.4.35
...
81293ae543
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81293ae543 | ||
|
|
237ebf3ef8 | ||
|
|
983a1408f1 | ||
|
|
ae4a51bf7d | ||
|
|
fb480d886d | ||
|
|
59f555059b | ||
|
|
3d784adf5d | ||
|
|
44cfea22ca | ||
|
|
bce0280846 | ||
|
|
0d9b5e9570 | ||
|
|
2b3266a1cc | ||
|
|
2f084174df | ||
|
|
5551786da2 | ||
|
|
09f8dc63bf | ||
|
|
8dec785d13 | ||
|
|
4674ff3389 | ||
|
|
4bb7de7247 | ||
|
|
719d1f2659 | ||
|
|
3787cc4dd0 |
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,789 @@
|
|||||||
|
# Per-document custom-field print selection — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let each offer, issued order, and invoice choose — on its detail page — which company custom fields print in its PDF sender block; default none selected.
|
||||||
|
|
||||||
|
**Architecture:** A new nullable `selected_custom_fields` column (JSON-array-in-VARCHAR) on `quotations`, `issued_orders`, `invoices`. A shared backend util encodes/decodes the index array. Create/update services persist it; detail services decode it to `number[]`. The PDF `buildAddressLines()` company-block builder gains an optional selected-indices filter. A shared React picker reused by the three detail pages drives the selection.
|
||||||
|
|
||||||
|
**Tech Stack:** Prisma 7 / MySQL, Fastify 5, Zod 4, Vitest (real `app_test` DB), React 19 + MUI v7 + React Query.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-16-per-document-custom-field-selection-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conventions for this plan
|
||||||
|
|
||||||
|
- This shell is non-interactive — `prisma migrate dev` is forbidden. Use the `migrate diff` recipe (Task 1).
|
||||||
|
- **Before the migration, ask the user to stop their dev server and wait for confirmation** (it holds DB connections). Do not run the migration until they confirm.
|
||||||
|
- Server tests run against `app_test` via `.env.test` (`npm test`). Apply the migration to `app_test` too or the suite breaks.
|
||||||
|
- `npm run typecheck` = `tsc -b --noEmit`. `npm run lint` must stay at 0 errors.
|
||||||
|
- Selection semantics in `buildAddressLines`: param **omitted/`undefined`** ⇒ show ALL custom fields (unchanged behavior — used for customer/supplier blocks). Param is an **array** (possibly empty) ⇒ show ONLY those indices (used for the company/sender block; empty ⇒ none).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Modify** `prisma/schema.prisma` — add `selected_custom_fields String?` to `quotations`, `issued_orders`, `invoices`.
|
||||||
|
- **Create** `prisma/migrations/<ts>_add_selected_custom_fields/migration.sql`.
|
||||||
|
- **Modify** `src/utils/custom-fields.ts` — add `encodeSelectedCustomFields` / `parseSelectedCustomFields`.
|
||||||
|
- **Create** `src/__tests__/selected-custom-fields.test.ts` — util + integration coverage.
|
||||||
|
- **Modify** `src/schemas/offers.schema.ts`, `issued-orders.schema.ts`, `invoices.schema.ts` — new optional array field.
|
||||||
|
- **Modify** `src/services/offers.service.ts`, `issued-orders.service.ts`, `invoices.service.ts` — persist on create/update, decode in detail.
|
||||||
|
- **Modify** `src/routes/admin/offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts` — filter company custom lines.
|
||||||
|
- **Modify** `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts` — add `selected_custom_fields: number[]` to detail interfaces.
|
||||||
|
- **Create** `src/admin/components/document/CustomFieldsPrintPicker.tsx` — shared picker.
|
||||||
|
- **Modify** `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx` — render picker, wire into save payload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Schema column + migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `prisma/schema.prisma` (models `quotations`, `issued_orders`, `invoices`)
|
||||||
|
- Create: `prisma/migrations/<yyyyMMddHHmmss>_add_selected_custom_fields/migration.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Ask the user to stop their dev server**
|
||||||
|
|
||||||
|
Post: "Please stop your dev server so I can apply a migration, and tell me when it's stopped." Wait for confirmation before any later `migrate deploy` step.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the column to each model in `prisma/schema.prisma`**
|
||||||
|
|
||||||
|
Add this line to the `quotations` model (near other scalar columns, e.g. after `language`):
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
selected_custom_fields String? @db.VarChar(255)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the identical line to the `issued_orders` model and to the `invoices` model.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Generate the migration SQL**
|
||||||
|
|
||||||
|
Run (Git Bash):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /d/cortex/boha-app-ts
|
||||||
|
TS=$(date +%Y%m%d%H%M%S)
|
||||||
|
mkdir -p "prisma/migrations/${TS}_add_selected_custom_fields"
|
||||||
|
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
|
||||||
|
> "prisma/migrations/${TS}_add_selected_custom_fields/migration.sql"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: a `migration.sql` containing three `ALTER TABLE … ADD COLUMN selected_custom_fields VARCHAR(255) NULL` statements (one per table). Open it and confirm it touches ONLY `quotations`, `issued_orders`, `invoices` and adds nothing else (no BOM — if it was written via PowerShell, strip the BOM).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Apply to dev DB + regenerate client (after user confirmed server stopped)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx prisma migrate deploy
|
||||||
|
npx prisma generate
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: "All migrations have been applied" and a regenerated client.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Apply to the test DB**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DATABASE_URL="$(grep -m1 '^DATABASE_URL' .env.test | cut -d= -f2- | tr -d '"')" npx prisma migrate deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the same migration applied to `app_test`. (If the env parsing is awkward on this shell, temporarily set `DATABASE_URL` to the app_test URL from `.env.test` and run `npx prisma migrate deploy`.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS (the new Prisma field is now known to the client).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add prisma/schema.prisma prisma/migrations
|
||||||
|
git commit -m "feat(documents): add selected_custom_fields column to quotations/issued_orders/invoices"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend encode/decode util (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/utils/custom-fields.ts`
|
||||||
|
- Test: `src/__tests__/selected-custom-fields.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `src/__tests__/selected-custom-fields.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
|
||||||
|
describe("selected custom fields encode/decode", () => {
|
||||||
|
it("encodes a non-empty index array to a JSON string", () => {
|
||||||
|
expect(encodeSelectedCustomFields([0, 2])).toBe("[0,2]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes empty / non-array to null", () => {
|
||||||
|
expect(encodeSelectedCustomFields([])).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields(undefined)).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields("nope")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes, sorts, and drops invalid entries before encoding", () => {
|
||||||
|
expect(encodeSelectedCustomFields([2, 0, 2, -1, 1.5, 3])).toBe("[0,2,3]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a stored string back to a number array", () => {
|
||||||
|
expect(parseSelectedCustomFields("[0,2]")).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses null / malformed to an empty array", () => {
|
||||||
|
expect(parseSelectedCustomFields(null)).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("{garbage")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields('"x"')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses already-array input (defensive) and filters invalid", () => {
|
||||||
|
expect(parseSelectedCustomFields([0, "1", 2, -3] as unknown)).toEqual([
|
||||||
|
0, 2,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it to confirm failure**
|
||||||
|
|
||||||
|
Run: `npm test -- selected-custom-fields`
|
||||||
|
Expected: FAIL — `encodeSelectedCustomFields is not a function`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the helpers**
|
||||||
|
|
||||||
|
Append to `src/utils/custom-fields.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Per-document selection of which COMPANY custom fields print on a PDF.
|
||||||
|
* Stored positionally (matching the `custom_<i>` keys the PDF builder emits)
|
||||||
|
* as a JSON array string, e.g. "[0,2]". Null/empty means "none selected".
|
||||||
|
*/
|
||||||
|
export function encodeSelectedCustomFields(indices: unknown): string | null {
|
||||||
|
const clean = normalizeIndices(indices);
|
||||||
|
return clean.length > 0 ? JSON.stringify(clean) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode the stored selection (string OR defensive array) into a clean number[]. */
|
||||||
|
export function parseSelectedCustomFields(raw: unknown): number[] {
|
||||||
|
if (raw == null) return [];
|
||||||
|
if (Array.isArray(raw)) return normalizeIndices(raw);
|
||||||
|
if (typeof raw !== "string" || raw.trim() === "") return [];
|
||||||
|
try {
|
||||||
|
return normalizeIndices(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
// Malformed JSON in a selection column degrades to "none" (expected
|
||||||
|
// condition — a hand-edited/legacy row should never 500 a PDF render).
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIndices(input: unknown): number[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const v of input) {
|
||||||
|
if (typeof v === "number" && Number.isInteger(v) && v >= 0) set.add(v);
|
||||||
|
}
|
||||||
|
return [...set].sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to confirm it passes**
|
||||||
|
|
||||||
|
Run: `npm test -- selected-custom-fields`
|
||||||
|
Expected: PASS (all util cases).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/utils/custom-fields.ts src/__tests__/selected-custom-fields.test.ts
|
||||||
|
git commit -m "feat(documents): selected-custom-fields encode/decode helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Zod schemas
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/schemas/offers.schema.ts`
|
||||||
|
- Modify: `src/schemas/issued-orders.schema.ts`
|
||||||
|
- Modify: `src/schemas/invoices.schema.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Offers schema**
|
||||||
|
|
||||||
|
In `src/schemas/offers.schema.ts`, inside `CreateQuotationSchema`, add after the `sections` line (line 50):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Positional indices of company custom fields to print on this document's
|
||||||
|
// PDF. Omitted/empty ⇒ none. Update schema derives via .partial().
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
(`UpdateQuotationSchema` already derives from this via `.partial().omit({ quotation_number: true })` — no change needed there.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Issued-orders schema**
|
||||||
|
|
||||||
|
In `src/schemas/issued-orders.schema.ts`, inside `CreateIssuedOrderSchema`, add after the `sections` field:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
(`UpdateIssuedOrderSchema` derives via `.partial()` — no change.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Invoices schema**
|
||||||
|
|
||||||
|
In `src/schemas/invoices.schema.ts`, add the same line to BOTH `CreateInvoiceSchema` (after its `sections` field) and `UpdateInvoiceSchema` (after its `sections` field — invoices define the two schemas separately, so it must be added in both):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/schemas/offers.schema.ts src/schemas/issued-orders.schema.ts src/schemas/invoices.schema.ts
|
||||||
|
git commit -m "feat(documents): accept selected_custom_fields in document schemas"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Services — persist on write, decode in detail (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/services/offers.service.ts`
|
||||||
|
- Modify: `src/services/issued-orders.service.ts`
|
||||||
|
- Modify: `src/services/invoices.service.ts`
|
||||||
|
- Test: `src/__tests__/selected-custom-fields.test.ts` (extend)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the import to all three services**
|
||||||
|
|
||||||
|
At the top of each of the three service files, add (or extend the existing import from `../utils/custom-fields`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Offers — persist on create**
|
||||||
|
|
||||||
|
In `createOffer` (`src/services/offers.service.ts`), inside `tx.quotations.create({ data: { … } })`, add after `scope_description`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Offers — persist on update**
|
||||||
|
|
||||||
|
In `updateOffer`, inside the `const data = { … }` object (after `scope_description`, before `modified_at`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields:
|
||||||
|
body.selected_custom_fields !== undefined
|
||||||
|
? encodeSelectedCustomFields(body.selected_custom_fields)
|
||||||
|
: undefined,
|
||||||
|
```
|
||||||
|
|
||||||
|
(`undefined` = "key absent in payload, leave column untouched" — matches the sibling header fields.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Offers — decode in detail**
|
||||||
|
|
||||||
|
In `getOffer`, in the returned object (after `valid_transitions`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Issued orders — persist + decode**
|
||||||
|
|
||||||
|
In `src/services/issued-orders.service.ts`:
|
||||||
|
|
||||||
|
- In `createIssuedOrder`, inside `tx.issued_orders.create({ data: { … } })`, add:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
- In `updateIssuedOrder`, inside the header `data` object passed to `issued_orders.update`, add:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields:
|
||||||
|
body.selected_custom_fields !== undefined
|
||||||
|
? encodeSelectedCustomFields(body.selected_custom_fields)
|
||||||
|
: undefined,
|
||||||
|
```
|
||||||
|
- In `getIssuedOrder`, in the returned object (after `valid_transitions`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Invoices — persist + decode**
|
||||||
|
|
||||||
|
In `src/services/invoices.service.ts`:
|
||||||
|
|
||||||
|
- In `createInvoice`, inside `tx.invoices.create({ data: { … } })`, add after `internal_notes`:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
),
|
||||||
|
```
|
||||||
|
- In `updateInvoice`, inside the first `if (editable) { … }` block (after the `tax_date` handling, still inside the block), add:
|
||||||
|
```ts
|
||||||
|
if (body.selected_custom_fields !== undefined)
|
||||||
|
data.selected_custom_fields = encodeSelectedCustomFields(
|
||||||
|
body.selected_custom_fields,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- In `getInvoice`, in the returned object (after `valid_transitions`), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Extend the test with an offers round-trip (real DB)**
|
||||||
|
|
||||||
|
Append to `src/__tests__/selected-custom-fields.test.ts`. Match the existing suite's fixture style — import the offers service directly and clean up. Use a far-future placeholder where the suite does; if an existing offers test helper exists, prefer it. Minimal version:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createOffer, getOffer } from "../services/offers.service";
|
||||||
|
import { prisma } from "../config/prisma"; // adjust to the project's prisma export path
|
||||||
|
|
||||||
|
describe("offers selected_custom_fields round-trip", () => {
|
||||||
|
const created: number[] = [];
|
||||||
|
afterAll(async () => {
|
||||||
|
if (created.length)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id: { in: created } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists selection on create and decodes it on detail", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [2, 0, 0],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBe("[0,2]");
|
||||||
|
|
||||||
|
const detail = await getOffer(res.id);
|
||||||
|
expect(detail?.selected_custom_fields).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null when selection is empty", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
> Before running: confirm the prisma import path (`../config/prisma` vs `../config/db` — grep an existing test). Adjust the import to match.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run tests**
|
||||||
|
|
||||||
|
Run: `npm test -- selected-custom-fields`
|
||||||
|
Expected: PASS (util + offers round-trip). If FK constraints require a customer, the `customer_id`-less draft path above avoids them.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Typecheck + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
git add src/services/offers.service.ts src/services/issued-orders.service.ts src/services/invoices.service.ts src/__tests__/selected-custom-fields.test.ts
|
||||||
|
git commit -m "feat(documents): persist & expose selected_custom_fields in services"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: PDF rendering — filter company custom lines (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/routes/admin/offers-pdf.ts`
|
||||||
|
- Modify: `src/routes/admin/issued-orders-pdf.ts`
|
||||||
|
- Modify: `src/routes/admin/invoices-pdf.ts`
|
||||||
|
- Test: `src/__tests__/selected-custom-fields.test.ts` (extend, if a PDF render is unit-testable; otherwise assert via the helper — see Step 6)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Offers PDF — add the filter param to `buildAddressLines`**
|
||||||
|
|
||||||
|
In `src/routes/admin/offers-pdf.ts`, change the signature (line 28-32):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function buildAddressLines(
|
||||||
|
entity: Record<string, unknown> | null,
|
||||||
|
isSupplier: boolean,
|
||||||
|
t: (key: string) => string,
|
||||||
|
selectedCustomFields?: number[],
|
||||||
|
): AddressResult {
|
||||||
|
```
|
||||||
|
|
||||||
|
Then change the custom-field loop (lines 88-96) to skip non-selected indices when a selection array is provided:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const filterCustom = Array.isArray(selectedCustomFields);
|
||||||
|
cfData.forEach((cf, i) => {
|
||||||
|
if (filterCustom && !selectedCustomFields!.includes(i)) return;
|
||||||
|
const cfName = (cf.name || "").trim();
|
||||||
|
const cfValue = (cf.value || "").trim();
|
||||||
|
const showLabel = cf.showLabel !== false;
|
||||||
|
if (cfValue) {
|
||||||
|
fieldMap[`custom_${i}`] =
|
||||||
|
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Offers PDF — pass the document's selection into the COMPANY call**
|
||||||
|
|
||||||
|
The offer's sender/company block is `supp` (`isSupplier: true` on `settings`). Update that call (lines 209-213) to pass the parsed selection; leave the customer call (`cust`) unchanged so customer custom fields still show in full:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const supp = buildAddressLines(
|
||||||
|
settings as unknown as Record<string, unknown>,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(quotation as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add the import at the top of the file:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseSelectedCustomFields } from "../../utils/custom-fields";
|
||||||
|
```
|
||||||
|
|
||||||
|
> Confirm `quotation` (the `OfferForPdf` payload the render function receives) is selected with the default field set so `selected_custom_fields` is present. It's a scalar column on `quotations`, so the default `findUnique`/`include` returns it — no `select` narrowing to adjust here.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Issued-orders PDF — same change on the COMPANY (buyer) block**
|
||||||
|
|
||||||
|
In `src/routes/admin/issued-orders-pdf.ts`:
|
||||||
|
|
||||||
|
- Add `selectedCustomFields?: number[]` as the last param of `buildAddressLines` and apply the identical `filterCustom` guard in its custom-field loop.
|
||||||
|
- The company block is `buyer = buildAddressLines(settings, true, t)` (line 316). Change to:
|
||||||
|
```ts
|
||||||
|
const buyer = buildAddressLines(
|
||||||
|
settings,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(order as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- Leave `buildSupplierLines(...)` untouched (supplier fields keep showing in full).
|
||||||
|
- Add `import { parseSelectedCustomFields } from "../../utils/custom-fields";`.
|
||||||
|
|
||||||
|
> Confirm the render function's `order` param carries `selected_custom_fields`. If the route fetches the order via a narrow `select`, add `selected_custom_fields: true` to it; if it uses `include`/default scalars, it's already present. Grep the route's `issued_orders.findUnique`/`findFirst` before assuming.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Invoices PDF — same change on the COMPANY (supplier-of-invoice) block**
|
||||||
|
|
||||||
|
In `src/routes/admin/invoices-pdf.ts`:
|
||||||
|
|
||||||
|
- Add `selectedCustomFields?: number[]` as the last param of `buildAddressLines` and apply the identical `filterCustom` guard.
|
||||||
|
- The company block is `supp = buildAddressLines(settings, true, t)` (line 457). Change to:
|
||||||
|
```ts
|
||||||
|
const supp = buildAddressLines(
|
||||||
|
settings,
|
||||||
|
true,
|
||||||
|
t,
|
||||||
|
parseSelectedCustomFields(
|
||||||
|
(invoice as { selected_custom_fields?: unknown }).selected_custom_fields,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
```
|
||||||
|
- Leave `cust = buildAddressLines(customer, false, t)` untouched.
|
||||||
|
- Note the separate `settings.custom_fields` read lower down (the "supplier email/web" extraction near line 469) is a DIFFERENT concern (pulls an email for a header line) — **do not** filter that; leave it as-is.
|
||||||
|
- Add `import { parseSelectedCustomFields } from "../../utils/custom-fields";`.
|
||||||
|
|
||||||
|
> Confirm `invoice` is fetched with default scalars (it is — `prisma.invoices.findUnique` without a narrowing `select` in this route), so `selected_custom_fields` is present.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Typecheck + lint**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm run lint`
|
||||||
|
Expected: PASS, 0 lint errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add a focused render assertion (extend the test file)**
|
||||||
|
|
||||||
|
If the three PDF modules export their HTML render function (e.g. offers exports `renderOfferHtml`), add a test that renders with a stubbed settings object carrying two company custom fields and asserts only the selected one appears. Mock `html-to-pdf` per the suite convention; you're asserting on the returned HTML string, not a real PDF. Example for offers (adapt names to the actual export):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { renderOfferHtml } from "../routes/admin/offers-pdf"; // confirm export name
|
||||||
|
|
||||||
|
it("offer PDF prints only selected company custom fields", () => {
|
||||||
|
const settings = {
|
||||||
|
name: "Naše Firma s.r.o.",
|
||||||
|
custom_fields: JSON.stringify({
|
||||||
|
fields: [
|
||||||
|
{ name: "Tel.", value: "123", showLabel: true },
|
||||||
|
{ name: "Web", value: "example.cz", showLabel: true },
|
||||||
|
],
|
||||||
|
field_order: [],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const quotation = {
|
||||||
|
customers: null,
|
||||||
|
quotation_items: [],
|
||||||
|
scope_sections: [],
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
selected_custom_fields: "[0]",
|
||||||
|
};
|
||||||
|
const html = renderOfferHtml(quotation as never, settings as never);
|
||||||
|
expect(html).toContain("Tel.: 123");
|
||||||
|
expect(html).not.toContain("example.cz");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If the render function is NOT exported / not unit-testable without a DB, SKIP this step rather than forcing it — the util test (Task 2) plus the service round-trip (Task 4) already cover the data path; note in the commit that PDF filtering was verified manually. Do not export internals solely to test them if that breaks the module's encapsulation; prefer a manual verification note.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/admin/offers-pdf.ts src/routes/admin/issued-orders-pdf.ts src/routes/admin/invoices-pdf.ts src/__tests__/selected-custom-fields.test.ts
|
||||||
|
git commit -m "feat(documents): PDF prints only the document's selected company custom fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Frontend — detail query types + shared picker
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts`
|
||||||
|
- Create: `src/admin/components/document/CustomFieldsPrintPicker.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the field to the three detail interfaces**
|
||||||
|
|
||||||
|
- `src/admin/lib/queries/offers.ts` — add to `OfferDetailData`:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: number[];
|
||||||
|
```
|
||||||
|
- `src/admin/lib/queries/issued-orders.ts` — add to `IssuedOrderDetail`:
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: number[];
|
||||||
|
```
|
||||||
|
- `src/admin/lib/queries/invoices.ts` — add to `InvoiceDetail`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields?: number[];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the shared picker component**
|
||||||
|
|
||||||
|
Create `src/admin/components/document/CustomFieldsPrintPicker.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FormControlLabel, Checkbox, Box, Typography } from "@mui/material";
|
||||||
|
import type { CompanySettingsCustomField } from "../../lib/queries/settings";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Company custom field definitions, in positional order (index = print key). */
|
||||||
|
fields: CompanySettingsCustomField[];
|
||||||
|
/** Currently selected positional indices. */
|
||||||
|
selected: number[];
|
||||||
|
/** Read-only (document not editable / locked by another user). */
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (next: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-document picker: choose which COMPANY custom fields print on this
|
||||||
|
* document's PDF. Selection is positional (matches the PDF `custom_<i>` keys).
|
||||||
|
* Renders nothing when the company has defined no custom fields.
|
||||||
|
*/
|
||||||
|
export default function CustomFieldsPrintPicker({
|
||||||
|
fields,
|
||||||
|
selected,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const printable = fields.filter((f) => (f.value || "").trim());
|
||||||
|
if (printable.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = (idx: number, checked: boolean) => {
|
||||||
|
const set = new Set(selected);
|
||||||
|
if (checked) set.add(idx);
|
||||||
|
else set.delete(idx);
|
||||||
|
onChange([...set].sort((a, b) => a - b));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
|
Vlastní pole na PDF
|
||||||
|
</Typography>
|
||||||
|
{fields.map((f, idx) => {
|
||||||
|
if (!(f.value || "").trim()) return null;
|
||||||
|
const label = (f.name || "").trim() ? `${f.name}: ${f.value}` : f.value;
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
key={idx}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={selected.includes(idx)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => toggle(idx, e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">{label}</Typography>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: indices are keyed off the FULL `fields` array (not the filtered `printable`) so they stay aligned with the PDF's `custom_<i>`, which also enumerates the full array. Empty-value fields are skipped visually but still consume their index.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Typecheck**
|
||||||
|
|
||||||
|
Run: `npm run typecheck`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/lib/queries/offers.ts src/admin/lib/queries/issued-orders.ts src/admin/lib/queries/invoices.ts src/admin/components/document/CustomFieldsPrintPicker.tsx
|
||||||
|
git commit -m "feat(documents): shared CustomFieldsPrintPicker + detail query types"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Frontend — wire the picker into the three detail pages
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/pages/OfferDetail.tsx`
|
||||||
|
- Modify: `src/admin/pages/IssuedOrderDetail.tsx`
|
||||||
|
- Modify: `src/admin/pages/InvoiceDetail.tsx`
|
||||||
|
|
||||||
|
For EACH page, the same four edits. Detail below uses OfferDetail; repeat the pattern for the other two (their company-settings query and edit-lock/editable flags already exist on the page — reuse them; do not add new queries).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Import the picker (all three pages)**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Seed local state from the loaded document**
|
||||||
|
|
||||||
|
Add state near the page's other editable-field state, and seed it when the document loads (follow the page's existing seeding pattern — if it copies server data into state in an effect or on query success, add this alongside; if it derives form state via `useState` initializers keyed on the query, match that):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>([]);
|
||||||
|
// when the detail query resolves (same place other fields are seeded):
|
||||||
|
// setSelectedCustomFields(data.selected_custom_fields ?? []);
|
||||||
|
```
|
||||||
|
|
||||||
|
Respect Rules of Hooks: declare this `useState` with the other hooks, before any early `return`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Render the picker in the editable header/meta area**
|
||||||
|
|
||||||
|
Place near the other document-level settings (currency/language). `companySettings` is already loaded on the page via `companySettingsOptions()`. Use the page's existing "is this document editable / not locked by another user" boolean for `disabled` (e.g. `!canEdit` or the locked flag the page already computes):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<CustomFieldsPrintPicker
|
||||||
|
fields={companySettings?.custom_fields ?? []}
|
||||||
|
selected={selectedCustomFields}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onChange={setSelectedCustomFields}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
> Use whatever the page already calls its edit-gate (e.g. `canEdit`, `editable`, `isLockedByOther`). Do NOT invent a new permission — reuse the page's existing flag so the picker locks exactly when the rest of the form does.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Include the selection in the save payload**
|
||||||
|
|
||||||
|
Find where the page builds its update/create payload (the object passed to the save mutation) and add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: selectedCustomFields,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Repeat Steps 1-4 for `IssuedOrderDetail.tsx` and `InvoiceDetail.tsx`**
|
||||||
|
|
||||||
|
Same edits; the company-settings query, edit-gate flag, and save payload all already exist on each page.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Typecheck + lint**
|
||||||
|
|
||||||
|
Run: `npm run typecheck && npm run lint`
|
||||||
|
Expected: PASS, 0 lint errors (watch `react-hooks/rules-of-hooks` — the new `useState` must precede any early return).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/pages/OfferDetail.tsx src/admin/pages/IssuedOrderDetail.tsx src/admin/pages/InvoiceDetail.tsx
|
||||||
|
git commit -m "feat(documents): per-document custom-field print picker on offer/issued-order/invoice detail"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Full verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the whole server suite**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
Expected: PASS (no regressions; new selected-custom-fields cases green).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Typecheck + lint + build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all PASS; client builds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual smoke (user-driven, dev server is theirs)**
|
||||||
|
|
||||||
|
Ask the user to: open an offer detail with company custom fields defined → tick one field → save → open its PDF and confirm only that field prints in the company block; confirm an untouched/older document prints no custom fields. Repeat for an issued order and an invoice.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final state check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
git log --oneline -8
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: clean tree, the feature commits present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review notes (addressed)
|
||||||
|
|
||||||
|
- **Spec coverage:** storage column (T1), encode/decode (T2), schemas (T3), service persist+decode (T4), PDF filter (T5), frontend types+picker (T6), detail-page wiring (T7), tests throughout + full verify (T8). Order confirmations deliberately untouched. ✅
|
||||||
|
- **Default = none:** `buildAddressLines` filters only when passed an array; the company call always passes the parsed selection (default `[]`), so nothing prints until ticked. Customer/supplier calls omit the param ⇒ unchanged. ✅
|
||||||
|
- **Positional identity:** indices key off the full `fields` array on both render and picker sides (T5 note, T6 Step 2 note). ✅
|
||||||
|
- **Type consistency:** `encodeSelectedCustomFields` / `parseSelectedCustomFields` used with identical signatures across services and PDF routes; detail interfaces expose `number[]`. ✅
|
||||||
|
- **Open confirmations flagged inline** (prisma import path in tests; whether each PDF route narrows its document `select`) — each has a grep-first instruction rather than an assumption.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Per-item Sleva (% discount) — Offers + Invoices
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Status:** Approved → implementing
|
||||||
|
**Scope:** Add a per-line-item percentage discount ("Sleva") to **offers**
|
||||||
|
(nabídky) and **issued invoices** (Faktury), on both the detail-page item
|
||||||
|
editor and the PDF. Received invoices, issued orders and orders are NOT
|
||||||
|
touched.
|
||||||
|
|
||||||
|
## Decisions (from brainstorming)
|
||||||
|
|
||||||
|
- **Type:** percentage per item (0–100), not an absolute amount.
|
||||||
|
- **Totals/VAT:** the discount reduces the line **net**; on invoices VAT is
|
||||||
|
computed on the **discounted** net. `net = qty × unit_price × (1 − discount/100)`.
|
||||||
|
- **PDF column is conditional:** render the Sleva column only when at least one
|
||||||
|
(included) item has `discount > 0`; otherwise the table is identical to today.
|
||||||
|
- **Detail-page column is always visible** (it's the input).
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
Add to `quotation_items` and `invoice_items`:
|
||||||
|
|
||||||
|
```
|
||||||
|
discount Decimal? @default(0.00) @db.Decimal(5, 2) // percent, 0–100
|
||||||
|
```
|
||||||
|
|
||||||
|
Nullable with default `0`, matching the existing money columns. "No sleva" =
|
||||||
|
`0` or null. One tracked migration; apply to dev `app` and `app_test`, then
|
||||||
|
`prisma generate`.
|
||||||
|
|
||||||
|
## Line math (single rule)
|
||||||
|
|
||||||
|
`lineNet = qty × unit_price × (1 − clamp(discount,0,100)/100)`
|
||||||
|
|
||||||
|
Add a shared backend helper so every site is identical:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/utils/money.ts (new)
|
||||||
|
export const lineNet = (qty: number, unitPrice: number, discountPct: number) =>
|
||||||
|
qty * unitPrice * (1 - (discountPct || 0) / 100);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
- **Schemas (Zod):** add `discount` to the offer-item and invoice-item schemas
|
||||||
|
via `numberInRange(0, 100)` (shared coercer in `common.ts`), optional, default
|
||||||
|
`0`.
|
||||||
|
- **offers.service** (`enrichQuotation`): line total uses `lineNet`; document
|
||||||
|
net ("Celkem bez DPH") sums discounted included lines. Persist `discount` on
|
||||||
|
item create/update.
|
||||||
|
- **invoices.service**: every `computeMoney` `getBase` callback
|
||||||
|
(`computeInvoiceTotals`, monthly-stats `invoiceMonthlyAmount`, per-rate
|
||||||
|
`vatMap`) returns `lineNet(...)` so subtotal **and** VAT use the discounted
|
||||||
|
net. Persist `discount` on item create/update.
|
||||||
|
- **DocumentItemsEditor's `documentItemsTotal`** (frontend) mirrors the same
|
||||||
|
discounted net.
|
||||||
|
|
||||||
|
## Frontend (Section 1 — detail-page editor)
|
||||||
|
|
||||||
|
- **Offers** → shared `src/admin/components/document/DocumentItemsEditor.tsx`:
|
||||||
|
`DocumentItem` gains `discount`; add a **"Sleva %"** numeric column (0–100);
|
||||||
|
per-line total and the footer net total apply it. Mobile card layout gets the
|
||||||
|
field. `emptyDocumentItem` defaults `discount: 0`. Hydration + save payloads
|
||||||
|
carry `discount`.
|
||||||
|
- **Invoices** → `src/admin/pages/InvoiceDetail.tsx` own editor
|
||||||
|
(`SortableInvoiceRow` + create/edit rows): `InvoiceItem` gains `discount`;
|
||||||
|
same column; net/VAT/gross totals apply it. Mobile layout too.
|
||||||
|
|
||||||
|
## PDF (Section 2 — conditional column)
|
||||||
|
|
||||||
|
`src/routes/admin/offers-pdf.ts` and `src/routes/admin/invoices-pdf.ts`:
|
||||||
|
|
||||||
|
- `const hasDiscount = items.some(i => includedInTotal(i) && Number(i.discount) > 0)`.
|
||||||
|
- When `hasDiscount`: add a column — header **"Sleva"** (cs) / **"Discount"**
|
||||||
|
(en, from the document's `language`), cell renders `"<n> %"` (escaped); adjust
|
||||||
|
colspans of any full-width rows accordingly.
|
||||||
|
- Line net / document total / VAT always use the discounted net regardless of
|
||||||
|
`hasDiscount`.
|
||||||
|
- `escapeHtml` every interpolation (string-built PDF templates).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `offer-invoice-totals` / invoice totals: net, VAT and gross with discounts
|
||||||
|
(incl. a 100% line = 0, and VAT-on-discounted-net).
|
||||||
|
- Schema: `discount` rejects <0 and >100, coerces form strings, defaults 0.
|
||||||
|
- PDF: column present when an item has a discount; absent when none; values
|
||||||
|
escaped.
|
||||||
|
|
||||||
|
## Non-goals (YAGNI)
|
||||||
|
|
||||||
|
- No separate "total discount" summary line, no document-level discount.
|
||||||
|
- No changes to received invoices, issued orders, or orders.
|
||||||
|
|
||||||
|
## Migration etiquette
|
||||||
|
|
||||||
|
DB change → user stops the dev server first; create the migration via the
|
||||||
|
non-interactive `prisma migrate diff` recipe, `migrate deploy` + `generate` on
|
||||||
|
dev and `app_test`, commit schema + migration folder together.
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Per-document custom-field print selection
|
||||||
|
|
||||||
|
**Date:** 2026-06-16
|
||||||
|
**Status:** Approved design — ready for implementation plan
|
||||||
|
**Scope:** offers (quotations), issued orders, invoices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Company custom fields are defined in company settings (`company_settings.custom_fields`,
|
||||||
|
a JSON blob of `{name, value, showLabel}[]` plus a `field_order`). They render in the
|
||||||
|
**sender/company block** of document PDFs via `buildAddressLines(settings, …)`.
|
||||||
|
|
||||||
|
Today **every** custom field with a value prints on **every** document PDF (offer, issued
|
||||||
|
order, invoice). The only existing per-field control is the company-settings checkbox
|
||||||
|
**"Zobrazit název v PDF"** (`showLabel`), which decides whether a printed field shows as
|
||||||
|
`Name: Value` or just `Value` — it does **not** control whether the field appears at all.
|
||||||
|
|
||||||
|
The user wants per-document control: on each offer / issued order / invoice **detail page**,
|
||||||
|
tick which company custom fields actually print on that specific document's PDF.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Each offer, issued order, and invoice independently selects which company custom fields
|
||||||
|
print in its PDF sender block.
|
||||||
|
- Selection lives on the document and is editable on its detail page.
|
||||||
|
- Default is **none selected** — existing documents and new drafts print no custom fields
|
||||||
|
until fields are deliberately ticked.
|
||||||
|
- The company-settings `showLabel` ("Zobrazit název v PDF") checkbox is **unchanged**.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Order confirmations ("orders" / `orders-pdf.ts`) are **out of scope** — they keep current
|
||||||
|
behavior (print all custom fields).
|
||||||
|
- No change to how custom fields are _defined_ in company settings.
|
||||||
|
- No stable per-field IDs — selection is **positional** (see Decisions).
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
1. **Default = none selected.** Existing rows get `NULL`; new documents start empty. A
|
||||||
|
document prints custom fields only for the indices it explicitly stores.
|
||||||
|
2. **Positional identity.** Selection stores field **positions** (`0,1,2…`) matching the
|
||||||
|
existing `custom_<i>` keys in the PDF code. No settings-structure change, no backfill.
|
||||||
|
Accepted caveat: reordering/deleting a custom field in company settings can shift what a
|
||||||
|
saved document's stored indices point at. Low risk — company fields rarely change, and
|
||||||
|
finalized PDFs are archived on NAS (served as-is, not re-rendered). The user accepted this
|
||||||
|
over the more robust stable-ID approach.
|
||||||
|
3. **Scope = 3 document types** (offers, issued orders, invoices). Order confirmations excluded.
|
||||||
|
4. **`showLabel` retained** in company settings, untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Data storage
|
||||||
|
|
||||||
|
Add one nullable column to each of `quotations`, `issued_orders`, `invoices`:
|
||||||
|
|
||||||
|
| Column | Type | Meaning |
|
||||||
|
| ------------------------ | --------- | ------------------------------------------------------------- |
|
||||||
|
| `selected_custom_fields` | `VARCHAR` | JSON array of selected indices, e.g. `"[0,2]"`; `NULL` = none |
|
||||||
|
|
||||||
|
Stored as JSON-in-text, consistent with the existing `company_settings.custom_fields` /
|
||||||
|
`customers.custom_fields` pattern. `NULL`/empty ⇒ no custom fields print.
|
||||||
|
|
||||||
|
One tracked migration per the project's non-interactive `prisma migrate diff` recipe
|
||||||
|
(CLAUDE.md). **Apply to `app_test` as well** (`DATABASE_URL=<app_test> npx prisma migrate
|
||||||
|
deploy`) or the suite breaks. Commit `schema.prisma` + migration folder together.
|
||||||
|
|
||||||
|
### 2. Backend — schemas
|
||||||
|
|
||||||
|
Add to each document's **Create and Update** Zod schema:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
|
||||||
|
```
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `src/schemas/offers.schema.ts` — `CreateQuotationSchema` (Update derives via `.partial().omit()`).
|
||||||
|
- `src/schemas/issued-orders.schema.ts` — `CreateIssuedOrderSchema` (Update derives via `.partial().omit()`).
|
||||||
|
- `src/schemas/invoices.schema.ts` — both `CreateInvoiceSchema` and `UpdateInvoiceSchema`
|
||||||
|
(invoices define the two schemas separately).
|
||||||
|
|
||||||
|
### 3. Backend — services
|
||||||
|
|
||||||
|
In each create/update service, persist the field inside the **existing transaction**:
|
||||||
|
|
||||||
|
- On write: `selected_custom_fields: Array.isArray(body.selected_custom_fields) &&
|
||||||
|
body.selected_custom_fields.length > 0 ? JSON.stringify(body.selected_custom_fields) : null`
|
||||||
|
- No other logic changes; sits alongside the existing header column assignments.
|
||||||
|
|
||||||
|
Files: `src/services/offers.service.ts`, `src/services/issued-orders.service.ts`,
|
||||||
|
`src/services/invoices.service.ts`.
|
||||||
|
|
||||||
|
### 4. Backend — detail responses
|
||||||
|
|
||||||
|
The detail endpoints spread the document row. Decode the stored string back into a
|
||||||
|
`number[]` so the frontend receives a clean array (default `[]` when `NULL`/malformed —
|
||||||
|
malformed JSON degrades gracefully with a logged warning, per the error convention). Add
|
||||||
|
`selected_custom_fields: number[]` to the corresponding detail interfaces in
|
||||||
|
`src/admin/lib/queries/{offers,issued-orders,invoices}.ts`.
|
||||||
|
|
||||||
|
### 5. PDF rendering
|
||||||
|
|
||||||
|
In each of `offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts`, extend the
|
||||||
|
company-block `buildAddressLines()` with an optional parameter:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
buildAddressLines(entity, isSupplier, t, selectedCustomFields?: number[] | null)
|
||||||
|
```
|
||||||
|
|
||||||
|
When building `fieldMap`, only emit a `custom_<i>` entry when `selectedCustomFields`
|
||||||
|
includes `i`. Behavior:
|
||||||
|
|
||||||
|
- `null` / `undefined` / empty array ⇒ **no** custom lines (the new default).
|
||||||
|
- Standard address lines (name, street, city/postal, country, IČO/`company_id`,
|
||||||
|
DIČ/`vat_id`) are **unaffected** — always built as today.
|
||||||
|
- For the custom fields that _are_ selected, the existing `showLabel` (`Name: Value` vs
|
||||||
|
`Value`) and `field_order` ordering logic is preserved unchanged.
|
||||||
|
|
||||||
|
Only the **company/sender** block is filtered (that's where company custom fields render).
|
||||||
|
The customer block (offers/invoices) and supplier block (issued orders) keep their own
|
||||||
|
custom-field behavior unchanged — those come from `customers`/`sklad_suppliers`, not company
|
||||||
|
settings, and are not in scope.
|
||||||
|
|
||||||
|
The PDF route reads the document's `selected_custom_fields` column, parses it to `number[]`,
|
||||||
|
and passes it into `buildAddressLines(settings, …, selected)`.
|
||||||
|
|
||||||
|
> Archived-PDF note: finalized/numbered documents serve their archived NAS copy and won't
|
||||||
|
> change retroactively. Drafts (and any forced re-render) reflect the current selection.
|
||||||
|
|
||||||
|
### 6. Frontend — shared picker component
|
||||||
|
|
||||||
|
New shared component under `src/admin/components/document/` (e.g.
|
||||||
|
`CustomFieldsPrintPicker.tsx`), reused by all three detail pages (extend the shared module,
|
||||||
|
don't fork three copies — per CLAUDE.md document-module conventions).
|
||||||
|
|
||||||
|
Props (shape): the parsed company custom fields (`{name, value}[]` from
|
||||||
|
`companySettings.custom_fields`), the current selected indices, an `onChange`, and a
|
||||||
|
`disabled` flag.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- Renders a checkbox per company custom field; label shows the field's name (and/or value)
|
||||||
|
so the user knows what each is.
|
||||||
|
- Bound to local state on the detail page, seeded from the document's
|
||||||
|
`selected_custom_fields`.
|
||||||
|
- Hidden/empty when the company has no custom fields defined.
|
||||||
|
- Respects edit-lock and editable-status rules: read-only (`disabled`) when the document
|
||||||
|
isn't editable or is locked by another user, matching how the rest of the header fields
|
||||||
|
behave on that page.
|
||||||
|
- Included in the page's save payload as `selected_custom_fields: number[]`.
|
||||||
|
|
||||||
|
Pages: `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx` —
|
||||||
|
each already loads `companySettingsOptions()`, so the field definitions are available with
|
||||||
|
no new query.
|
||||||
|
|
||||||
|
Placement: in the editable header/meta area of each detail page, near the other
|
||||||
|
document-level settings (currency/language/etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Company settings: custom_fields = [{name:"Tel.",…}, {name:"Web",…}, {name:"Datová schránka",…}]
|
||||||
|
idx 0 idx 1 idx 2
|
||||||
|
|
||||||
|
Offer detail page → user ticks "Tel." and "Datová schránka"
|
||||||
|
→ save payload selected_custom_fields: [0, 2]
|
||||||
|
→ service stores quotations.selected_custom_fields = "[0,2]"
|
||||||
|
|
||||||
|
Offer PDF render
|
||||||
|
→ read column "[0,2]" → [0,2]
|
||||||
|
→ buildAddressLines(settings, …, [0,2])
|
||||||
|
→ fieldMap emits custom_0 ("Tel.: …") and custom_2 ("Datová schránka: …"), skips custom_1
|
||||||
|
→ sender block prints Tel. and Datová schránka only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Server-side Vitest against `app_test` (no Prisma mocking; mock Puppeteer/NAS only):
|
||||||
|
|
||||||
|
- Create/update each document with `selected_custom_fields` → column persists the JSON
|
||||||
|
string; empty/omitted → `NULL`.
|
||||||
|
- Detail response decodes to `number[]` (and `[]` for `NULL`).
|
||||||
|
- Schema: non-integer / negative entries rejected; omitted is valid.
|
||||||
|
- PDF: with a stubbed `html-to-pdf`, assert the rendered company block includes only the
|
||||||
|
selected custom field lines and still includes standard address lines; empty selection ⇒
|
||||||
|
no custom lines; standard lines unaffected.
|
||||||
|
- Regression: customer/supplier custom fields still render regardless of company selection.
|
||||||
|
|
||||||
|
## Migration & rollout
|
||||||
|
|
||||||
|
- One migration adding the three columns (all default `NULL`), applied to dev `app` and
|
||||||
|
`app_test`.
|
||||||
|
- No data backfill (none-selected default is the intended post-ship state).
|
||||||
|
- No production PDF changes for already-archived documents.
|
||||||
|
|
||||||
|
## Affected files (reference)
|
||||||
|
|
||||||
|
- `prisma/schema.prisma` (+ new migration folder)
|
||||||
|
- `src/schemas/offers.schema.ts`, `issued-orders.schema.ts`, `invoices.schema.ts`
|
||||||
|
- `src/services/offers.service.ts`, `issued-orders.service.ts`, `invoices.service.ts`
|
||||||
|
- `src/routes/admin/offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts`
|
||||||
|
- (detail route handlers, if decoding is done there rather than in the service)
|
||||||
|
- `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts`
|
||||||
|
- `src/admin/components/document/CustomFieldsPrintPicker.tsx` (new)
|
||||||
|
- `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx`
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.35",
|
"version": "2.4.40",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.35",
|
"version": "2.4.40",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.35",
|
"version": "2.4.40",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `invoice_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `quotation_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -208,6 +208,7 @@ model invoice_items {
|
|||||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||||
unit String? @db.VarChar(20)
|
unit String? @db.VarChar(20)
|
||||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||||
|
discount Decimal? @default(0.00) @db.Decimal(5, 2)
|
||||||
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
|
||||||
position Int? @default(0)
|
position Int? @default(0)
|
||||||
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
|
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
|
||||||
@@ -238,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[]
|
||||||
@@ -392,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)
|
||||||
@@ -505,6 +508,7 @@ model quotation_items {
|
|||||||
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
|
||||||
unit String? @db.VarChar(20)
|
unit String? @db.VarChar(20)
|
||||||
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
|
||||||
|
discount Decimal? @default(0.00) @db.Decimal(5, 2)
|
||||||
is_included_in_total Boolean? @default(true)
|
is_included_in_total Boolean? @default(true)
|
||||||
modified_at DateTime? @db.DateTime(0)
|
modified_at DateTime? @db.DateTime(0)
|
||||||
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
|
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
|
||||||
@@ -525,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)
|
||||||
|
|||||||
46
src/__tests__/app-version-store.test.ts
Normal file
46
src/__tests__/app-version-store.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
130
src/__tests__/item-discount-pdf.test.ts
Normal file
130
src/__tests__/item-discount-pdf.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
import offersPdfRoutes from "../routes/admin/offers-pdf";
|
||||||
|
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
|
||||||
|
import { createInvoice } from "../services/invoices.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-item Sleva column is CONDITIONAL on the PDF: render it only when at
|
||||||
|
* least one item carries a discount > 0; otherwise the table looks exactly as
|
||||||
|
* before. The discounted net always flows into the line/grand totals.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let app: ReturnType<typeof Fastify> | null = null;
|
||||||
|
let adminToken = "";
|
||||||
|
const quotationIds: number[] = [];
|
||||||
|
const invoiceIds: number[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
|
||||||
|
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("Test setup: admin user not found");
|
||||||
|
adminToken = jwt.sign(
|
||||||
|
{ sub: admin.id, username: admin.username, role: "admin" },
|
||||||
|
config.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of quotationIds)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id } });
|
||||||
|
for (const id of invoiceIds)
|
||||||
|
await prisma.invoices.deleteMany({ where: { id } });
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const offerHtml = async (discount: number): Promise<string> => {
|
||||||
|
const q = await prisma.quotations.create({
|
||||||
|
data: {
|
||||||
|
quotation_number: `Q-SLEVA-${discount}-${Date.now()}`,
|
||||||
|
status: "active",
|
||||||
|
currency: "CZK",
|
||||||
|
language: "cs",
|
||||||
|
quotation_items: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
description: "Položka",
|
||||||
|
quantity: 2,
|
||||||
|
unit: "ks",
|
||||||
|
unit_price: 100,
|
||||||
|
discount,
|
||||||
|
is_included_in_total: true,
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
quotationIds.push(q.id);
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/offers-pdf/${q.id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("offer PDF Sleva column", () => {
|
||||||
|
it("renders a Sleva column + discounted total when an item has a discount", async () => {
|
||||||
|
const html = await offerHtml(10);
|
||||||
|
expect(html).toContain("Sleva");
|
||||||
|
// 2 × 100 with 10% off => 180,00 (no thousands separator at this size).
|
||||||
|
expect(html).toContain("180,00");
|
||||||
|
expect(html).not.toContain("200,00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the Sleva column when no item has a discount", async () => {
|
||||||
|
const html = await offerHtml(0);
|
||||||
|
expect(html).not.toContain("Sleva");
|
||||||
|
expect(html).toContain("200,00");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoiceHtml = async (discount: number): Promise<string> => {
|
||||||
|
const inv = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
apply_vat: true,
|
||||||
|
vat_rate: 21,
|
||||||
|
language: "cs",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: "Položka",
|
||||||
|
quantity: 2,
|
||||||
|
unit_price: 1000,
|
||||||
|
discount,
|
||||||
|
vat_rate: 21,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
invoiceIds.push(inv.id);
|
||||||
|
const res = await app!.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/invoices-pdf/${inv.id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("invoice PDF Sleva column", () => {
|
||||||
|
it("renders a Sleva column when an item has a discount", async () => {
|
||||||
|
const html = await invoiceHtml(10);
|
||||||
|
expect(html).toContain("Sleva");
|
||||||
|
expect(html).toContain("10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the Sleva column when no item has a discount", async () => {
|
||||||
|
const html = await invoiceHtml(0);
|
||||||
|
expect(html).not.toContain("Sleva");
|
||||||
|
});
|
||||||
|
});
|
||||||
55
src/__tests__/item-discount-schema.test.ts
Normal file
55
src/__tests__/item-discount-schema.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { CreateQuotationSchema } from "../schemas/offers.schema";
|
||||||
|
import { CreateInvoiceSchema } from "../schemas/invoices.schema";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-item Sleva is a percentage: the offer- and invoice-item schemas must
|
||||||
|
* accept 0–100, default to 0 when absent, and reject out-of-range values.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("offer item discount schema", () => {
|
||||||
|
it("accepts a 0–100 discount", () => {
|
||||||
|
const r = CreateQuotationSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 15 }],
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(true);
|
||||||
|
expect(r.success && r.data.items?.[0].discount).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults discount to 0 when absent", () => {
|
||||||
|
const r = CreateQuotationSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100 }],
|
||||||
|
});
|
||||||
|
expect(r.success && r.data.items?.[0].discount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a discount above 100", () => {
|
||||||
|
const r = CreateQuotationSchema.safeParse({
|
||||||
|
items: [
|
||||||
|
{ description: "X", quantity: 1, unit_price: 100, discount: 150 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invoice item discount schema", () => {
|
||||||
|
it("accepts a 0–100 discount and defaults to 0", () => {
|
||||||
|
const ok = CreateInvoiceSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 10 }],
|
||||||
|
});
|
||||||
|
expect(ok.success && ok.data.items?.[0].discount).toBe(10);
|
||||||
|
|
||||||
|
const def = CreateInvoiceSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100 }],
|
||||||
|
});
|
||||||
|
expect(def.success && def.data.items?.[0].discount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a negative discount", () => {
|
||||||
|
const r = CreateInvoiceSchema.safeParse({
|
||||||
|
items: [{ description: "X", quantity: 1, unit_price: 100, discount: -5 }],
|
||||||
|
});
|
||||||
|
expect(r.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
94
src/__tests__/item-discount-totals.test.ts
Normal file
94
src/__tests__/item-discount-totals.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { createOffer, getOfferTotals } from "../services/offers.service";
|
||||||
|
import {
|
||||||
|
createInvoice,
|
||||||
|
getInvoiceListTotals,
|
||||||
|
} from "../services/invoices.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-item Sleva (% discount) must reduce the line NET — and on invoices the
|
||||||
|
* VAT is computed on the discounted net. Hits the real `app_test` DB through the
|
||||||
|
* service layer (suite convention), mirroring offer-invoice-totals.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MARKER = "SLEVA-TOTALS-2096";
|
||||||
|
const YEAR = 2096;
|
||||||
|
const MONTH = 8; // invoice issue_date window [2096-08-01, 2096-09-01)
|
||||||
|
const dateStr = `${YEAR}-0${MONTH}-15`;
|
||||||
|
|
||||||
|
const offerIds: number[] = [];
|
||||||
|
const invoiceIds: number[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const id of offerIds)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id } });
|
||||||
|
for (const id of invoiceIds)
|
||||||
|
await prisma.invoices.deleteMany({ where: { id } });
|
||||||
|
offerIds.length = 0;
|
||||||
|
invoiceIds.length = 0;
|
||||||
|
await prisma.number_sequences.deleteMany({
|
||||||
|
where: { type: { in: ["offer", "invoice"] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const amountFor = (
|
||||||
|
totals: { currency: string; amount: number }[],
|
||||||
|
currency: string,
|
||||||
|
): number | undefined => totals.find((t) => t.currency === currency)?.amount;
|
||||||
|
|
||||||
|
describe("offer per-item discount", () => {
|
||||||
|
it("reduces the offer NET total by the per-item percentage", async () => {
|
||||||
|
// 2 × 1000 with 10% off => 1800 net.
|
||||||
|
const res = await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
project_code: MARKER,
|
||||||
|
items: [
|
||||||
|
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if ("error" in res) throw new Error(JSON.stringify(res));
|
||||||
|
offerIds.push(res.id);
|
||||||
|
|
||||||
|
const { totals } = await getOfferTotals({ search: MARKER });
|
||||||
|
expect(amountFor(totals, "CZK")).toBe(1800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invoice per-item discount", () => {
|
||||||
|
it("computes VAT on the discounted net (gross = discounted net + VAT)", async () => {
|
||||||
|
// 2 × 1000 with 10% off => net 1800, VAT@21% = 378, gross 2178.
|
||||||
|
const inv = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
vat_rate: 21,
|
||||||
|
apply_vat: true,
|
||||||
|
issue_date: dateStr,
|
||||||
|
items: [
|
||||||
|
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
invoiceIds.push(inv.id);
|
||||||
|
|
||||||
|
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
|
||||||
|
expect(amountFor(totals, "CZK")).toBe(2178);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a 100% discount line as zero", async () => {
|
||||||
|
const inv = await createInvoice({
|
||||||
|
status: "draft",
|
||||||
|
currency: "CZK",
|
||||||
|
vat_rate: 21,
|
||||||
|
apply_vat: true,
|
||||||
|
issue_date: dateStr,
|
||||||
|
items: [
|
||||||
|
{ description: "Free", quantity: 5, unit_price: 200, discount: 100 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
invoiceIds.push(inv.id);
|
||||||
|
|
||||||
|
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
|
||||||
|
expect(amountFor(totals, "CZK")).toBeUndefined(); // zero totals are dropped
|
||||||
|
});
|
||||||
|
});
|
||||||
22
src/__tests__/money.test.ts
Normal file
22
src/__tests__/money.test.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { lineNet } from "../utils/money";
|
||||||
|
|
||||||
|
describe("lineNet (per-item discounted net)", () => {
|
||||||
|
it("returns qty × unit_price when there is no discount", () => {
|
||||||
|
expect(lineNet(2, 1000, 0)).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a percentage discount to the line net", () => {
|
||||||
|
expect(lineNet(2, 1000, 10)).toBe(1800);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a 100% discount as zero", () => {
|
||||||
|
expect(lineNet(3, 500, 100)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats null/undefined/NaN discount as no discount", () => {
|
||||||
|
expect(lineNet(2, 1000, null)).toBe(2000);
|
||||||
|
expect(lineNet(2, 1000, undefined)).toBe(2000);
|
||||||
|
expect(lineNet(2, 1000, NaN)).toBe(2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
129
src/__tests__/selected-custom-fields.test.ts
Normal file
129
src/__tests__/selected-custom-fields.test.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
encodeSelectedCustomFields,
|
||||||
|
parseSelectedCustomFields,
|
||||||
|
} from "../utils/custom-fields";
|
||||||
|
import { afterAll } from "vitest";
|
||||||
|
import { createOffer, getOffer } from "../services/offers.service";
|
||||||
|
import { renderOfferHtml } from "../routes/admin/offers-pdf";
|
||||||
|
import type { OfferForPdf } from "../routes/admin/offers-pdf";
|
||||||
|
import type { company_settings } from "@prisma/client";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
|
||||||
|
describe("selected custom fields encode/decode", () => {
|
||||||
|
it("encodes a non-empty index array to a JSON string", () => {
|
||||||
|
expect(encodeSelectedCustomFields([0, 2])).toBe("[0,2]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes empty / non-array to null", () => {
|
||||||
|
expect(encodeSelectedCustomFields([])).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields(undefined)).toBeNull();
|
||||||
|
expect(encodeSelectedCustomFields("nope")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes, sorts, and drops invalid entries before encoding", () => {
|
||||||
|
expect(encodeSelectedCustomFields([2, 0, 2, -1, 1.5, 3])).toBe("[0,2,3]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a stored string back to a number array", () => {
|
||||||
|
expect(parseSelectedCustomFields("[0,2]")).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses null / malformed to an empty array", () => {
|
||||||
|
expect(parseSelectedCustomFields(null)).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields("{garbage")).toEqual([]);
|
||||||
|
expect(parseSelectedCustomFields('"x"')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses already-array input (defensive) and filters invalid", () => {
|
||||||
|
expect(parseSelectedCustomFields([0, "1", 2, -3] as unknown)).toEqual([
|
||||||
|
0, 2,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offers selected_custom_fields round-trip", () => {
|
||||||
|
const created: number[] = [];
|
||||||
|
afterAll(async () => {
|
||||||
|
if (created.length)
|
||||||
|
await prisma.quotations.deleteMany({ where: { id: { in: created } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists selection on create and decodes it on detail", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [2, 0, 0],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBe("[0,2]");
|
||||||
|
|
||||||
|
const detail = await getOffer(res.id);
|
||||||
|
expect(detail?.selected_custom_fields).toEqual([0, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores null when selection is empty", async () => {
|
||||||
|
const res = (await createOffer({
|
||||||
|
status: "draft",
|
||||||
|
selected_custom_fields: [],
|
||||||
|
})) as { id: number };
|
||||||
|
created.push(res.id);
|
||||||
|
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
|
||||||
|
expect(row?.selected_custom_fields).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offer PDF prints only selected company custom fields", () => {
|
||||||
|
// Two company custom fields; the document selects only index 0.
|
||||||
|
const settings = {
|
||||||
|
company_name: "Test s.r.o.",
|
||||||
|
custom_fields: JSON.stringify({
|
||||||
|
fields: [
|
||||||
|
{ name: "Email", value: "selected@example.com", showLabel: false },
|
||||||
|
{ name: "Web", value: "notselected.example.com", showLabel: false },
|
||||||
|
],
|
||||||
|
field_order: [],
|
||||||
|
}),
|
||||||
|
logo_data: null,
|
||||||
|
} as unknown as company_settings;
|
||||||
|
|
||||||
|
const baseQuotation = {
|
||||||
|
quotation_number: "TEST-2098-001",
|
||||||
|
language: "EN",
|
||||||
|
currency: "EUR",
|
||||||
|
valid_until: new Date("2098-01-01"),
|
||||||
|
project_code: null,
|
||||||
|
created_at: new Date("2098-01-01"),
|
||||||
|
customers: { name: "Cust" },
|
||||||
|
quotation_items: [],
|
||||||
|
scope_sections: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("renders the selected custom field and omits the non-selected one", () => {
|
||||||
|
const html = renderOfferHtml(
|
||||||
|
{
|
||||||
|
...baseQuotation,
|
||||||
|
selected_custom_fields: "[0]",
|
||||||
|
} as unknown as OfferForPdf,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
expect(html).toContain("selected@example.com");
|
||||||
|
expect(html).not.toContain("notselected.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prints NO company custom fields when the document selects none", () => {
|
||||||
|
// The company call site always passes a parsed array; a null column
|
||||||
|
// decodes to [] → empty selection → no custom fields on the document.
|
||||||
|
const html = renderOfferHtml(
|
||||||
|
{
|
||||||
|
...baseQuotation,
|
||||||
|
selected_custom_fields: null,
|
||||||
|
} as unknown as OfferForPdf,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
|
expect(html).not.toContain("selected@example.com");
|
||||||
|
expect(html).not.toContain("notselected.example.com");
|
||||||
|
});
|
||||||
|
});
|
||||||
24
src/__tests__/version-header.test.ts
Normal file
24
src/__tests__/version-header.test.ts
Normal 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+/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
35
src/admin/components/UpdateBanner.tsx
Normal file
35
src/admin/components/UpdateBanner.tsx
Normal 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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
60
src/admin/components/document/CustomFieldsPrintPicker.tsx
Normal file
60
src/admin/components/document/CustomFieldsPrintPicker.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { FormControlLabel, Checkbox, Box, Typography } from "@mui/material";
|
||||||
|
import type { CompanySettingsCustomField } from "../../lib/queries/settings";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Company custom field definitions, in positional order (index = print key). */
|
||||||
|
fields: CompanySettingsCustomField[];
|
||||||
|
/** Currently selected positional indices. */
|
||||||
|
selected: number[];
|
||||||
|
/** Read-only (document not editable / locked by another user). */
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (next: number[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-document picker: choose which COMPANY custom fields print on this
|
||||||
|
* document's PDF. Selection is positional (matches the PDF `custom_<i>` keys).
|
||||||
|
* Renders nothing when the company has defined no printable custom fields.
|
||||||
|
*/
|
||||||
|
export default function CustomFieldsPrintPicker({
|
||||||
|
fields,
|
||||||
|
selected,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
}: Props) {
|
||||||
|
const printable = fields.filter((f) => (f.value || "").trim());
|
||||||
|
if (printable.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = (idx: number, checked: boolean) => {
|
||||||
|
const set = new Set(selected);
|
||||||
|
if (checked) set.add(idx);
|
||||||
|
else set.delete(idx);
|
||||||
|
onChange([...set].sort((a, b) => a - b));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
|
||||||
|
Vlastní pole na PDF
|
||||||
|
</Typography>
|
||||||
|
{fields.map((f, idx) => {
|
||||||
|
if (!(f.value || "").trim()) return null;
|
||||||
|
const label = (f.name || "").trim() ? `${f.name}: ${f.value}` : f.value;
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
key={idx}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={selected.includes(idx)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => toggle(idx, e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={<Typography variant="body2">{label}</Typography>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,10 +49,31 @@ export interface DocumentItem {
|
|||||||
quantity: string | number;
|
quantity: string | number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: string | number;
|
unit_price: string | number;
|
||||||
|
/** Per-item discount as a percentage (0–100). Offers only; 0 elsewhere. */
|
||||||
|
discount: string | number;
|
||||||
/** Offers only — undefined (issued orders) counts as included. */
|
/** Offers only — undefined (issued orders) counts as included. */
|
||||||
is_included_in_total?: boolean;
|
is_included_in_total?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discount input styling: centered, and with the native number spinners hidden
|
||||||
|
* so a 3-digit value ("100") isn't clipped under the up/down arrows.
|
||||||
|
*/
|
||||||
|
const discountInputSx = {
|
||||||
|
"& input": { textAlign: "center" },
|
||||||
|
"& input[type=number]": { MozAppearance: "textfield" as const },
|
||||||
|
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
|
||||||
|
WebkitAppearance: "none",
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** NET of one line after its per-item percentage discount. */
|
||||||
|
const discountedLineNet = (item: DocumentItem): number =>
|
||||||
|
(Number(item.quantity) || 0) *
|
||||||
|
(Number(item.unit_price) || 0) *
|
||||||
|
(1 - (Number(item.discount) || 0) / 100);
|
||||||
|
|
||||||
let documentItemKeyCounter = 0;
|
let documentItemKeyCounter = 0;
|
||||||
|
|
||||||
/** Unique client-side key for a (new or hydrated) document item row. */
|
/** Unique client-side key for a (new or hydrated) document item row. */
|
||||||
@@ -66,6 +87,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
|
discount: 0,
|
||||||
is_included_in_total: true,
|
is_included_in_total: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,9 +95,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
|
|||||||
export const documentItemsTotal = (items: DocumentItem[]): number =>
|
export const documentItemsTotal = (items: DocumentItem[]): number =>
|
||||||
items.reduce(
|
items.reduce(
|
||||||
(sum, it) =>
|
(sum, it) =>
|
||||||
it.is_included_in_total === false
|
it.is_included_in_total === false ? sum : sum + discountedLineNet(it),
|
||||||
? sum
|
|
||||||
: sum + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0),
|
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,6 +131,7 @@ function SortableItemRow({
|
|||||||
readOnly,
|
readOnly,
|
||||||
canDelete,
|
canDelete,
|
||||||
showIncludedInTotal,
|
showIncludedInTotal,
|
||||||
|
showDiscount,
|
||||||
itemDescriptionMaxLength,
|
itemDescriptionMaxLength,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onRemove,
|
onRemove,
|
||||||
@@ -121,6 +142,7 @@ function SortableItemRow({
|
|||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
showIncludedInTotal: boolean;
|
showIncludedInTotal: boolean;
|
||||||
|
showDiscount: boolean;
|
||||||
itemDescriptionMaxLength: number;
|
itemDescriptionMaxLength: number;
|
||||||
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
|
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
@@ -143,8 +165,7 @@ function SortableItemRow({
|
|||||||
position: "relative" as const,
|
position: "relative" as const,
|
||||||
zIndex: isDragging ? 10 : undefined,
|
zIndex: isDragging ? 10 : undefined,
|
||||||
};
|
};
|
||||||
const lineTotal =
|
const lineTotal = discountedLineNet(item);
|
||||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
@@ -253,6 +274,17 @@ function SortableItemRow({
|
|||||||
InputProps={{ readOnly }}
|
InputProps={{ readOnly }}
|
||||||
sx={{ "& input": { textAlign: "right" } }}
|
sx={{ "& input": { textAlign: "right" } }}
|
||||||
/>
|
/>
|
||||||
|
{showDiscount && (
|
||||||
|
<TextField
|
||||||
|
label="Sleva %"
|
||||||
|
type="number"
|
||||||
|
value={item.discount ?? ""}
|
||||||
|
onChange={(e) => onUpdate("discount", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
sx={discountInputSx}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -396,6 +428,18 @@ function SortableItemRow({
|
|||||||
sx={{ "& input": { textAlign: "right" } }}
|
sx={{ "& input": { textAlign: "right" } }}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{showDiscount && (
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
value={item.discount ?? ""}
|
||||||
|
onChange={(e) => onUpdate("discount", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||||
|
InputProps={{ readOnly }}
|
||||||
|
sx={discountInputSx}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
{showIncludedInTotal && (
|
{showIncludedInTotal && (
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -442,10 +486,18 @@ interface DocumentItemsEditorProps {
|
|||||||
error?: string;
|
error?: string;
|
||||||
/** Render the offers-only "V ceně" (is_included_in_total) column. */
|
/** Render the offers-only "V ceně" (is_included_in_total) column. */
|
||||||
showIncludedInTotal?: boolean;
|
showIncludedInTotal?: boolean;
|
||||||
|
/** Render the per-item "Sleva %" discount column (offers only). */
|
||||||
|
showDiscount?: boolean;
|
||||||
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
|
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -460,8 +512,10 @@ export default function DocumentItemsEditor({
|
|||||||
readOnly,
|
readOnly,
|
||||||
error,
|
error,
|
||||||
showIncludedInTotal = false,
|
showIncludedInTotal = 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"));
|
||||||
@@ -491,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;
|
||||||
@@ -561,8 +618,9 @@ 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}
|
||||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||||
onUpdate={(field, value) => updateItem(index, field, value)}
|
onUpdate={(field, value) => updateItem(index, field, value)}
|
||||||
onRemove={() => removeItem(index)}
|
onRemove={() => removeItem(index)}
|
||||||
@@ -594,6 +652,11 @@ export default function DocumentItemsEditor({
|
|||||||
<TableCell sx={{ width: "8rem" }} align="center">
|
<TableCell sx={{ width: "8rem" }} align="center">
|
||||||
Jedn. cena
|
Jedn. cena
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{showDiscount && (
|
||||||
|
<TableCell sx={{ width: "7rem" }} align="center">
|
||||||
|
Sleva %
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
{showIncludedInTotal && (
|
{showIncludedInTotal && (
|
||||||
<TableCell sx={{ width: "4rem" }} align="center">
|
<TableCell sx={{ width: "4rem" }} align="center">
|
||||||
V ceně
|
V ceně
|
||||||
@@ -613,8 +676,9 @@ 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}
|
||||||
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
itemDescriptionMaxLength={itemDescriptionMaxLength}
|
||||||
onUpdate={(field, value) =>
|
onUpdate={(field, value) =>
|
||||||
updateItem(index, field, value)
|
updateItem(index, field, value)
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface InvoiceItem {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
|
discount?: number;
|
||||||
vat_rate?: number;
|
vat_rate?: number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
position?: number;
|
position?: number;
|
||||||
@@ -84,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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export interface OfferItemData {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
|
discount?: number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
position?: number;
|
position?: number;
|
||||||
}
|
}
|
||||||
@@ -183,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) =>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -185,9 +186,34 @@ interface InvoiceItem {
|
|||||||
quantity: string | number;
|
quantity: string | number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: string | number;
|
unit_price: string | number;
|
||||||
|
/** Per-item discount as a percentage (0–100). */
|
||||||
|
discount: string | number;
|
||||||
vat_rate: number;
|
vat_rate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discount input styling: centered, native number spinners hidden so a 3-digit
|
||||||
|
* value ("100") isn't clipped under the up/down arrows.
|
||||||
|
*/
|
||||||
|
const discountInputSx = {
|
||||||
|
"& input": { textAlign: "center" },
|
||||||
|
"& input[type=number]": { MozAppearance: "textfield" as const },
|
||||||
|
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
|
||||||
|
WebkitAppearance: "none",
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** NET of one invoice line after its per-item percentage discount. */
|
||||||
|
const invoiceLineNet = (item: {
|
||||||
|
quantity: string | number;
|
||||||
|
unit_price: string | number;
|
||||||
|
discount?: string | number | null;
|
||||||
|
}): number =>
|
||||||
|
(Number(item.quantity) || 0) *
|
||||||
|
(Number(item.unit_price) || 0) *
|
||||||
|
(1 - (Number(item.discount) || 0) / 100);
|
||||||
|
|
||||||
interface InvoiceForm {
|
interface InvoiceForm {
|
||||||
customer_id: number | null;
|
customer_id: number | null;
|
||||||
customer_name: string;
|
customer_name: string;
|
||||||
@@ -253,8 +279,7 @@ function SortableInvoiceRow({
|
|||||||
position: "relative" as const,
|
position: "relative" as const,
|
||||||
zIndex: isDragging ? 10 : undefined,
|
zIndex: isDragging ? 10 : undefined,
|
||||||
};
|
};
|
||||||
const lineTotal =
|
const lineTotal = invoiceLineNet(item);
|
||||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
@@ -349,6 +374,14 @@ function SortableInvoiceRow({
|
|||||||
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
|
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
|
||||||
slotProps={{ htmlInput: { step: "any" } }}
|
slotProps={{ htmlInput: { step: "any" } }}
|
||||||
/>
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Sleva %"
|
||||||
|
type="number"
|
||||||
|
value={item.discount ?? ""}
|
||||||
|
onChange={(e) => onUpdate(index, "discount", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||||
|
sx={discountInputSx}
|
||||||
|
/>
|
||||||
{apply_vat && (
|
{apply_vat && (
|
||||||
<Select
|
<Select
|
||||||
label="DPH"
|
label="DPH"
|
||||||
@@ -458,6 +491,15 @@ function SortableInvoiceRow({
|
|||||||
sx={{ "& input": { textAlign: "right" } }}
|
sx={{ "& input": { textAlign: "right" } }}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
value={item.discount ?? ""}
|
||||||
|
onChange={(e) => onUpdate(index, "discount", e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
|
||||||
|
sx={discountInputSx}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
{apply_vat ? (
|
{apply_vat ? (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Select
|
<Select
|
||||||
@@ -513,6 +555,7 @@ export default function InvoiceDetail() {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
|
discount: 0,
|
||||||
vat_rate: 21,
|
vat_rate: 21,
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
@@ -569,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",
|
||||||
@@ -577,6 +623,7 @@ export default function InvoiceDetail() {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
|
discount: 0,
|
||||||
vat_rate: 21,
|
vat_rate: 21,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -628,6 +675,10 @@ export default function InvoiceDetail() {
|
|||||||
|
|
||||||
const invoiceQuery = useQuery(invoiceDetailOptions(id));
|
const invoiceQuery = useQuery(invoiceDetailOptions(id));
|
||||||
const invoice = invoiceQuery.data ?? null;
|
const invoice = invoiceQuery.data ?? null;
|
||||||
|
// Read-only view: show the Sleva column only when some line carries a discount.
|
||||||
|
const hasItemDiscount = (invoice?.items ?? []).some(
|
||||||
|
(it) => Number(it.discount) > 0,
|
||||||
|
);
|
||||||
|
|
||||||
const nextNumberQuery = useQuery({
|
const nextNumberQuery = useQuery({
|
||||||
queryKey: ["invoices", "next-number"],
|
queryKey: ["invoices", "next-number"],
|
||||||
@@ -751,6 +802,7 @@ export default function InvoiceDetail() {
|
|||||||
quantity: Number(item.quantity) || 1,
|
quantity: Number(item.quantity) || 1,
|
||||||
unit: item.unit || "",
|
unit: item.unit || "",
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
discount: Number(item.discount) || 0,
|
||||||
// Per-line VAT: hydrate from the ITEM's own stored rate (a real DB
|
// Per-line VAT: hydrate from the ITEM's own stored rate (a real DB
|
||||||
// column returned by the detail endpoint) — falling back to the
|
// column returned by the detail endpoint) — falling back to the
|
||||||
// invoice-level rate only when the line carries none. Hydrating
|
// invoice-level rate only when the line carries none. Hydrating
|
||||||
@@ -773,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);
|
||||||
@@ -848,6 +904,7 @@ export default function InvoiceDetail() {
|
|||||||
quantity: Number(item.quantity) || 1,
|
quantity: Number(item.quantity) || 1,
|
||||||
unit: (item.unit as string) || "",
|
unit: (item.unit as string) || "",
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
discount: Number(item.discount) || 0,
|
||||||
vat_rate: vatRate,
|
vat_rate: vatRate,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -874,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;
|
||||||
@@ -967,8 +1030,7 @@ export default function InvoiceDetail() {
|
|||||||
const vatByRate: Record<number, number> = {};
|
const vatByRate: Record<number, number> = {};
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
const lineTotal =
|
const lineTotal = invoiceLineNet(item);
|
||||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
|
||||||
subtotal += lineTotal;
|
subtotal += lineTotal;
|
||||||
|
|
||||||
if (form.apply_vat) {
|
if (form.apply_vat) {
|
||||||
@@ -1038,6 +1100,7 @@ export default function InvoiceDetail() {
|
|||||||
// field never reaches the server as NaN (mirrors createTotals).
|
// field never reaches the server as NaN (mirrors createTotals).
|
||||||
quantity: Number(item.quantity) || 0,
|
quantity: Number(item.quantity) || 0,
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
discount: Number(item.discount) || 0,
|
||||||
position: i,
|
position: i,
|
||||||
})),
|
})),
|
||||||
sections: sections.map((s, i) => ({
|
sections: sections.map((s, i) => ({
|
||||||
@@ -1046,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
|
||||||
@@ -1058,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}`);
|
||||||
}
|
}
|
||||||
@@ -1361,9 +1430,7 @@ export default function InvoiceDetail() {
|
|||||||
isMobile ? (
|
isMobile ? (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
{invoice.items.map((item, index) => {
|
{invoice.items.map((item, index) => {
|
||||||
const lineSubtotal =
|
const lineSubtotal = invoiceLineNet(item);
|
||||||
(Number(item.quantity) || 0) *
|
|
||||||
(Number(item.unit_price) || 0);
|
|
||||||
const lineVat = Number(invoice.apply_vat)
|
const lineVat = Number(invoice.apply_vat)
|
||||||
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
|
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
|
||||||
: 0;
|
: 0;
|
||||||
@@ -1494,6 +1561,11 @@ export default function InvoiceDetail() {
|
|||||||
<TableCell sx={{ width: "8rem" }} align="center">
|
<TableCell sx={{ width: "8rem" }} align="center">
|
||||||
Jedn. cena
|
Jedn. cena
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{hasItemDiscount && (
|
||||||
|
<TableCell sx={{ width: "4rem" }} align="center">
|
||||||
|
Sleva %
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell sx={{ width: "4rem" }} align="center">
|
<TableCell sx={{ width: "4rem" }} align="center">
|
||||||
DPH
|
DPH
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -1504,9 +1576,7 @@ export default function InvoiceDetail() {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{invoice.items.map((item, index) => {
|
{invoice.items.map((item, index) => {
|
||||||
const lineSubtotal =
|
const lineSubtotal = invoiceLineNet(item);
|
||||||
(Number(item.quantity) || 0) *
|
|
||||||
(Number(item.unit_price) || 0);
|
|
||||||
const lineVat = Number(invoice.apply_vat)
|
const lineVat = Number(invoice.apply_vat)
|
||||||
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
|
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
|
||||||
: 0;
|
: 0;
|
||||||
@@ -1555,6 +1625,13 @@ export default function InvoiceDetail() {
|
|||||||
>
|
>
|
||||||
{formatCurrency(item.unit_price, invoice.currency)}
|
{formatCurrency(item.unit_price, invoice.currency)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{hasItemDiscount && (
|
||||||
|
<TableCell align="center">
|
||||||
|
{Number(item.discount) > 0
|
||||||
|
? `${Number(item.discount)} %`
|
||||||
|
: "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
<TableCell align="center">
|
<TableCell align="center">
|
||||||
{Number(invoice.apply_vat)
|
{Number(invoice.apply_vat)
|
||||||
? Number(item.vat_rate)
|
? Number(item.vat_rate)
|
||||||
@@ -2152,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 */}
|
||||||
@@ -2238,6 +2324,9 @@ export default function InvoiceDetail() {
|
|||||||
<TableCell sx={{ width: "8rem" }} align="center">
|
<TableCell sx={{ width: "8rem" }} align="center">
|
||||||
Jedn. cena
|
Jedn. cena
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell sx={{ width: "7rem" }} align="center">
|
||||||
|
Sleva %
|
||||||
|
</TableCell>
|
||||||
{form.apply_vat ? (
|
{form.apply_vat ? (
|
||||||
<TableCell sx={{ width: "5rem" }} align="center">
|
<TableCell sx={{ width: "5rem" }} align="center">
|
||||||
DPH
|
DPH
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,8 +298,12 @@ export default function IssuedOrderDetail() {
|
|||||||
quantity: numberOr(it.quantity, 1),
|
quantity: numberOr(it.quantity, 1),
|
||||||
unit: it.unit || "",
|
unit: it.unit || "",
|
||||||
unit_price: Number(it.unit_price) || 0,
|
unit_price: Number(it.unit_price) || 0,
|
||||||
|
// Issued orders have no Sleva column; keep the shared item shape happy.
|
||||||
|
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 =
|
||||||
@@ -306,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
|
||||||
@@ -393,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;
|
||||||
@@ -428,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
|
||||||
@@ -872,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 */}
|
||||||
@@ -881,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 */}
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -93,6 +94,7 @@ interface OfferItemPayload {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
unit_price: number;
|
unit_price: number;
|
||||||
|
discount: number;
|
||||||
is_included_in_total: boolean;
|
is_included_in_total: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
}
|
}
|
||||||
@@ -113,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,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("");
|
||||||
@@ -269,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,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -303,6 +309,7 @@ export default function OfferDetail() {
|
|||||||
quantity: numberOr(it.quantity, 1),
|
quantity: numberOr(it.quantity, 1),
|
||||||
unit: it.unit || "",
|
unit: it.unit || "",
|
||||||
unit_price: Number(it.unit_price) || 0,
|
unit_price: Number(it.unit_price) || 0,
|
||||||
|
discount: Number(it.discount) || 0,
|
||||||
is_included_in_total: it.is_included_in_total !== false,
|
is_included_in_total: it.is_included_in_total !== false,
|
||||||
}))
|
}))
|
||||||
: [emptyDocumentItem()];
|
: [emptyDocumentItem()];
|
||||||
@@ -316,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);
|
||||||
@@ -433,6 +441,7 @@ export default function OfferDetail() {
|
|||||||
quantity: Number(item.quantity) || 0,
|
quantity: Number(item.quantity) || 0,
|
||||||
unit: item.unit,
|
unit: item.unit,
|
||||||
unit_price: Number(item.unit_price) || 0,
|
unit_price: Number(item.unit_price) || 0,
|
||||||
|
discount: Number(item.discount) || 0,
|
||||||
is_included_in_total: item.is_included_in_total !== false,
|
is_included_in_total: item.is_included_in_total !== false,
|
||||||
position: i,
|
position: i,
|
||||||
})),
|
})),
|
||||||
@@ -442,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
|
||||||
@@ -926,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) */}
|
||||||
@@ -936,6 +955,7 @@ export default function OfferDetail() {
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
error={errors.items}
|
error={errors.items}
|
||||||
showIncludedInTotal
|
showIncludedInTotal
|
||||||
|
showDiscount
|
||||||
itemDescriptionMaxLength={8000}
|
itemDescriptionMaxLength={8000}
|
||||||
templatesSlot={
|
templatesSlot={
|
||||||
itemTemplates && itemTemplates.length > 0 ? (
|
itemTemplates && itemTemplates.length > 0 ? (
|
||||||
@@ -957,6 +977,7 @@ export default function OfferDetail() {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
unit: "ks",
|
unit: "ks",
|
||||||
unit_price: template.default_price || 0,
|
unit_price: template.default_price || 0,
|
||||||
|
discount: 0,
|
||||||
is_included_in_total: true,
|
is_included_in_total: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
48
src/admin/utils/appVersion.ts
Normal file
48
src/admin/utils/appVersion.ts
Normal 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();
|
||||||
|
}
|
||||||
35
src/admin/utils/lazyWithReload.ts
Normal file
35
src/admin/utils/lazyWithReload.ts
Normal 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
33
src/middleware/version.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import { htmlToPdf } from "../../utils/html-to-pdf";
|
|||||||
import { getRate } from "../../services/exchange-rates";
|
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 { parseSelectedCustomFields } from "../../utils/custom-fields";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatNum,
|
formatNum,
|
||||||
@@ -33,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: [] };
|
||||||
|
|
||||||
@@ -88,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;
|
||||||
@@ -161,6 +166,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
col_desc: "Popis",
|
col_desc: "Popis",
|
||||||
col_qty: "Množství",
|
col_qty: "Množství",
|
||||||
col_unit_price: "Jedn. cena",
|
col_unit_price: "Jedn. cena",
|
||||||
|
col_discount: "Sleva",
|
||||||
col_price: "Cena",
|
col_price: "Cena",
|
||||||
col_vat_pct: "%DPH",
|
col_vat_pct: "%DPH",
|
||||||
col_vat: "DPH",
|
col_vat: "DPH",
|
||||||
@@ -208,6 +214,7 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
col_desc: "Description",
|
col_desc: "Description",
|
||||||
col_qty: "Quantity",
|
col_qty: "Quantity",
|
||||||
col_unit_price: "Unit price",
|
col_unit_price: "Unit price",
|
||||||
|
col_discount: "Discount",
|
||||||
col_price: "Price",
|
col_price: "Price",
|
||||||
col_vat_pct: "VAT%",
|
col_vat_pct: "VAT%",
|
||||||
col_vat: "VAT",
|
col_vat: "VAT",
|
||||||
@@ -312,6 +319,8 @@ export default async function invoicesPdfRoutes(
|
|||||||
where: { invoice_id: id },
|
where: { invoice_id: id },
|
||||||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||||||
});
|
});
|
||||||
|
// Sleva column is rendered only when at least one item has a discount.
|
||||||
|
const hasDiscount = items.some((it) => Number(it.discount) > 0);
|
||||||
|
|
||||||
let customer: Record<string, unknown> | null = null;
|
let customer: Record<string, unknown> | null = null;
|
||||||
if (invoice.customer_id) {
|
if (invoice.customer_id) {
|
||||||
@@ -362,7 +371,11 @@ export default async function invoicesPdfRoutes(
|
|||||||
let subtotal = 0;
|
let subtotal = 0;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const lineSubtotal = Number(item.quantity) * Number(item.unit_price);
|
const lineSubtotal = lineNet(
|
||||||
|
Number(item.quantity),
|
||||||
|
Number(item.unit_price),
|
||||||
|
Number(item.discount),
|
||||||
|
);
|
||||||
subtotal += lineSubtotal;
|
subtotal += lineSubtotal;
|
||||||
const rate = Number(item.vat_rate);
|
const rate = Number(item.vat_rate);
|
||||||
const key = String(rate);
|
const key = String(rate);
|
||||||
@@ -445,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
|
||||||
@@ -489,11 +510,16 @@ export default async function invoicesPdfRoutes(
|
|||||||
.map((item, i) => {
|
.map((item, i) => {
|
||||||
const qty = Number(item.quantity);
|
const qty = Number(item.quantity);
|
||||||
const unitPrice = Number(item.unit_price);
|
const unitPrice = Number(item.unit_price);
|
||||||
const lineSubtotal = qty * unitPrice;
|
const disc = Number(item.discount) || 0;
|
||||||
|
const lineSubtotal = lineNet(qty, unitPrice, disc);
|
||||||
const vatRate = Number(item.vat_rate);
|
const vatRate = Number(item.vat_rate);
|
||||||
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
const lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
|
||||||
const lineTotal = lineSubtotal + lineVat;
|
const lineTotal = lineSubtotal + lineVat;
|
||||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||||
|
const discDecimals = Math.floor(disc) === disc ? 0 : 2;
|
||||||
|
const discCell = hasDiscount
|
||||||
|
? `<td class="center">${disc > 0 ? `${formatNum(disc, discDecimals)} %` : "—"}</td>`
|
||||||
|
: "";
|
||||||
const subDesc = item.item_description || "";
|
const subDesc = item.item_description || "";
|
||||||
|
|
||||||
return `<tr>
|
return `<tr>
|
||||||
@@ -501,6 +527,7 @@ export default async function invoicesPdfRoutes(
|
|||||||
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
||||||
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
<td class="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
|
||||||
<td class="right">${formatNum(unitPrice)}</td>
|
<td class="right">${formatNum(unitPrice)}</td>
|
||||||
|
${discCell}
|
||||||
<td class="right">${formatNum(lineSubtotal)}</td>
|
<td class="right">${formatNum(lineSubtotal)}</td>
|
||||||
<td class="center">${applyVat ? Math.floor(vatRate) : 0}%</td>
|
<td class="center">${applyVat ? Math.floor(vatRate) : 0}%</td>
|
||||||
<td class="right">${formatNum(lineVat)}</td>
|
<td class="right">${formatNum(lineVat)}</td>
|
||||||
@@ -1052,9 +1079,10 @@ ${indentCSS}
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
|
||||||
<th style="width:36%">${escapeHtml(t.col_desc)}</th>
|
<th style="width:${hasDiscount ? 29 : 36}%">${escapeHtml(t.col_desc)}</th>
|
||||||
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
|
||||||
|
${hasDiscount ? `<th class="center" style="width:7%">${escapeHtml(t.col_discount)}</th>` : ""}
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th>
|
||||||
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
<th class="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
|
||||||
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
|
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { requirePermission } from "../../middleware/auth";
|
|||||||
import { nasOffersManager } from "../../services/nas-offers-manager";
|
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 { parseSelectedCustomFields } from "../../utils/custom-fields";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatNum,
|
formatNum,
|
||||||
@@ -28,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: [] };
|
||||||
|
|
||||||
@@ -84,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;
|
||||||
@@ -123,6 +128,7 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
|
|||||||
description: { EN: "Description", CZ: "Popis" },
|
description: { EN: "Description", CZ: "Popis" },
|
||||||
qty: { EN: "Qty", CZ: "Mn." },
|
qty: { EN: "Qty", CZ: "Mn." },
|
||||||
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
|
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
|
||||||
|
discount: { EN: "Discount", CZ: "Sleva" },
|
||||||
included: { EN: "Included", CZ: "Zahrnuto" },
|
included: { EN: "Included", CZ: "Zahrnuto" },
|
||||||
total: { EN: "Total", CZ: "Celkem" },
|
total: { EN: "Total", CZ: "Celkem" },
|
||||||
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
|
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
|
||||||
@@ -170,9 +176,15 @@ export function renderOfferHtml(
|
|||||||
let total = 0;
|
let total = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.is_included_in_total !== false) {
|
if (item.is_included_in_total !== false) {
|
||||||
total += (Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
|
total += lineNet(
|
||||||
|
Number(item.quantity) || 0,
|
||||||
|
Number(item.unit_price) || 0,
|
||||||
|
Number(item.discount),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sleva column is rendered only when at least one item carries a discount.
|
||||||
|
const hasDiscount = items.some((it) => Number(it.discount) > 0);
|
||||||
// Visibility uses the SAME per-language title rule as the render loop plus
|
// Visibility uses the SAME per-language title rule as the render loop plus
|
||||||
// a tag-stripped content check (an empty-Quill "<p><br></p>" doesn't count)
|
// a tag-stripped content check (an empty-Quill "<p><br></p>" doesn't count)
|
||||||
// — otherwise a section with only the other language's title would count as
|
// — otherwise a section with only the other language's title would count as
|
||||||
@@ -202,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
|
||||||
@@ -217,10 +233,15 @@ export function renderOfferHtml(
|
|||||||
let itemsHtml = "";
|
let itemsHtml = "";
|
||||||
items.forEach((item, i) => {
|
items.forEach((item, i) => {
|
||||||
const qty = Number(item.quantity) || 0;
|
const qty = Number(item.quantity) || 0;
|
||||||
const lineTotal = qty * (Number(item.unit_price) || 0);
|
const disc = Number(item.discount) || 0;
|
||||||
|
const lineTotal = lineNet(qty, Number(item.unit_price) || 0, disc);
|
||||||
// Integers render without decimals; fractional quantities keep 2 decimals
|
// Integers render without decimals; fractional quantities keep 2 decimals
|
||||||
// (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document).
|
// (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document).
|
||||||
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
|
||||||
|
const discDecimals = Math.floor(disc) === disc ? 0 : 2;
|
||||||
|
const discCell = hasDiscount
|
||||||
|
? `<td class="center">${disc > 0 ? `${formatNum(disc, discDecimals)} %` : "—"}</td>`
|
||||||
|
: "";
|
||||||
const subDesc = item.item_description || "";
|
const subDesc = item.item_description || "";
|
||||||
const evenClass = i % 2 === 1 ? ' class="even"' : "";
|
const evenClass = i % 2 === 1 ? ' class="even"' : "";
|
||||||
itemsHtml += `<tr${evenClass}>
|
itemsHtml += `<tr${evenClass}>
|
||||||
@@ -228,6 +249,7 @@ export function renderOfferHtml(
|
|||||||
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
|
||||||
<td class="center">${formatNum(qty, qtyDecimals)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
|
<td class="center">${formatNum(qty, qtyDecimals)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
|
||||||
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
|
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
|
||||||
|
${discCell}
|
||||||
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
|
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
});
|
});
|
||||||
@@ -621,10 +643,11 @@ ${indentCSS}
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="center" style="width:5%">${escapeHtml(t("no"))}</th>
|
<th class="center" style="width:5%">${escapeHtml(t("no"))}</th>
|
||||||
<th style="width:44%">${escapeHtml(t("description"))}</th>
|
<th style="width:${hasDiscount ? 36 : 44}%">${escapeHtml(t("description"))}</th>
|
||||||
<th class="center" style="width:13%">${escapeHtml(t("qty"))}</th>
|
<th class="center" style="width:12%">${escapeHtml(t("qty"))}</th>
|
||||||
<th class="right" style="width:18%">${escapeHtml(t("unit_price"))}</th>
|
<th class="right" style="width:16%">${escapeHtml(t("unit_price"))}</th>
|
||||||
<th class="right" style="width:20%">${escapeHtml(t("total"))}</th>
|
${hasDiscount ? `<th class="center" style="width:9%">${escapeHtml(t("discount"))}</th>` : ""}
|
||||||
|
<th class="right" style="width:18%">${escapeHtml(t("total"))}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const InvoiceItemSchema = z.object({
|
|||||||
quantity: positiveNumberFromForm.default(1),
|
quantity: positiveNumberFromForm.default(1),
|
||||||
unit: z.string().max(20).nullish(),
|
unit: z.string().max(20).nullish(),
|
||||||
unit_price: numberFromForm.default(0),
|
unit_price: numberFromForm.default(0),
|
||||||
|
// Per-item discount as a percentage (0–100); column is Decimal(5,2).
|
||||||
|
discount: numberInRange(0, 100).optional().default(0),
|
||||||
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
vat_rate: numberInRange(0, 100).optional().default(21.0),
|
||||||
position: nonNegativeNumberFromForm.optional(),
|
position: nonNegativeNumberFromForm.optional(),
|
||||||
});
|
});
|
||||||
@@ -48,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({
|
||||||
@@ -72,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>;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
nonNegativeIntFromForm,
|
nonNegativeIntFromForm,
|
||||||
nonNegativeNumberFromForm,
|
nonNegativeNumberFromForm,
|
||||||
positiveNumberFromForm,
|
positiveNumberFromForm,
|
||||||
|
numberInRange,
|
||||||
nullableIntIdFromForm,
|
nullableIntIdFromForm,
|
||||||
booleanFromForm,
|
booleanFromForm,
|
||||||
isoDateString,
|
isoDateString,
|
||||||
@@ -17,6 +18,8 @@ const QuotationItemSchema = z.object({
|
|||||||
quantity: positiveNumberFromForm.default(1),
|
quantity: positiveNumberFromForm.default(1),
|
||||||
unit: z.string().max(20).nullish(),
|
unit: z.string().max(20).nullish(),
|
||||||
unit_price: nonNegativeNumberFromForm.default(0),
|
unit_price: nonNegativeNumberFromForm.default(0),
|
||||||
|
// Per-item discount as a percentage (0–100); column is Decimal(5,2).
|
||||||
|
discount: numberInRange(0, 100).optional().default(0),
|
||||||
is_included_in_total: booleanFromForm.optional().default(true),
|
is_included_in_total: booleanFromForm.optional().default(true),
|
||||||
// Int column — a float position would 500 at Prisma.
|
// Int column — a float position would 500 at Prisma.
|
||||||
position: nonNegativeIntFromForm.optional(),
|
position: nonNegativeIntFromForm.optional(),
|
||||||
@@ -45,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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { utcMidnightOfLocalDay } from "../utils/date";
|
import { utcMidnightOfLocalDay } from "../utils/date";
|
||||||
|
import { lineNet } from "../utils/money";
|
||||||
import { toCzk } from "./exchange-rates";
|
import { toCzk } from "./exchange-rates";
|
||||||
import {
|
import {
|
||||||
generateInvoiceNumber,
|
generateInvoiceNumber,
|
||||||
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).
|
||||||
@@ -31,6 +36,8 @@ interface InvoiceItemInput {
|
|||||||
quantity?: number;
|
quantity?: number;
|
||||||
unit?: string | null;
|
unit?: string | null;
|
||||||
unit_price?: number;
|
unit_price?: number;
|
||||||
|
/** Per-item discount as a percentage (0–100). */
|
||||||
|
discount?: number;
|
||||||
vat_rate?: number;
|
vat_rate?: number;
|
||||||
position?: number;
|
position?: number;
|
||||||
}
|
}
|
||||||
@@ -165,14 +172,24 @@ function buildInvoiceWhere(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function computeInvoiceTotals(
|
function computeInvoiceTotals(
|
||||||
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
|
items: Array<{
|
||||||
|
quantity: unknown;
|
||||||
|
unit_price: unknown;
|
||||||
|
discount?: unknown;
|
||||||
|
vat_rate: unknown;
|
||||||
|
}>,
|
||||||
applyVat: boolean | null,
|
applyVat: boolean | null,
|
||||||
defaultVatRate: unknown,
|
defaultVatRate: unknown,
|
||||||
) {
|
) {
|
||||||
const { subtotal, vat: vatAmount } = computeMoney(
|
const { subtotal, vat: vatAmount } = computeMoney(
|
||||||
items,
|
items,
|
||||||
applyVat,
|
applyVat,
|
||||||
(i) => (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
(i) =>
|
||||||
|
lineNet(
|
||||||
|
Number(i.quantity) || 0,
|
||||||
|
Number(i.unit_price) || 0,
|
||||||
|
Number(i.discount),
|
||||||
|
),
|
||||||
// Preserve original semantics: a line vat_rate of 0 (non-null, non-"") IS used.
|
// Preserve original semantics: a line vat_rate of 0 (non-null, non-"") IS used.
|
||||||
(i) =>
|
(i) =>
|
||||||
i.vat_rate != null && i.vat_rate !== ""
|
i.vat_rate != null && i.vat_rate !== ""
|
||||||
@@ -305,6 +322,7 @@ export function invoiceTotalWithVat(inv: {
|
|||||||
invoice_items: Array<{
|
invoice_items: Array<{
|
||||||
quantity: { toNumber(): number } | null;
|
quantity: { toNumber(): number } | null;
|
||||||
unit_price: { toNumber(): number } | null;
|
unit_price: { toNumber(): number } | null;
|
||||||
|
discount?: { toNumber(): number } | null;
|
||||||
vat_rate: { toNumber(): number } | null;
|
vat_rate: { toNumber(): number } | null;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
@@ -312,8 +330,11 @@ export function invoiceTotalWithVat(inv: {
|
|||||||
inv.invoice_items,
|
inv.invoice_items,
|
||||||
inv.apply_vat,
|
inv.apply_vat,
|
||||||
(i) =>
|
(i) =>
|
||||||
(Number(i.quantity?.toNumber()) || 0) *
|
lineNet(
|
||||||
(Number(i.unit_price?.toNumber()) || 0),
|
Number(i.quantity?.toNumber()) || 0,
|
||||||
|
Number(i.unit_price?.toNumber()) || 0,
|
||||||
|
i.discount?.toNumber(),
|
||||||
|
),
|
||||||
// Preserve original `||` semantics: a line rate of 0/NaN falls through to
|
// Preserve original `||` semantics: a line rate of 0/NaN falls through to
|
||||||
// the invoice default, then 21.
|
// the invoice default, then 21.
|
||||||
(i) =>
|
(i) =>
|
||||||
@@ -401,7 +422,12 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
|
|||||||
const { vat } = computeMoney(
|
const { vat } = computeMoney(
|
||||||
inv.invoice_items,
|
inv.invoice_items,
|
||||||
inv.apply_vat,
|
inv.apply_vat,
|
||||||
(item) => (Number(item.quantity) || 0) * (Number(item.unit_price) || 0),
|
(item) =>
|
||||||
|
lineNet(
|
||||||
|
Number(item.quantity) || 0,
|
||||||
|
Number(item.unit_price) || 0,
|
||||||
|
Number(item.discount),
|
||||||
|
),
|
||||||
(item) => Number(item.vat_rate) || Number(inv.vat_rate) || 21,
|
(item) => Number(item.vat_rate) || Number(inv.vat_rate) || 21,
|
||||||
false, // accumulate raw per-line VAT; round only the per-currency sum
|
false, // accumulate raw per-line VAT; round only the per-currency sum
|
||||||
);
|
);
|
||||||
@@ -490,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,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,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,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -551,6 +583,7 @@ export async function createInvoice(body: InvoiceInput) {
|
|||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
unit: item.unit ?? null,
|
unit: item.unit ?? null,
|
||||||
unit_price: item.unit_price ?? 0,
|
unit_price: item.unit_price ?? 0,
|
||||||
|
discount: item.discount ?? 0,
|
||||||
vat_rate: item.vat_rate ?? 21.0,
|
vat_rate: item.vat_rate ?? 21.0,
|
||||||
position: item.position ?? i,
|
position: item.position ?? i,
|
||||||
})),
|
})),
|
||||||
@@ -629,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)
|
||||||
@@ -688,6 +725,7 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
|
|||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
unit: item.unit ?? null,
|
unit: item.unit ?? null,
|
||||||
unit_price: item.unit_price ?? 0,
|
unit_price: item.unit_price ?? 0,
|
||||||
|
discount: item.discount ?? 0,
|
||||||
vat_rate: item.vat_rate ?? 21.0,
|
vat_rate: item.vat_rate ?? 21.0,
|
||||||
position: item.position ?? i,
|
position: item.position ?? i,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
import { lineNet } from "../utils/money";
|
||||||
import {
|
import {
|
||||||
generateOfferNumber,
|
generateOfferNumber,
|
||||||
releaseOfferNumber,
|
releaseOfferNumber,
|
||||||
@@ -7,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;
|
||||||
@@ -14,6 +19,8 @@ interface QuotationItemInput {
|
|||||||
quantity?: number;
|
quantity?: number;
|
||||||
unit?: string;
|
unit?: string;
|
||||||
unit_price?: number;
|
unit_price?: number;
|
||||||
|
/** Per-item discount as a percentage (0–100). */
|
||||||
|
discount?: number;
|
||||||
is_included_in_total?: boolean;
|
is_included_in_total?: boolean;
|
||||||
position?: number;
|
position?: number;
|
||||||
}
|
}
|
||||||
@@ -61,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;
|
||||||
@@ -122,6 +130,7 @@ type EnrichableLineItem = {
|
|||||||
is_included_in_total?: unknown;
|
is_included_in_total?: unknown;
|
||||||
quantity?: unknown;
|
quantity?: unknown;
|
||||||
unit_price?: unknown;
|
unit_price?: unknown;
|
||||||
|
discount?: unknown;
|
||||||
};
|
};
|
||||||
type EnrichableQuotation = {
|
type EnrichableQuotation = {
|
||||||
quotation_items: EnrichableLineItem[];
|
quotation_items: EnrichableLineItem[];
|
||||||
@@ -138,7 +147,12 @@ function enrichQuotation<T extends EnrichableQuotation>(q: T) {
|
|||||||
.filter((i) => i.is_included_in_total !== false)
|
.filter((i) => i.is_included_in_total !== false)
|
||||||
.reduce(
|
.reduce(
|
||||||
(s: number, i) =>
|
(s: number, i) =>
|
||||||
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
|
s +
|
||||||
|
lineNet(
|
||||||
|
Number(i.quantity) || 0,
|
||||||
|
Number(i.unit_price) || 0,
|
||||||
|
Number(i.discount),
|
||||||
|
),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const { quotation_items, scope_sections, ...rest } = q;
|
const { quotation_items, scope_sections, ...rest } = q;
|
||||||
@@ -274,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,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,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,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -345,6 +365,7 @@ export async function createOffer(body: Record<string, unknown>) {
|
|||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
unit: item.unit ?? null,
|
unit: item.unit ?? null,
|
||||||
unit_price: item.unit_price ?? 0,
|
unit_price: item.unit_price ?? 0,
|
||||||
|
discount: item.discount ?? 0,
|
||||||
is_included_in_total: item.is_included_in_total !== false,
|
is_included_in_total: item.is_included_in_total !== false,
|
||||||
position: item.position ?? i,
|
position: item.position ?? i,
|
||||||
})),
|
})),
|
||||||
@@ -462,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(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -480,6 +505,7 @@ export async function updateOffer(id: number, body: Record<string, unknown>) {
|
|||||||
quantity: item.quantity ?? 1,
|
quantity: item.quantity ?? 1,
|
||||||
unit: item.unit ?? null,
|
unit: item.unit ?? null,
|
||||||
unit_price: item.unit_price ?? 0,
|
unit_price: item.unit_price ?? 0,
|
||||||
|
discount: item.discount ?? 0,
|
||||||
is_included_in_total: item.is_included_in_total !== false,
|
is_included_in_total: item.is_included_in_total !== false,
|
||||||
position: item.position ?? i,
|
position: item.position ?? i,
|
||||||
})),
|
})),
|
||||||
@@ -602,6 +628,7 @@ export async function duplicateOffer(id: number) {
|
|||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unit: item.unit,
|
unit: item.unit,
|
||||||
unit_price: item.unit_price,
|
unit_price: item.unit_price,
|
||||||
|
discount: item.discount,
|
||||||
is_included_in_total: item.is_included_in_total,
|
is_included_in_total: item.is_included_in_total,
|
||||||
position: item.position,
|
position: item.position,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
17
src/utils/money.ts
Normal file
17
src/utils/money.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Net amount of a single document line after its per-item percentage discount
|
||||||
|
* ("Sleva"). `net = qty × unit_price × (1 − discount/100)`.
|
||||||
|
*
|
||||||
|
* The discount is tolerant of null/undefined/NaN (a missing discount = none)
|
||||||
|
* and clamped to 0–100 so a stray value can never make the net negative.
|
||||||
|
* Single source of truth for offers + invoices (totals, VAT base, PDFs).
|
||||||
|
*/
|
||||||
|
export function lineNet(
|
||||||
|
qty: number,
|
||||||
|
unitPrice: number,
|
||||||
|
discountPct: number | null | undefined,
|
||||||
|
): number {
|
||||||
|
const pct = Number(discountPct);
|
||||||
|
const safePct = Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : 0;
|
||||||
|
return qty * unitPrice * (1 - safePct / 100);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user