Compare commits

...

23 Commits

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:19:46 +02:00
BOHA
719d1f2659 feat(documents): per-item Sleva (% discount) on offers + invoices
Adds an optional per-line-item percentage discount ("Sleva") to offers and
issued invoices, on both the detail-page editor and the PDF.

- DB: discount Decimal(5,2) DEFAULT 0 on quotation_items + invoice_items
  (migration 20260613195833_add_item_discount).
- Totals/VAT: new shared lineNet() (qty × price × (1 − discount/100)); offers'
  NET total and invoices' subtotal AND VAT now compute on the discounted net
  (every getBase/enrichQuotation/stats path). Backward compatible: discount 0
  is identical to before.
- Schemas: discount = numberInRange(0,100), default 0; persisted on
  create/update (+ offer duplicate).
- Detail editors: editable "Sleva %" column — offers via DocumentItemsEditor's
  new opt-in showDiscount prop (issued orders unaffected); invoices in their own
  editor. Spinners hidden + 7rem column so "100" is fully visible. Read-only
  invoice view shows the column when any line has a discount.
- PDFs (offers + invoices): conditional "Sleva"/"Discount" column rendered only
  when an item has a discount; line/total/VAT always use the discounted net.

Tests: +16 (money, totals incl. VAT-on-discounted-net, schema range, PDF
column present/absent). Full suite 665 pass, tsc -b clean, lint 0. Editor
verified live via Playwright. Design spec in docs/superpowers/specs/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:46:44 +02:00
BOHA
3787cc4dd0 docs(spec): per-item Sleva (% discount) on offers + invoices
Approved design: percentage per line item, reduces net (VAT on discounted
net), conditional PDF column, offers + issued invoices only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:56:43 +02:00
BOHA
1ee59b54bc chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix
UI fix:
- Close-only modals showing a redundant second close button now use
  hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
  buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.

Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
  of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
  useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
  AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
  preserving payload shape, minimal vite module type, frontend body/query-key
  types, SystemInfo for Settings.

Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.

Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 18:35:31 +02:00
BOHA
907ee574e6 chore(lint): remove all 34 dead-code warnings (verified site by site)
unused-vars (25): leftover imports/types across pages, routes and services;
write-only sickHours accumulator; unused holidayCount pair; test helpers
authPatch/authDelete; TOut dropped from ApiMutationOptions (the hook keeps
its own generic — no call-site changes); requireAuth no longer importable
in customers/warehouse routes (matches the no-bare-auth-reads convention).

useless-assignment (5): initializers proven overwritten on every path
(systemQty x2, hours, writtenSize -> let x: number) and the seed's dead
adminUser reassignment (create kept, assignment dropped).

useless-escape (4): [eE+\-] -> [eE+-] and [\/...] -> [/...] — identical
semantics.

Behavior-neutral; lint 102 -> 68 warnings (0 errors), suite 641 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:13:52 +02:00
BOHA
b51ea2505e feat(db): stock CHECK constraints + audit_logs DATETIME (2038 fix)
Migration 20260612234500: CHECK (quantity >= 0) on sklad_batches and
sklad_item_locations — the DB itself now rejects negative stock, so a
future C2-class bug (cumulative decrements driving a batch negative and
silently vanishing from totals) becomes a loud error instead of hidden
corruption. All three databases verified clean of negative rows before
the migration.

audit_logs.created_at TIMESTAMP(0) -> DATETIME(0): TIMESTAMP dies in 2038
and converts through the session timezone; existing wall times preserved
by the MODIFY.

Pinned by db-constraints.test.ts (4 tests, each watched fail pre-migration:
negative insert/update/location all succeeded before, 2098 audit timestamp
was rejected before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:56:56 +02:00
BOHA
fd2e613aa5 feat(ui): sidebar icon refresh + unified detail-page headers
Sidebar (user-approved design, docs/superpowers/specs/2026-06-12-sidebar-
icons-design.md): all 31 menu items get unique contextual stroke glyphs in
rounded-square tiles tinted per section (Docházka blue, Kniha jízd teal,
Administrativa amber, Sklad violet, Systém slate; new teal/violet/slate
palette entries with channel tokens in both schemes). Replaces the old set
where 13 items shared a glyph (3x people, 3x sliders, ...) and Faktury was a
dollar sign. Odin stays the only solid + animated tile. Uniqueness pinned by
navdata-icons.test.ts (tsconfig.test.json gains jsx for the .tsx import).

Headers: ProjectDetail and OrderDetail now match the Offer/Invoice shell —
number rendered in secondary color at regular weight next to the bold
entity word, and flexWrap on both left header boxes so the title drops
below Zpět on phones instead of overflowing the viewport (the reported
mobile bug on received orders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:37:44 +02:00
106 changed files with 4190 additions and 672 deletions

View File

@@ -46,6 +46,17 @@ without explicit user confirmation.**
errors from prior pids).
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
read-only pre-flight first: `prisma migrate status` on prod, verify FK
constraint names the migration DROPs, check no data violates new

View File

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

View File

@@ -0,0 +1,52 @@
# Sidebar icon refresh — design
**Date:** 2026-06-12 · **Status:** approved (visual companion session, 3 screens)
## Problem
All 31 sidebar items have icons, but 13 share a glyph with another item
(3× "people", 3× "sliders", 2× grid/bar-chart/box/history-clock) and Faktury
uses a dollar sign in an app that bills in Kč/EUR. Duplicated glyphs defeat
the purpose of icons as unique visual anchors.
## Decisions (user-approved)
1. **Full refresh** of all items (option B over targeted fix).
2. **Tile language (option B of 3 rendered directions):** every item's glyph
sits in a rounded-square tile (border-radius 32%, echoing OdinMark), as a
**soft color wash — one muted hue per menu section**:
Přehled `primary` (red) · Docházka `info` (blue) · Kniha jízd `teal` ·
Administrativa `warning` (amber) · Sklad `violet` · Systém `slate`.
3. **Odin stays unique:** the only **solid** (gradient) tile and the only
animated icon. No animation anywhere else. Red stays reserved for
Odin/primary.
4. **Glyph set** approved on the full-set screen: receipt for Faktury,
business card for Zákazníci, truck for Dodavatelé, stamp for Schvalování,
ledger ± for Správa bilancí, route/pin/car for Kniha jízd, barcode for
Inventura, shelf rack for Lokace, arrows into/out of a box for
Příjmy/Výdeje, warehouse hall for Sklad přehled, tag/cart/folder for
Nabídky/Objednávky/Projekty, gauge for Přehled, paper plane for Žádosti,
timesheet for Moje historie (docházka), shapes for Kategorie, chart frame
for Reporty, magnifier-over-list for Audit log. Kept concepts (now unique
in the set): clock (Záznam docházky), calendar (Plán prací), people
(Uživatelé), gear (Nastavení), box (Položky).
## Implementation
- `theme.ts`: add `teal`, `violet`, `slate` palette entries (light + dark
mains, full main/light/dark/contrastText so cssVariables emits `*Channel`
tokens) + TS module augmentation.
- `navData.tsx`: `MenuSection.hue` (palette key), new glyph SVGs
(stroke, 24-viewBox, width 2, round caps), `MenuItem.rawIcon` flag for
Odin (its icon ships its own tile).
- `SidebarNav.tsx`: renders the tile (26 px, radius 32%, `bgcolor:
rgba(<hue>.mainChannel / 0.12)`, glyph color `<hue>.main`, svg 16 px)
around every non-`rawIcon` item. No hardcoded hex; washes are
channel-alpha so both schemes work.
- Pinning test `navdata-icons.test.ts`: every non-raw icon renders to unique
static markup (no two items may ever share a glyph again).
## Out of scope
Animation for non-Odin icons (explicitly rejected), page-level icons,
mobile drawer changes (it reuses SidebarNav).

View File

@@ -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 (0100), 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, 0100
```
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 (0100);
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.

View File

@@ -0,0 +1,211 @@
# Per-document custom-field print selection
**Date:** 2026-06-16
**Status:** Approved design — ready for implementation plan
**Scope:** offers (quotations), issued orders, invoices
---
## Problem
Company custom fields are defined in company settings (`company_settings.custom_fields`,
a JSON blob of `{name, value, showLabel}[]` plus a `field_order`). They render in the
**sender/company block** of document PDFs via `buildAddressLines(settings, …)`.
Today **every** custom field with a value prints on **every** document PDF (offer, issued
order, invoice). The only existing per-field control is the company-settings checkbox
**"Zobrazit název v PDF"** (`showLabel`), which decides whether a printed field shows as
`Name: Value` or just `Value` — it does **not** control whether the field appears at all.
The user wants per-document control: on each offer / issued order / invoice **detail page**,
tick which company custom fields actually print on that specific document's PDF.
## Goals
- Each offer, issued order, and invoice independently selects which company custom fields
print in its PDF sender block.
- Selection lives on the document and is editable on its detail page.
- Default is **none selected** — existing documents and new drafts print no custom fields
until fields are deliberately ticked.
- The company-settings `showLabel` ("Zobrazit název v PDF") checkbox is **unchanged**.
## Non-goals
- Order confirmations ("orders" / `orders-pdf.ts`) are **out of scope** — they keep current
behavior (print all custom fields).
- No change to how custom fields are _defined_ in company settings.
- No stable per-field IDs — selection is **positional** (see Decisions).
## Decisions (locked)
1. **Default = none selected.** Existing rows get `NULL`; new documents start empty. A
document prints custom fields only for the indices it explicitly stores.
2. **Positional identity.** Selection stores field **positions** (`0,1,2…`) matching the
existing `custom_<i>` keys in the PDF code. No settings-structure change, no backfill.
Accepted caveat: reordering/deleting a custom field in company settings can shift what a
saved document's stored indices point at. Low risk — company fields rarely change, and
finalized PDFs are archived on NAS (served as-is, not re-rendered). The user accepted this
over the more robust stable-ID approach.
3. **Scope = 3 document types** (offers, issued orders, invoices). Order confirmations excluded.
4. **`showLabel` retained** in company settings, untouched.
---
## Design
### 1. Data storage
Add one nullable column to each of `quotations`, `issued_orders`, `invoices`:
| Column | Type | Meaning |
| ------------------------ | --------- | ------------------------------------------------------------- |
| `selected_custom_fields` | `VARCHAR` | JSON array of selected indices, e.g. `"[0,2]"`; `NULL` = none |
Stored as JSON-in-text, consistent with the existing `company_settings.custom_fields` /
`customers.custom_fields` pattern. `NULL`/empty ⇒ no custom fields print.
One tracked migration per the project's non-interactive `prisma migrate diff` recipe
(CLAUDE.md). **Apply to `app_test` as well** (`DATABASE_URL=<app_test> npx prisma migrate
deploy`) or the suite breaks. Commit `schema.prisma` + migration folder together.
### 2. Backend — schemas
Add to each document's **Create and Update** Zod schema:
```ts
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
```
Files:
- `src/schemas/offers.schema.ts``CreateQuotationSchema` (Update derives via `.partial().omit()`).
- `src/schemas/issued-orders.schema.ts``CreateIssuedOrderSchema` (Update derives via `.partial().omit()`).
- `src/schemas/invoices.schema.ts` — both `CreateInvoiceSchema` and `UpdateInvoiceSchema`
(invoices define the two schemas separately).
### 3. Backend — services
In each create/update service, persist the field inside the **existing transaction**:
- On write: `selected_custom_fields: Array.isArray(body.selected_custom_fields) &&
body.selected_custom_fields.length > 0 ? JSON.stringify(body.selected_custom_fields) : null`
- No other logic changes; sits alongside the existing header column assignments.
Files: `src/services/offers.service.ts`, `src/services/issued-orders.service.ts`,
`src/services/invoices.service.ts`.
### 4. Backend — detail responses
The detail endpoints spread the document row. Decode the stored string back into a
`number[]` so the frontend receives a clean array (default `[]` when `NULL`/malformed —
malformed JSON degrades gracefully with a logged warning, per the error convention). Add
`selected_custom_fields: number[]` to the corresponding detail interfaces in
`src/admin/lib/queries/{offers,issued-orders,invoices}.ts`.
### 5. PDF rendering
In each of `offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts`, extend the
company-block `buildAddressLines()` with an optional parameter:
```ts
buildAddressLines(entity, isSupplier, t, selectedCustomFields?: number[] | null)
```
When building `fieldMap`, only emit a `custom_<i>` entry when `selectedCustomFields`
includes `i`. Behavior:
- `null` / `undefined` / empty array ⇒ **no** custom lines (the new default).
- Standard address lines (name, street, city/postal, country, IČO/`company_id`,
DIČ/`vat_id`) are **unaffected** — always built as today.
- For the custom fields that _are_ selected, the existing `showLabel` (`Name: Value` vs
`Value`) and `field_order` ordering logic is preserved unchanged.
Only the **company/sender** block is filtered (that's where company custom fields render).
The customer block (offers/invoices) and supplier block (issued orders) keep their own
custom-field behavior unchanged — those come from `customers`/`sklad_suppliers`, not company
settings, and are not in scope.
The PDF route reads the document's `selected_custom_fields` column, parses it to `number[]`,
and passes it into `buildAddressLines(settings, …, selected)`.
> Archived-PDF note: finalized/numbered documents serve their archived NAS copy and won't
> change retroactively. Drafts (and any forced re-render) reflect the current selection.
### 6. Frontend — shared picker component
New shared component under `src/admin/components/document/` (e.g.
`CustomFieldsPrintPicker.tsx`), reused by all three detail pages (extend the shared module,
don't fork three copies — per CLAUDE.md document-module conventions).
Props (shape): the parsed company custom fields (`{name, value}[]` from
`companySettings.custom_fields`), the current selected indices, an `onChange`, and a
`disabled` flag.
Behavior:
- Renders a checkbox per company custom field; label shows the field's name (and/or value)
so the user knows what each is.
- Bound to local state on the detail page, seeded from the document's
`selected_custom_fields`.
- Hidden/empty when the company has no custom fields defined.
- Respects edit-lock and editable-status rules: read-only (`disabled`) when the document
isn't editable or is locked by another user, matching how the rest of the header fields
behave on that page.
- Included in the page's save payload as `selected_custom_fields: number[]`.
Pages: `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx` —
each already loads `companySettingsOptions()`, so the field definitions are available with
no new query.
Placement: in the editable header/meta area of each detail page, near the other
document-level settings (currency/language/etc.).
---
## Data flow
```
Company settings: custom_fields = [{name:"Tel.",…}, {name:"Web",…}, {name:"Datová schránka",…}]
idx 0 idx 1 idx 2
Offer detail page → user ticks "Tel." and "Datová schránka"
→ save payload selected_custom_fields: [0, 2]
→ service stores quotations.selected_custom_fields = "[0,2]"
Offer PDF render
→ read column "[0,2]" → [0,2]
→ buildAddressLines(settings, …, [0,2])
→ fieldMap emits custom_0 ("Tel.: …") and custom_2 ("Datová schránka: …"), skips custom_1
→ sender block prints Tel. and Datová schránka only
```
## Testing
Server-side Vitest against `app_test` (no Prisma mocking; mock Puppeteer/NAS only):
- Create/update each document with `selected_custom_fields` → column persists the JSON
string; empty/omitted → `NULL`.
- Detail response decodes to `number[]` (and `[]` for `NULL`).
- Schema: non-integer / negative entries rejected; omitted is valid.
- PDF: with a stubbed `html-to-pdf`, assert the rendered company block includes only the
selected custom field lines and still includes standard address lines; empty selection ⇒
no custom lines; standard lines unaffected.
- Regression: customer/supplier custom fields still render regardless of company selection.
## Migration & rollout
- One migration adding the three columns (all default `NULL`), applied to dev `app` and
`app_test`.
- No data backfill (none-selected default is the intended post-ship state).
- No production PDF changes for already-archived documents.
## Affected files (reference)
- `prisma/schema.prisma` (+ new migration folder)
- `src/schemas/offers.schema.ts`, `issued-orders.schema.ts`, `invoices.schema.ts`
- `src/services/offers.service.ts`, `issued-orders.service.ts`, `invoices.service.ts`
- `src/routes/admin/offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts`
- (detail route handlers, if decoding is done there rather than in the service)
- `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts`
- `src/admin/components/document/CustomFieldsPrintPicker.tsx` (new)
- `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx`

4
package-lock.json generated
View File

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

View File

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

View File

@@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE `audit_logs` MODIFY `created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0);
-- Stock invariants enforced by the database itself: a future cumulative-
-- decrement bug (the C2 class — duplicate issue lines driving a batch
-- negative and silently vanishing from stock totals) becomes a loud error
-- instead of hidden corruption. All databases verified clean of negative
-- rows before this migration (2026-06-12).
ALTER TABLE `sklad_batches`
ADD CONSTRAINT `chk_sklad_batches_quantity_nonneg` CHECK (`quantity` >= 0);
ALTER TABLE `sklad_item_locations`
ADD CONSTRAINT `chk_sklad_item_locations_quantity_nonneg` CHECK (`quantity` >= 0);

View File

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

View File

@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE `invoices` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
-- AlterTable
ALTER TABLE `issued_orders` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
-- AlterTable
ALTER TABLE `quotations` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;

View File

@@ -65,7 +65,9 @@ model audit_logs {
new_values String? @db.LongText
user_agent String? @db.Text
session_id String? @db.VarChar(128)
created_at DateTime? @default(now()) @db.Timestamp(0)
// DATETIME, not TIMESTAMP — TIMESTAMP dies in 2038 and converts through
// the session timezone (2026-06-12 hardening migration).
created_at DateTime? @default(now()) @db.DateTime(0)
@@index([action], map: "idx_audit_logs_action")
@@index([created_at], map: "idx_audit_logs_created")
@@ -206,6 +208,7 @@ model invoice_items {
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
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)
position Int? @default(0)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
@@ -236,6 +239,7 @@ model invoices {
billing_text String? @db.VarChar(500)
language String? @default("cs") @db.VarChar(5)
internal_notes String? @db.Text
selected_custom_fields String? @db.VarChar(255)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
invoice_items invoice_items[]
@@ -390,6 +394,7 @@ model issued_orders {
language String? @default("cs") @db.VarChar(5)
order_text String? @db.VarChar(500)
internal_notes String? @db.Text
selected_custom_fields String? @db.VarChar(255)
locked_by Int?
locked_at DateTime? @db.DateTime(0)
created_at DateTime? @default(now()) @db.DateTime(0)
@@ -503,6 +508,7 @@ model quotation_items {
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
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)
modified_at DateTime? @db.DateTime(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
@@ -523,6 +529,7 @@ model quotations {
status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
selected_custom_fields String? @db.VarChar(255)
locked_by Int?
locked_at DateTime? @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)

View File

@@ -391,12 +391,12 @@ async function main() {
console.log(` Admin role now has all ${allPerms.length} permissions`);
// 4. Ensure admin user exists
let adminUser = await prisma.users.findFirst({
const adminUser = await prisma.users.findFirst({
where: { username: "admin" },
});
if (!adminUser) {
const hash = bcrypt.hashSync("admin", 10);
adminUser = await prisma.users.create({
await prisma.users.create({
data: {
username: "admin",
email: "admin@boha.local",

View File

@@ -59,7 +59,7 @@ async function main() {
// fails we KEEP file_data and report the row as failed.
const expectedSize = rec.file_data.length;
const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath);
let writtenSize = -1;
let writtenSize: number;
if (readBack) {
writtenSize = readBack.data.length;
} else {

View File

@@ -0,0 +1,374 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`buildUserSectionHtml > produces a stable per-user section (full output pinned) 1`] = `
"<div class="user-section">
<div class="user-header">
<h3>Jan Novák</h3>
<span class="total">Odpracováno: 8:00 h</span>
</div>
<table>
<thead><tr>
<th style="width:55px">Datum</th>
<th style="width:55px">Den</th>
<th style="width:60px">Typ</th>
<th class="text-center" style="width:55px">Příchod</th>
<th class="text-center" style="width:80px">Pauza</th>
<th class="text-center" style="width:55px">Odchod</th>
<th class="text-center" style="width:85px">Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr></thead>
<tbody><tr class="weekend">
<td>1. 3. 2098</td>
<td>Sobota</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>2. 3. 2098</td>
<td>Neděle</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>3. 3. 2098</td>
<td>Pondělí</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>4. 3. 2098</td>
<td>Úterý</td>
<td>Práce</td>
<td class="text-center">08:00</td>
<td class="text-center">12:00 - 12:30</td>
<td class="text-center">16:30</td>
<td class="text-center">8:00 (8,00)</td>
<td style="font-size:8px"><div>Alpha (8:00h)</div></td>
<td>Poznámka &lt;b&gt;</td>
</tr><tr class="vacation">
<td>5. 3. 2098</td>
<td>Středa</td>
<td>Dovolená</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">8,00</td>
<td style="font-size:8px">—</td>
<td></td>
</tr><tr class="">
<td>6. 3. 2098</td>
<td>Čtvrtek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>7. 3. 2098</td>
<td>Pátek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>8. 3. 2098</td>
<td>Sobota</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>9. 3. 2098</td>
<td>Neděle</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>10. 3. 2098</td>
<td>Pondělí</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>11. 3. 2098</td>
<td>Úterý</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>12. 3. 2098</td>
<td>Středa</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>13. 3. 2098</td>
<td>Čtvrtek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>14. 3. 2098</td>
<td>Pátek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>15. 3. 2098</td>
<td>Sobota</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>16. 3. 2098</td>
<td>Neděle</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>17. 3. 2098</td>
<td>Pondělí</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>18. 3. 2098</td>
<td>Úterý</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>19. 3. 2098</td>
<td>Středa</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>20. 3. 2098</td>
<td>Čtvrtek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>21. 3. 2098</td>
<td>Pátek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>22. 3. 2098</td>
<td>Sobota</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>23. 3. 2098</td>
<td>Neděle</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>24. 3. 2098</td>
<td>Pondělí</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>25. 3. 2098</td>
<td>Úterý</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>26. 3. 2098</td>
<td>Středa</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>27. 3. 2098</td>
<td>Čtvrtek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>28. 3. 2098</td>
<td>Pátek</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>29. 3. 2098</td>
<td>Sobota</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="weekend">
<td>30. 3. 2098</td>
<td>Neděle</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr><tr class="">
<td>31. 3. 2098</td>
<td>Pondělí</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr></tbody>
</table>
<div class="user-summary">
<div class="summary-row">
<span class="summary-label">Odpracováno:</span>
<span class="summary-value">8,00h</span>
</div>
<div class="summary-row">
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
<span class="summary-value">16,00h</span>
</div>
<div class="summary-row">
<span class="summary-label">Dovolená:</span>
<span class="summary-value">8,00h</span>
</div>
<div class="summary-row">
<span class="summary-label">Přesčas:</span>
<span class="summary-value">8,00h</span>
</div>
<div class="summary-row">
<span class="summary-label">Svátek:</span>
<span class="summary-value">0,00h</span>
</div>
<div class="summary-row">
<span class="summary-label">So/Ne:</span>
<span class="summary-value">0,00h</span>
</div>
<div class="summary-row">
<span class="summary-label">Práce v noci:</span>
<span class="summary-value">0,00h</span>
</div>
<div class="summary-row summary-fund">
<span class="summary-label">Fond měsíce:</span>
<span class="summary-value">168,00h (21 dnů)</span>
</div>
<div class="legend">
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
<span class="legend-item"><span class="legend-swatch holiday"></span>Svátek</span>
<span class="legend-item"><span class="legend-swatch weekend-holiday"></span>Víkend + svátek</span>
<span class="legend-item"><span class="legend-swatch vacation"></span>Dovolená</span>
</div>
</div>
</div>"
`;

View File

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

View File

@@ -0,0 +1,155 @@
import { describe, it, expect } from "vitest";
import {
buildProjectLogsHtml,
buildUserSectionHtml,
buildPrintHtml,
} from "../admin/hooks/useAttendanceAdmin";
/**
* Characterization test for the attendance print-HTML builders.
*
* These builders had `any`-typed params; this pins their CURRENT output so the
* follow-up retyping (precise AttendanceRecord/UserTotal/PrintData types) is
* provably behavior-preserving. The only runtime touch in that retype is
* `parseInt(log.hours)` → `parseInt(String(log.hours))` to satisfy the typed
* `string | number | null` shape — the cases below exercise both string and
* numeric hours/minutes precisely so that equivalence is locked in.
*
* Fixtures are cast through `Parameters<typeof fn>` so the test compiles both
* before and after the param types tighten.
*/
type RecordArg = Parameters<typeof buildProjectLogsHtml>[0];
type UserDataArg = Parameters<typeof buildUserSectionHtml>[1];
type PrintDataArg = Parameters<typeof buildUserSectionHtml>[2];
const asRecord = (o: unknown) => o as unknown as RecordArg;
const asUserData = (o: unknown) => o as unknown as UserDataArg;
const asPrintData = (o: unknown) => o as unknown as PrintDataArg;
describe("buildProjectLogsHtml", () => {
it("formats hours/minutes given as strings", () => {
expect(
buildProjectLogsHtml(
asRecord({
project_logs: [
{ project_id: 1, project_name: "Alpha", hours: "2", minutes: "30" },
],
}),
),
).toBe("<div>Alpha (2:30h)</div>");
});
it("formats hours/minutes given as numbers (parseInt-of-number path)", () => {
expect(
buildProjectLogsHtml(
asRecord({
project_logs: [
{ project_id: 2, project_name: "Beta", hours: 1, minutes: 5 },
],
}),
),
).toBe("<div>Beta (1:05h)</div>");
});
it("derives duration from started_at/ended_at when hours are absent", () => {
expect(
buildProjectLogsHtml(
asRecord({
project_logs: [
{
project_id: 3,
project_name: "Gamma",
started_at: "2098-01-05T08:00:00",
ended_at: "2098-01-05T10:30:00",
},
],
}),
),
).toBe("<div>Gamma (2:30h)</div>");
});
it("falls back to '#id' and 0:00 when name and times are missing", () => {
expect(
buildProjectLogsHtml(asRecord({ project_logs: [{ project_id: 7 }] })),
).toBe("<div>#7 (0:00h)</div>");
});
it("renders the bare project name when there are no logs", () => {
expect(
buildProjectLogsHtml(
asRecord({ project_name: "Solo & <Co>", project_logs: [] }),
),
).toBe("Solo &amp; &lt;Co&gt;");
});
});
// Deterministic fixture: a fixed far-future month, one work day (with a numeric
// project log so the snapshot also guards the parseInt path in context) and one
// vacation day.
const workRecord = {
id: 1,
user_id: 5,
shift_date: "2098-03-04",
leave_type: "work",
arrival_time: "2098-03-04T08:00:00",
departure_time: "2098-03-04T16:30:00",
break_start: "2098-03-04T12:00:00",
break_end: "2098-03-04T12:30:00",
notes: "Poznámka <b>",
project_logs: [
{ project_id: 1, project_name: "Alpha", hours: 8, minutes: 0 },
],
};
const leaveRecord = {
id: 2,
user_id: 5,
shift_date: "2098-03-05",
leave_type: "vacation",
leave_hours: 8,
};
const userData = {
name: "Jan Novák",
minutes: 480,
records: [workRecord, leaveRecord],
};
const printData = {
month: "2098-03",
month_name: "Březen 2098",
selected_user_name: null,
user_totals: { "5": userData },
};
describe("buildUserSectionHtml", () => {
it("produces a stable per-user section (full output pinned)", () => {
const html = buildUserSectionHtml(
"5",
asUserData(userData),
asPrintData(printData),
);
expect(html).toMatchSnapshot();
});
});
describe("buildPrintHtml", () => {
// NB: the document footer embeds `new Date()`, so assert stable fragments
// rather than snapshotting the whole (non-deterministic) output.
it("wraps the user sections in a stable document shell", () => {
const section = buildUserSectionHtml(
"5",
asUserData(userData),
asPrintData(printData),
);
const html = buildPrintHtml(
asPrintData(printData),
section,
"",
"",
"Firma s.r.o.",
);
expect(html).toContain("<title>Docházka - Březen 2098</title>");
expect(html).toContain("EVIDENCE DOCHÁZKY");
expect(html).toContain('<div class="period">Březen 2098</div>');
expect(html).toContain("Firma s.r.o.");
expect(html).toContain(section);
});
});

View File

@@ -0,0 +1,136 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "../config/database";
/**
* Pins the 2026-06-12 DB hardening migration:
* 1. CHECK (quantity >= 0) on sklad_batches + sklad_item_locations — the DB
* itself rejects negative stock, so a future C2-class bug (cumulative
* decrements driving a batch negative) becomes a loud error instead of
* silently hidden corruption.
* 2. audit_logs.created_at is DATETIME, not TIMESTAMP — no 2038 cap.
*/
const N = "db_constraints_";
let itemId: number;
let locationId: number;
let receiptId: number;
let lineAId: number;
let lineBId: number;
async function cleanup() {
await prisma.sklad_batches.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_item_locations.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_receipt_lines.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } });
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
await prisma.sklad_locations.deleteMany({
where: { name: { contains: N } },
});
await prisma.audit_logs.deleteMany({
where: { description: { contains: N } },
});
}
beforeAll(async () => {
await cleanup();
const item = await prisma.sklad_items.create({
data: { name: `${N}item`, unit: "ks" },
});
itemId = item.id;
const location = await prisma.sklad_locations.create({
data: { code: `DBC${Date.now() % 100000}`, name: `${N}loc` },
});
locationId = location.id;
const receipt = await prisma.sklad_receipts.create({
data: { notes: `${N}receipt`, status: "CONFIRMED" },
});
receiptId = receipt.id;
const lineA = await prisma.sklad_receipt_lines.create({
data: {
receipt_id: receiptId,
item_id: itemId,
quantity: 5,
unit_price: 1,
},
});
lineAId = lineA.id;
const lineB = await prisma.sklad_receipt_lines.create({
data: {
receipt_id: receiptId,
item_id: itemId,
quantity: 5,
unit_price: 1,
},
});
lineBId = lineB.id;
});
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
describe("stock CHECK constraints", () => {
it("rejects inserting a negative batch quantity", async () => {
await expect(
prisma.sklad_batches.create({
data: {
item_id: itemId,
receipt_line_id: lineAId,
quantity: -1,
original_qty: 5,
unit_price: 1,
is_consumed: false,
},
}),
).rejects.toThrow();
});
it("rejects updating a batch quantity below zero", async () => {
const batch = await prisma.sklad_batches.create({
data: {
item_id: itemId,
receipt_line_id: lineBId,
quantity: 5,
original_qty: 5,
unit_price: 1,
is_consumed: false,
},
});
await expect(
prisma.$executeRaw`UPDATE sklad_batches SET quantity = -2 WHERE id = ${batch.id}`,
).rejects.toThrow();
const fresh = await prisma.sklad_batches.findUnique({
where: { id: batch.id },
});
expect(Number(fresh?.quantity)).toBe(5);
});
it("rejects a negative item_location quantity", async () => {
await expect(
prisma.sklad_item_locations.create({
data: { item_id: itemId, location_id: locationId, quantity: -1 },
}),
).rejects.toThrow();
});
});
describe("audit_logs.created_at has no 2038 cap", () => {
it("accepts a far-future timestamp (DATETIME, not TIMESTAMP)", async () => {
const row = await prisma.audit_logs.create({
data: {
action: "test",
description: `${N}future`,
created_at: new Date(Date.UTC(2098, 0, 15, 12, 0, 0)),
},
});
expect(row.created_at?.getUTCFullYear()).toBe(2098);
});
});

View File

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

View File

@@ -169,7 +169,10 @@ describe("createIssuedOrder", () => {
it("numbers immediately when created already-finalized (status sent)", async () => {
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.status).toBe("sent");
});
@@ -199,7 +202,9 @@ describe("createIssuedOrder", () => {
describe("updateIssuedOrder status transitions", () => {
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" });
expect("error" in ok).toBe(false);
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 () => {
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: "confirmed" });
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 });
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", () => {
@@ -525,7 +570,9 @@ describe("POST /api/admin/issued-orders legacy dropped fields", () => {
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 () => {
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: "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
// full payload + status in ONE PUT while the order is still editable
// (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" });
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 () => {
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: "confirmed" });
@@ -733,6 +784,23 @@ describe("renderIssuedOrderHtml", () => {
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Č", () => {
const html = renderIssuedOrderHtml(
order,

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

View 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 0100, default to 0 when absent, and reject out-of-range values.
*/
describe("offer item discount schema", () => {
it("accepts a 0100 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 0100 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);
});
});

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

View File

@@ -0,0 +1,117 @@
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
/**
* Guards against the "two Zavřít buttons" bug class.
*
* The shared <Modal> (src/admin/ui/Modal.tsx) ALWAYS renders a primary submit
* button (text = `submitText`) and — unless `hideCancel` is set — a secondary
* cancel button (text = `cancelText`, default "Zrušit"). A close-only modal
* therefore sets `submitText="Zavřít"`; if it ALSO leaves the cancel button on,
* the user sees two buttons that both just close the dialog — and when
* `cancelText` is "Zavřít" too, two literally-identical "Zavřít" buttons
* (the production bug found on the "paid received invoice" detail modal).
*
* Rule enforced: any <Modal> whose `submitText` is a "Zavřít…" label (i.e. its
* only action is to close) MUST pass `hideCancel`. Close-only modals get a
* single button. See PlanCellModal / PlanCategoriesModal for the correct shape.
*/
const ADMIN_DIR = join(__dirname, "..", "admin");
function tsxFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) out.push(...tsxFiles(full));
else if (entry.name.endsWith(".tsx")) out.push(full);
}
return out;
}
/**
* Extract every `<Modal …>` opening-tag substring from a source string.
* Brace- and string-aware so arrow functions (`=>`) and conditional props
* (`{cond ? "a" : "b"}`) inside the tag don't terminate it early.
*/
function modalOpeningTags(src: string): string[] {
const tags: string[] = [];
let i = 0;
while ((i = src.indexOf("<Modal", i)) !== -1) {
const after = src[i + 6];
// Must be the <Modal> component, not <ModalSomething>.
if (after && !/[\s>/]/.test(after)) {
i += 6;
continue;
}
let depth = 0;
let quote: string | null = null;
let j = i + 6;
for (; j < src.length; j++) {
const c = src[j];
if (quote) {
if (c === quote && src[j - 1] !== "\\") quote = null;
continue;
}
if (c === '"' || c === "'" || c === "`") quote = c;
else if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) break;
}
tags.push(src.slice(i, j + 1));
i = j + 1;
}
return tags;
}
/** Value of a JSX prop: `name="..."`, `name={...}` (balanced), or boolean. */
function propValue(tag: string, name: string): string | null {
const re = new RegExp(`\\b${name}\\b`);
const m = re.exec(tag);
if (!m) return null;
let k = m.index + m[0].length;
while (k < tag.length && /\s/.test(tag[k])) k++;
if (tag[k] !== "=") return ""; // boolean prop (e.g. `hideCancel`)
k++;
while (k < tag.length && /\s/.test(tag[k])) k++;
if (tag[k] === '"' || tag[k] === "'") {
const q = tag[k];
const end = tag.indexOf(q, k + 1);
return tag.slice(k + 1, end);
}
if (tag[k] === "{") {
let depth = 0;
for (let p = k; p < tag.length; p++) {
if (tag[p] === "{") depth++;
else if (tag[p] === "}" && --depth === 0) return tag.slice(k, p + 1);
}
}
return null;
}
describe("Modal close-only buttons", () => {
const offenders: string[] = [];
for (const file of tsxFiles(ADMIN_DIR)) {
const src = readFileSync(file, "utf8");
for (const tag of modalOpeningTags(src)) {
const submit = propValue(tag, "submitText");
const isCloseOnly = submit != null && submit.includes("Zavřít");
const hasHideCancel = propValue(tag, "hideCancel") != null;
if (isCloseOnly && !hasHideCancel) {
const title = propValue(tag, "title") ?? "(no title)";
offenders.push(`${file.replace(ADMIN_DIR, "admin")}${title}`);
}
}
}
it("close-only modals (submitText='Zavřít') set hideCancel — no duplicate close button", () => {
expect(
offenders,
`These <Modal>s render a close-only 'Zavřít' submit alongside a redundant ` +
`cancel button (two buttons that both just close). Add hideCancel:\n` +
offenders.map((o) => `${o}`).join("\n"),
).toEqual([]);
});
});

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

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { isValidElement, type ReactElement } from "react";
import { menuSections, type MenuItem } from "../admin/ui/navData";
/**
* Pins the sidebar icon-refresh contract (2026-06-12 design): every menu
* item has a glyph, and no two items share one — duplicated glyphs were the
* whole problem the refresh fixed (3× "people", 3× "sliders", …). Odin's
* rawIcon (OdinMark, MUI-styled + animated) is exempt from rendering here
* but still must exist and stay the ONLY rawIcon.
*/
const allItems: Array<MenuItem & { section: string }> = menuSections.flatMap(
(s) => s.items.map((item) => ({ section: s.label, ...item })),
);
describe("sidebar nav icons", () => {
it("every item has an icon and a section hue", () => {
for (const section of menuSections) {
expect(section.hue, `section ${section.label}`).toBeTruthy();
}
for (const item of allItems) {
expect(isValidElement(item.icon), `${item.section}/${item.label}`).toBe(
true,
);
}
});
it("Odin is the only rawIcon (its own tile + animation stay unique)", () => {
const raw = allItems.filter((i) => i.rawIcon);
expect(raw.map((i) => i.label)).toEqual(["Odin"]);
});
it("no two items share a glyph", () => {
const rendered = allItems
.filter((i) => !i.rawIcon)
.map((item) => ({
key: `${item.section}/${item.label}`,
markup: renderToStaticMarkup(item.icon as ReactElement),
}));
const seen = new Map<string, string>();
for (const { key, markup } of rendered) {
const dupe = seen.get(markup);
expect(dupe, `${key} shares its glyph with ${dupe}`).toBeUndefined();
seen.set(markup, key);
}
expect(seen.size).toBe(rendered.length);
});
});

View File

@@ -1090,26 +1090,6 @@ async function authPost(path: string, token: string, body: unknown) {
});
}
async function authPatch(path: string, token: string, body: unknown) {
return app.inject({
method: "PATCH",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
async function authDelete(path: string, token: string) {
return app.inject({
method: "DELETE",
url: path,
headers: { Authorization: `Bearer ${token}` },
});
}
beforeAll(async () => {
app = await buildApp();

View File

@@ -0,0 +1,129 @@
import { describe, it, expect } from "vitest";
import {
encodeSelectedCustomFields,
parseSelectedCustomFields,
} from "../utils/custom-fields";
import { afterAll } from "vitest";
import { createOffer, getOffer } from "../services/offers.service";
import { renderOfferHtml } from "../routes/admin/offers-pdf";
import type { OfferForPdf } from "../routes/admin/offers-pdf";
import type { company_settings } from "@prisma/client";
import prisma from "../config/database";
describe("selected custom fields encode/decode", () => {
it("encodes a non-empty index array to a JSON string", () => {
expect(encodeSelectedCustomFields([0, 2])).toBe("[0,2]");
});
it("encodes empty / non-array to null", () => {
expect(encodeSelectedCustomFields([])).toBeNull();
expect(encodeSelectedCustomFields(undefined)).toBeNull();
expect(encodeSelectedCustomFields("nope")).toBeNull();
});
it("dedupes, sorts, and drops invalid entries before encoding", () => {
expect(encodeSelectedCustomFields([2, 0, 2, -1, 1.5, 3])).toBe("[0,2,3]");
});
it("parses a stored string back to a number array", () => {
expect(parseSelectedCustomFields("[0,2]")).toEqual([0, 2]);
});
it("parses null / malformed to an empty array", () => {
expect(parseSelectedCustomFields(null)).toEqual([]);
expect(parseSelectedCustomFields("")).toEqual([]);
expect(parseSelectedCustomFields("{garbage")).toEqual([]);
expect(parseSelectedCustomFields('"x"')).toEqual([]);
});
it("parses already-array input (defensive) and filters invalid", () => {
expect(parseSelectedCustomFields([0, "1", 2, -3] as unknown)).toEqual([
0, 2,
]);
});
});
describe("offers selected_custom_fields round-trip", () => {
const created: number[] = [];
afterAll(async () => {
if (created.length)
await prisma.quotations.deleteMany({ where: { id: { in: created } } });
});
it("persists selection on create and decodes it on detail", async () => {
const res = (await createOffer({
status: "draft",
selected_custom_fields: [2, 0, 0],
})) as { id: number };
created.push(res.id);
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
expect(row?.selected_custom_fields).toBe("[0,2]");
const detail = await getOffer(res.id);
expect(detail?.selected_custom_fields).toEqual([0, 2]);
});
it("stores null when selection is empty", async () => {
const res = (await createOffer({
status: "draft",
selected_custom_fields: [],
})) as { id: number };
created.push(res.id);
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
expect(row?.selected_custom_fields).toBeNull();
});
});
describe("offer PDF prints only selected company custom fields", () => {
// Two company custom fields; the document selects only index 0.
const settings = {
company_name: "Test s.r.o.",
custom_fields: JSON.stringify({
fields: [
{ name: "Email", value: "selected@example.com", showLabel: false },
{ name: "Web", value: "notselected.example.com", showLabel: false },
],
field_order: [],
}),
logo_data: null,
} as unknown as company_settings;
const baseQuotation = {
quotation_number: "TEST-2098-001",
language: "EN",
currency: "EUR",
valid_until: new Date("2098-01-01"),
project_code: null,
created_at: new Date("2098-01-01"),
customers: { name: "Cust" },
quotation_items: [],
scope_sections: [],
};
it("renders the selected custom field and omits the non-selected one", () => {
const html = renderOfferHtml(
{
...baseQuotation,
selected_custom_fields: "[0]",
} as unknown as OfferForPdf,
settings,
);
expect(html).toContain("selected@example.com");
expect(html).not.toContain("notselected.example.com");
});
it("prints NO company custom fields when the document selects none", () => {
// The company call site always passes a parsed array; a null column
// decodes to [] → empty selection → no custom fields on the document.
const html = renderOfferHtml(
{
...baseQuotation,
selected_custom_fields: null,
} as unknown as OfferForPdf,
settings,
);
expect(html).not.toContain("selected@example.com");
expect(html).not.toContain("notselected.example.com");
});
});

View File

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

View File

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

View File

@@ -148,7 +148,7 @@ const TrashIcon = (
);
export default function PlanCellModal(props: Props) {
const { open, mode, onClose } = props;
const { open, mode } = props;
if (!open || mode.kind === "closed") return null;
if (mode.kind === "view") return <ViewModal {...props} />;
if (mode.kind === "day-in-range")
@@ -488,6 +488,7 @@ function DayInRangeModal(
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít — ponechat rozsah beze změny"
hideCancel
maxWidth="md"
>
<Typography variant="body2" sx={{ mb: 2 }}>

View File

@@ -491,9 +491,11 @@ export default function PlanGrid({
}, [projects]);
// Memoize the day list so it isn't rebuilt on every render (only when the
// visible range changes). Stable identity also keeps the row map cheap.
const dateFrom = data?.date_from;
const dateTo = data?.date_to;
const days = useMemo(
() => (data ? eachDay(data.date_from, data.date_to) : []),
[data?.date_from, data?.date_to],
() => (dateFrom && dateTo ? eachDay(dateFrom, dateTo) : []),
[dateFrom, dateTo],
);
if (!data) return <LoadingState />;

View File

@@ -19,46 +19,22 @@ import {
} from "../utils/attendanceHelpers";
// ---------- Shared types ----------
export interface ShiftFormData {
user_id: string;
shift_date: string;
leave_type: string;
leave_hours: number;
arrival_date: string;
arrival_time: string;
break_start_date: string;
break_start_time: string;
break_end_date: string;
break_end_time: string;
departure_date: string;
departure_time: string;
notes: string;
}
export interface ProjectLog {
_key?: string;
id?: number;
project_id: string | number;
hours: string | number;
minutes: string | number;
}
export interface Project {
id: number | string;
project_number: string;
name: string;
}
export interface User {
id: number | string;
name: string;
}
export interface EditingRecord {
user_name: string;
shift_date: string;
}
// Definitions live in the dependency-free ./shiftFormTypes module; re-exported
// here so existing importers of ShiftFormModal keep working.
export type {
ShiftFormData,
ProjectLog,
Project,
User,
EditingRecord,
} from "./shiftFormTypes";
import type {
ShiftFormData,
ProjectLog,
Project,
User,
EditingRecord,
} from "./shiftFormTypes";
// ---------- Sub-component props ----------

View File

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

View File

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

View File

@@ -132,8 +132,9 @@ export default function DashSessions() {
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
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
sx={{
display: "flex",

View File

@@ -0,0 +1,60 @@
import { FormControlLabel, Checkbox, Box, Typography } from "@mui/material";
import type { CompanySettingsCustomField } from "../../lib/queries/settings";
interface Props {
/** Company custom field definitions, in positional order (index = print key). */
fields: CompanySettingsCustomField[];
/** Currently selected positional indices. */
selected: number[];
/** Read-only (document not editable / locked by another user). */
disabled?: boolean;
onChange: (next: number[]) => void;
}
/**
* Per-document picker: choose which COMPANY custom fields print on this
* document's PDF. Selection is positional (matches the PDF `custom_<i>` keys).
* Renders nothing when the company has defined no printable custom fields.
*/
export default function CustomFieldsPrintPicker({
fields,
selected,
disabled = false,
onChange,
}: Props) {
const printable = fields.filter((f) => (f.value || "").trim());
if (printable.length === 0) return null;
const toggle = (idx: number, checked: boolean) => {
const set = new Set(selected);
if (checked) set.add(idx);
else set.delete(idx);
onChange([...set].sort((a, b) => a - b));
};
return (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Vlastní pole na PDF
</Typography>
{fields.map((f, idx) => {
if (!(f.value || "").trim()) return null;
const label = (f.name || "").trim() ? `${f.name}: ${f.value}` : f.value;
return (
<FormControlLabel
key={idx}
control={
<Checkbox
size="small"
checked={selected.includes(idx)}
disabled={disabled}
onChange={(e) => toggle(idx, e.target.checked)}
/>
}
label={<Typography variant="body2">{label}</Typography>}
/>
);
})}
</Box>
);
}

View File

@@ -49,10 +49,31 @@ export interface DocumentItem {
quantity: string | number;
unit: string;
unit_price: string | number;
/** Per-item discount as a percentage (0100). Offers only; 0 elsewhere. */
discount: string | number;
/** Offers only — undefined (issued orders) counts as included. */
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;
/** Unique client-side key for a (new or hydrated) document item row. */
@@ -66,6 +87,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
quantity: 1,
unit: "ks",
unit_price: 0,
discount: 0,
is_included_in_total: true,
});
@@ -73,9 +95,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
export const documentItemsTotal = (items: DocumentItem[]): number =>
items.reduce(
(sum, it) =>
it.is_included_in_total === false
? sum
: sum + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0),
it.is_included_in_total === false ? sum : sum + discountedLineNet(it),
0,
);
@@ -111,6 +131,7 @@ function SortableItemRow({
readOnly,
canDelete,
showIncludedInTotal,
showDiscount,
itemDescriptionMaxLength,
onUpdate,
onRemove,
@@ -121,6 +142,7 @@ function SortableItemRow({
readOnly: boolean;
canDelete: boolean;
showIncludedInTotal: boolean;
showDiscount: boolean;
itemDescriptionMaxLength: number;
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
onRemove: () => void;
@@ -143,8 +165,7 @@ function SortableItemRow({
position: "relative" as const,
zIndex: isDragging ? 10 : undefined,
};
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const lineTotal = discountedLineNet(item);
if (isMobile) {
return (
@@ -253,6 +274,17 @@ function SortableItemRow({
InputProps={{ readOnly }}
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
sx={{
@@ -396,6 +428,18 @@ function SortableItemRow({
sx={{ "& input": { textAlign: "right" } }}
/>
</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 && (
<TableCell align="center">
<Checkbox
@@ -442,10 +486,18 @@ interface DocumentItemsEditorProps {
error?: string;
/** Render the offers-only "V ceně" (is_included_in_total) column. */
showIncludedInTotal?: boolean;
/** Render the per-item "Sleva %" discount column (offers only). */
showDiscount?: boolean;
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */
itemDescriptionMaxLength?: number;
/** Optional page-specific control (e.g. item-template Select) next to the add button. */
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,
error,
showIncludedInTotal = false,
showDiscount = false,
itemDescriptionMaxLength = 5000,
templatesSlot,
allowEmpty = false,
}: DocumentItemsEditorProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
@@ -491,10 +545,13 @@ export default function DocumentItemsEditor({
const addItem = () => onChange([...items, emptyDocumentItem()]);
const removeItem = (index: number) => {
if (items.length <= 1) return;
if (!allowEmpty && items.length <= 1) return;
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 { active, over } = event;
if (!over || active.id === over.id) return;
@@ -561,8 +618,9 @@ export default function DocumentItemsEditor({
index={index}
currency={currency}
readOnly={readOnly}
canDelete={items.length > 1}
canDelete={canDeleteRow}
showIncludedInTotal={showIncludedInTotal}
showDiscount={showDiscount}
itemDescriptionMaxLength={itemDescriptionMaxLength}
onUpdate={(field, value) => updateItem(index, field, value)}
onRemove={() => removeItem(index)}
@@ -594,6 +652,11 @@ export default function DocumentItemsEditor({
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
{showDiscount && (
<TableCell sx={{ width: "7rem" }} align="center">
Sleva %
</TableCell>
)}
{showIncludedInTotal && (
<TableCell sx={{ width: "4rem" }} align="center">
V ceně
@@ -613,8 +676,9 @@ export default function DocumentItemsEditor({
index={index}
currency={currency}
readOnly={readOnly}
canDelete={items.length > 1}
canDelete={canDeleteRow}
showIncludedInTotal={showIncludedInTotal}
showDiscount={showDiscount}
itemDescriptionMaxLength={itemDescriptionMaxLength}
onUpdate={(field, value) =>
updateItem(index, field, value)

View File

@@ -0,0 +1,44 @@
// Shared attendance shift-form types, kept in a dependency-free module so they
// can be imported without pulling in the ShiftFormModal component graph (which
// reaches MUI/import.meta files that don't compile under the CommonJS test
// project). ShiftFormModal re-exports these for backward compatibility.
export interface ShiftFormData {
user_id: string;
shift_date: string;
leave_type: string;
leave_hours: number;
arrival_date: string;
arrival_time: string;
break_start_date: string;
break_start_time: string;
break_end_date: string;
break_end_time: string;
departure_date: string;
departure_time: string;
notes: string;
}
export interface ProjectLog {
_key?: string;
id?: number;
project_id: string | number;
hours: string | number;
minutes: string | number;
}
export interface Project {
id: number | string;
project_number: string;
name: string;
}
export interface User {
id: number | string;
name: string;
}
export interface EditingRecord {
user_name: string;
shift_date: string;
}

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import MenuItem from "@mui/material/MenuItem";
import { Select } from "../../ui";
@@ -26,7 +26,7 @@ export default function ReservationPicker({
}),
);
const reservations = result?.data ?? [];
const reservations = useMemo(() => result?.data ?? [], [result?.data]);
// When item/project change, the loaded reservation list changes. If the
// currently-selected reservation is no longer available, clear it and notify

View File

@@ -43,9 +43,10 @@ export function AlertProvider({ children }: { children: ReactNode }) {
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
useEffect(() => {
const timeouts = timeoutsRef.current;
return () => {
timeoutsRef.current.forEach(clearTimeout);
timeoutsRef.current.clear();
timeouts.forEach(clearTimeout);
timeouts.clear();
};
}, []);

View File

@@ -122,6 +122,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
);
}
},
// silentRefresh and setAccessTokenFn reference each other; listing
// silentRefresh here would recreate both callbacks on every render. The
// scheduled silentRefresh is safe to call as a stable closure because all
// of its state lives in refs.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);

View File

@@ -29,7 +29,7 @@ import type {
ProjectLog,
Project,
User,
} from "../components/ShiftFormModal";
} from "../components/shiftFormTypes";
// ---------------------------------------------------------------------------
// Types
@@ -234,7 +234,6 @@ function computeUserTotals(
}
const holidayDates = holidaysInMonth(yr, mo + 1);
const holidayCount = holidayDates.length;
for (const uid of Object.keys(totals)) {
const t = totals[uid];
@@ -345,14 +344,25 @@ function escapeHtml(str: string): string {
.replace(/'/g, "&#039;");
}
function buildProjectLogsHtml(record: Record<string, any>): string {
/** Shape of the print endpoint payload consumed by the HTML builders. */
interface PrintData {
month: string;
month_name: string;
selected_user_name?: string | null;
user_totals: Record<string, UserTotal>;
}
export function buildProjectLogsHtml(record: AttendanceRecord): string {
if (record.project_logs && record.project_logs.length > 0) {
return record.project_logs
.map((log: any) => {
.map((log) => {
let h: number, m: number;
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(log.hours) || 0;
m = parseInt(log.minutes) || 0;
// hours/minutes arrive as string or number — String() before parseInt
// (parseInt already coerces to string internally, so this is a no-op
// at runtime, it just satisfies parseInt's string parameter type).
h = parseInt(String(log.hours)) || 0;
m = parseInt(String(log.minutes)) || 0;
} else if (log.started_at && log.ended_at) {
const mins = Math.max(
0,
@@ -375,13 +385,13 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
return escapeHtml(record.project_name || "—");
}
function buildUserSectionHtml(
export function buildUserSectionHtml(
userId: string,
userData: Record<string, any>,
printData: Record<string, any>,
userData: UserTotal,
printData: PrintData,
): string {
// Build a date-keyed lookup of the user's records.
const recordsByDate = new Map<string, Record<string, any>>();
const recordsByDate = new Map<string, AttendanceRecord>();
for (const r of userData.records || []) {
recordsByDate.set(normalizeDateStr(r.shift_date), r);
}
@@ -392,7 +402,7 @@ function buildUserSectionHtml(
const mo = parseInt(monthStr, 10);
const daysInMonth = getDaysInMonth(yr, mo);
const userRecords = (userData.records || []) as Record<string, any>[];
const userRecords = userData.records ?? [];
const rows: string[] = [];
for (let d = 1; d <= daysInMonth; d++) {
@@ -476,14 +486,12 @@ function buildUserSectionHtml(
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
// "holiday" leave_type is auto-computed from Czech holidays further down
// and no longer selectable in the UI, so it's deliberately omitted here.
let vacationHours = 0,
sickHours = 0;
let vacationHours = 0;
for (const r of userRecords) {
const lt = r.leave_type || "work";
if (lt === "work") continue;
const h = Number(r.leave_hours) || 8;
if (lt === "vacation") vacationHours += h;
else if (lt === "sick") sickHours += h;
}
// Odpracováno: floor(worked / 8) × 8.
@@ -492,7 +500,6 @@ function buildUserSectionHtml(
// Free Svátek: 8h per holiday date the user did not work.
const holidayDates = holidaysInMonth(yr, mo);
const holidayCount = holidayDates.length;
const workedDates = new Set(
userRecords
.filter((r) => (r.leave_type || "work") === "work")
@@ -592,8 +599,8 @@ function buildUserSectionHtml(
</div>`;
}
function buildPrintHtml(
pData: Record<string, any>,
export function buildPrintHtml(
pData: PrintData,
userSections: string,
emptyMsg: string,
filterNote: string,
@@ -1334,7 +1341,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
const pData = result.data;
const userSections = Object.entries(pData.user_totals)
.map(([uid, uData]) =>
buildUserSectionHtml(uid, uData as Record<string, any>, pData),
buildUserSectionHtml(uid, uData as UserTotal, pData),
)
.join("");
const emptyMsg =

View File

@@ -200,7 +200,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// Snapshot the currently-cached grid for a given key. Returns
// `undefined` if there's nothing cached (we don't patch empty data).
function snapshotGrid(key: readonly unknown[]): GridData | undefined {
return qc.getQueryData<GridData>(key as any);
return qc.getQueryData<GridData>(key);
}
// Apply a per-date cell patch to the cached grid at `key`. Returns the
@@ -312,7 +312,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
function rollbackCells(ctx: CellRollback | undefined) {
if (!ctx || !ctx.rolled) return;
const prev = qc.getQueryData<GridData>(ctx.key as any);
const prev = qc.getQueryData<GridData>(ctx.key);
if (!prev) return;
const userPrev = prev.cells[ctx.userId] ?? {};
const userNext = { ...userPrev };
@@ -379,7 +379,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// Find the user that owns this entry in the current grid by
// looking for any cell with entryId === id (we already know
// the id from vars; it doesn't change across the update).
const grid = qc.getQueryData<GridData>(currentGridKey as any);
const grid = qc.getQueryData<GridData>(currentGridKey);
let ownerUserId: number | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
@@ -430,7 +430,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// For delete we need to know the entry's user_id and full range.
// Look it up from the current grid: find the user that has a cell
// with entryId === id, and read rangeFrom/rangeTo from that cell.
const grid = qc.getQueryData<GridData>(currentGridKey as any);
const grid = qc.getQueryData<GridData>(currentGridKey);
if (!grid) return null;
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
let entryRange: { from: string; to: string } | null = null;
@@ -511,7 +511,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
onMutate: (vars): CellRollback => {
// Find the user/date for this overrideId in the current grid, then
// patch that single cell with the new values.
const grid = qc.getQueryData<GridData>(currentGridKey as any);
const grid = qc.getQueryData<GridData>(currentGridKey);
if (!grid) return null;
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cells] of Object.entries(byDate)) {
@@ -559,7 +559,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
method: "DELETE",
}),
onMutate: (vars): CellRollback => {
const grid = qc.getQueryData<GridData>(currentGridKey as any);
const grid = qc.getQueryData<GridData>(currentGridKey);
if (!grid) return null;
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cells] of Object.entries(byDate)) {
@@ -600,7 +600,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// PlanWork.tsx so they continue to compile unchanged; by the time a
// `mutateAsync` promise rejects, the mutation's own `onError` has already
// restored the patched cells, so there is nothing left to do here.
function rollbackMutation(_mutation: unknown, _userId: number) {
/* no-op — see onMutate/onError above */
}

View File

@@ -38,6 +38,7 @@ export interface InvoiceItem {
quantity: number;
unit: string;
unit_price: number;
discount?: number;
vat_rate?: number;
is_included_in_total: boolean;
position?: number;
@@ -84,6 +85,7 @@ export interface InvoiceDetail {
bank_account_id?: number | null;
items?: InvoiceItem[];
sections?: InvoiceSection[];
selected_custom_fields?: number[];
subtotal: number;
vat_amount: number;
total: number;

View File

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

View File

@@ -92,7 +92,7 @@ async function performMutation<TIn, TOut>(opts: {
return result.data as TOut;
}
export interface ApiMutationOptions<TIn, TOut> {
export interface ApiMutationOptions<TIn> {
url: string | ((input: TIn) => string);
method: HttpMethod | ((input: TIn) => HttpMethod);
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
@@ -122,7 +122,7 @@ export interface ApiMutationOptions<TIn, TOut> {
* as `ApiError` (with `.status` attached) so callers can branch on HTTP code.
*/
export function useApiMutation<TIn, TOut>(
opts: ApiMutationOptions<TIn, TOut> &
opts: ApiMutationOptions<TIn> &
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
) {
const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;

View File

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

View File

@@ -1,5 +1,4 @@
import { queryOptions } from "@tanstack/react-query";
import apiFetch from "../../utils/api";
import { jsonQuery } from "../apiAdapter";
export interface PlanUser {

View File

@@ -1,5 +1,5 @@
import { useState, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext";
@@ -9,7 +9,6 @@ import { iconBadgeSx } from "../theme";
import {
attendanceHistoryOptions,
type AttendanceRecord,
type ProjectLogEntry,
} from "../lib/queries/attendance";
import {
formatDate,
@@ -201,7 +200,6 @@ const renderProjectCell = (record: AttendanceRecord) => {
export default function AttendanceHistory() {
const { user, hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName = companySettings?.company_name || "";
const printRef = useRef<HTMLDivElement>(null);
@@ -212,7 +210,7 @@ export default function AttendanceHistory() {
const { data, isPending } = useQuery(
attendanceHistoryOptions({ month, userId: user?.id }),
);
const records = data ?? [];
const records = useMemo(() => data ?? [], [data]);
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split("-");

View File

@@ -26,10 +26,7 @@ const LocationMap = styled("div")(({ theme }) => ({
}));
import { formatDate, formatTime } from "../utils/attendanceHelpers";
import {
attendanceLocationOptions,
type LocationRecord,
} from "../lib/queries/attendance";
import { attendanceLocationOptions } from "../lib/queries/attendance";
import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
const BackIcon = (

View File

@@ -3,10 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import { companySettingsOptions } from "../lib/queries/settings";
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
@@ -23,7 +20,6 @@ import {
TextField,
Select,
CheckboxField,
StatusChip,
FileUpload,
LoadingState,
type DataColumn,

View File

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

View File

@@ -27,6 +27,7 @@ import RichEditor from "../components/RichEditor";
import SectionsEditor, {
type DocumentSection,
} from "../components/document/SectionsEditor";
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import {
DndContext,
closestCenter,
@@ -185,9 +186,34 @@ interface InvoiceItem {
quantity: string | number;
unit: string;
unit_price: string | number;
/** Per-item discount as a percentage (0100). */
discount: string | 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 {
customer_id: number | null;
customer_name: string;
@@ -253,8 +279,7 @@ function SortableInvoiceRow({
position: "relative" as const,
zIndex: isDragging ? 10 : undefined,
};
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const lineTotal = invoiceLineNet(item);
if (isMobile) {
return (
@@ -349,6 +374,14 @@ function SortableInvoiceRow({
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
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 && (
<Select
label="DPH"
@@ -458,6 +491,15 @@ function SortableInvoiceRow({
sx={{ "& input": { textAlign: "right" } }}
/>
</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 ? (
<TableCell>
<Select
@@ -513,6 +555,7 @@ export default function InvoiceDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
discount: 0,
vat_rate: 21,
}),
[],
@@ -569,6 +612,9 @@ export default function InvoiceDetail() {
// Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF —
// shared editor with offers/issued orders).
const [sections, setSections] = useState<DocumentSection[]>([]);
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [items, setItems] = useState<InvoiceItem[]>(() => [
{
_key: "inv-1",
@@ -577,6 +623,7 @@ export default function InvoiceDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
discount: 0,
vat_rate: 21,
},
]);
@@ -621,10 +668,17 @@ export default function InvoiceDetail() {
const customers = customersQuery.data ?? [];
const bankAccountsQuery = useQuery(bankAccountsOptions());
const bankAccounts = bankAccountsQuery.data ?? [];
const bankAccounts = useMemo(
() => bankAccountsQuery.data ?? [],
[bankAccountsQuery.data],
);
const invoiceQuery = useQuery(invoiceDetailOptions(id));
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({
queryKey: ["invoices", "next-number"],
@@ -656,8 +710,9 @@ export default function InvoiceDetail() {
const [deleting, setDeleting] = useState(false);
useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
blobTimeouts.forEach(clearTimeout);
};
}, []);
@@ -747,6 +802,7 @@ export default function InvoiceDetail() {
quantity: Number(item.quantity) || 1,
unit: item.unit || "",
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
// column returned by the detail endpoint) — falling back to the
// invoice-level rate only when the line carries none. Hydrating
@@ -769,11 +825,15 @@ export default function InvoiceDetail() {
: [];
setSections(mappedSections);
const mappedCustomFields = inv.selected_custom_fields ?? [];
setSelectedCustomFields(mappedCustomFields);
// Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
sections: mappedSections,
selectedCustomFields: mappedCustomFields,
});
setDataReady(true);
@@ -844,6 +904,7 @@ export default function InvoiceDetail() {
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
vat_rate: vatRate,
})),
);
@@ -870,15 +931,21 @@ export default function InvoiceDetail() {
// Edit mode: captured inside the sync effect from raw query data.
// Create mode: captured on the first render after sync effects populate the form.
if (dataReady && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
selectedCustomFields,
});
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
return (
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
JSON.stringify({ form, items, sections, selectedCustomFields }) !==
initialSnapshotRef.current
);
}, [form, items, sections]);
}, [form, items, sections, selectedCustomFields]);
useEffect(() => {
if (!isDirty) return;
@@ -963,8 +1030,7 @@ export default function InvoiceDetail() {
const vatByRate: Record<number, number> = {};
items.forEach((item) => {
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const lineTotal = invoiceLineNet(item);
subtotal += lineTotal;
if (form.apply_vat) {
@@ -1034,6 +1100,7 @@ export default function InvoiceDetail() {
// field never reaches the server as NaN (mirrors createTotals).
quantity: Number(item.quantity) || 0,
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
position: i,
})),
sections: sections.map((s, i) => ({
@@ -1042,6 +1109,7 @@ export default function InvoiceDetail() {
content: s.content,
position: i,
})),
selected_custom_fields: selectedCustomFields,
};
// Only set status when a target is given (create-as-draft / create-as-live
// / finalize). Editing a live invoice sends no status — the backend keeps
@@ -1054,7 +1122,12 @@ export default function InvoiceDetail() {
const data = await saveMutation.mutateAsync(payload);
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
selectedCustomFields,
});
if (!isEdit) {
navigate(`/invoices/${data.invoice_id}`);
}
@@ -1357,9 +1430,7 @@ export default function InvoiceDetail() {
isMobile ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{invoice.items.map((item, index) => {
const lineSubtotal =
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0);
const lineSubtotal = invoiceLineNet(item);
const lineVat = Number(invoice.apply_vat)
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
: 0;
@@ -1490,6 +1561,11 @@ export default function InvoiceDetail() {
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
{hasItemDiscount && (
<TableCell sx={{ width: "4rem" }} align="center">
Sleva %
</TableCell>
)}
<TableCell sx={{ width: "4rem" }} align="center">
DPH
</TableCell>
@@ -1500,9 +1576,7 @@ export default function InvoiceDetail() {
</TableHead>
<TableBody>
{invoice.items.map((item, index) => {
const lineSubtotal =
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0);
const lineSubtotal = invoiceLineNet(item);
const lineVat = Number(invoice.apply_vat)
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
: 0;
@@ -1551,6 +1625,13 @@ export default function InvoiceDetail() {
>
{formatCurrency(item.unit_price, invoice.currency)}
</TableCell>
{hasItemDiscount && (
<TableCell align="center">
{Number(item.discount) > 0
? `${Number(item.discount)} %`
: "—"}
</TableCell>
)}
<TableCell align="center">
{Number(invoice.apply_vat)
? Number(item.vat_rate)
@@ -2148,6 +2229,15 @@ export default function InvoiceDetail() {
]}
/>
</Field>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={false}
onChange={setSelectedCustomFields}
/>
</Box>
</Card>
{/* Items */}
@@ -2234,6 +2324,9 @@ export default function InvoiceDetail() {
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
<TableCell sx={{ width: "7rem" }} align="center">
Sleva %
</TableCell>
{form.apply_vat ? (
<TableCell sx={{ width: "5rem" }} align="center">
DPH

View File

@@ -23,6 +23,7 @@ import SectionsEditor, {
type DocumentSection,
} from "../components/document/SectionsEditor";
import LockBanner from "../components/document/LockBanner";
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import { useDocumentLock } from "../hooks/useDocumentLock";
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
import { useDocumentPdf } from "../hooks/useDocumentPdf";
@@ -137,6 +138,7 @@ interface IssuedOrderSavePayload {
internal_notes: string;
items: IssuedOrderItemPayload[];
sections: IssuedOrderSectionPayload[];
selected_custom_fields: number[];
status?: string;
}
@@ -164,6 +166,9 @@ export default function IssuedOrderDetail() {
emptyDocumentItem(),
]);
const [sections, setSections] = useState<DocumentSection[]>([]);
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
// Which save action is in flight ("draft" | the live status | "save"), so
@@ -255,7 +260,7 @@ export default function IssuedOrderDetail() {
const canExport = hasPermission("orders.view");
const { isDirty, markClean } = useUnsavedChangesGuard(
{ form, items, sections },
{ form, items, sections, selectedCustomFields },
!isEdit || dataReady,
);
@@ -293,8 +298,12 @@ export default function IssuedOrderDetail() {
quantity: numberOr(it.quantity, 1),
unit: it.unit || "",
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);
const mappedSections =
@@ -306,6 +315,7 @@ export default function IssuedOrderDetail() {
}))
: [];
setSections(mappedSections);
setSelectedCustomFields(d.selected_custom_fields ?? []);
setLockedBy(d.locked_by ?? null);
// Acquire the edit lock when nobody holds it, the order is still
@@ -393,8 +403,18 @@ export default function IssuedOrderDetail() {
const newErrors: Record<string, string> = {};
if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele";
if (!form.order_date) newErrors.order_date = "Zadejte datum";
if (items.length === 0 || items.every((i) => !i.description.trim())) {
newErrors.items = "Přidejte alespoň jednu položku";
// Items are optional (an order may be issued by sections alone), but a
// 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);
if (Object.keys(newErrors).length > 0) return;
@@ -428,6 +448,7 @@ export default function IssuedOrderDetail() {
content: s.content,
position: i,
})),
selected_custom_fields: selectedCustomFields,
};
// Only set status when a target is given (create-as-draft / create-as-live
// / finalize). Editing a live order sends no status — the backend keeps
@@ -872,6 +893,15 @@ export default function IssuedOrderDetail() {
placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)"
/>
</Field>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={!editable}
onChange={setSelectedCustomFields}
/>
</Box>
</Card>
{/* Items */}
@@ -881,6 +911,7 @@ export default function IssuedOrderDetail() {
currency={form.currency}
readOnly={!editable}
error={errors.items}
allowEmpty
/>
{/* Rich-text PDF sections — the free-form PDF content lives here */}

View File

@@ -33,6 +33,7 @@ import SectionsEditor, {
type DocumentSection,
} from "../components/document/SectionsEditor";
import LockBanner from "../components/document/LockBanner";
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import { useDocumentLock } from "../hooks/useDocumentLock";
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
import { useDocumentPdf } from "../hooks/useDocumentPdf";
@@ -93,6 +94,7 @@ interface OfferItemPayload {
quantity: number;
unit: string;
unit_price: number;
discount: number;
is_included_in_total: boolean;
position: number;
}
@@ -113,6 +115,7 @@ interface OfferSavePayload {
language: string;
items: OfferItemPayload[];
sections: OfferSectionPayload[];
selected_custom_fields: number[];
status?: string;
}
@@ -177,6 +180,9 @@ export default function OfferDetail() {
emptyDocumentItem(),
]);
const [sections, setSections] = useState<DocumentSection[]>([]);
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
const [offerStatus, setOfferStatus] = useState<string>("");
const [quotationNumber, setQuotationNumber] = useState("");
@@ -269,7 +275,7 @@ export default function OfferDetail() {
const canDelete = isEdit && !orderInfo && !isInvalidated && !isLockedByOther;
const { markClean } = useUnsavedChangesGuard(
{ form, items, sections },
{ form, items, sections, selectedCustomFields },
!loading,
);
@@ -303,6 +309,7 @@ export default function OfferDetail() {
quantity: numberOr(it.quantity, 1),
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
discount: Number(it.discount) || 0,
is_included_in_total: it.is_included_in_total !== false,
}))
: [emptyDocumentItem()];
@@ -316,6 +323,7 @@ export default function OfferDetail() {
}))
: [];
setSections(mappedSections);
setSelectedCustomFields(d.selected_custom_fields ?? []);
markClean({ form: formData, items: mappedItems, sections: mappedSections });
setOfferStatus(d.status || "");
setOrderInfo(d.order ?? null);
@@ -433,6 +441,7 @@ export default function OfferDetail() {
quantity: Number(item.quantity) || 0,
unit: item.unit,
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
is_included_in_total: item.is_included_in_total !== false,
position: i,
})),
@@ -442,6 +451,7 @@ export default function OfferDetail() {
content: s.content,
position: i,
})),
selected_custom_fields: selectedCustomFields,
};
// Only set status when a target is given (create-as-draft / create-as-live
// / finalize). Editing a live offer sends no status — the backend keeps
@@ -926,6 +936,15 @@ export default function OfferDetail() {
/>
</Field>
</Box>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={readOnly}
onChange={setSelectedCustomFields}
/>
</Box>
</Card>
{/* Items (drag-and-drop, offers carry the "V ceně" column) */}
@@ -936,6 +955,7 @@ export default function OfferDetail() {
readOnly={readOnly}
error={errors.items}
showIncludedInTotal
showDiscount
itemDescriptionMaxLength={8000}
templatesSlot={
itemTemplates && itemTemplates.length > 0 ? (
@@ -957,6 +977,7 @@ export default function OfferDetail() {
quantity: 1,
unit: "ks",
unit_price: template.default_price || 0,
discount: 0,
is_included_in_total: true,
},
]);

View File

@@ -1,14 +1,7 @@
import { useState, useEffect, useMemo, useRef } from "react";
import DOMPurify from "dompurify";
import { useQuery } from "@tanstack/react-query";
import {
orderDetailOptions,
type OrderData,
type OrderItem,
type OrderSection,
type OrderInvoice,
type OrderProject,
} from "../lib/queries/orders";
import { orderDetailOptions, type OrderItem } from "../lib/queries/orders";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
@@ -128,8 +121,9 @@ export default function OrderDetail() {
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
blobTimeouts.forEach(clearTimeout);
};
}, []);
@@ -389,7 +383,17 @@ export default function OrderDetail() {
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{/* flexWrap: the title must drop below the Zpět button on phones
instead of overflowing the viewport (same as Offer/Invoice
detail headers). */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/orders"
@@ -399,9 +403,22 @@ export default function OrderDetail() {
>
Zpět
</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4">
Objednávka {order.order_number}
Objednávka
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{order.order_number}
</Box>
</Typography>
<StatusChip
label={statusLabel(ORDER_STATUS, order.status)}

View File

@@ -10,6 +10,10 @@ import {
getCell,
getCells,
type ModalMode as HookModalMode,
type PlanEntryBody,
type PlanEntryPatchBody,
type PlanOverrideBody,
type PlanOverridePatchBody,
} from "../hooks/usePlanWork";
import type { PlanCellModalMode } from "../components/PlanCellModal";
import PlanGrid from "../components/PlanGrid";
@@ -182,7 +186,13 @@ export default function PlanWork() {
if (bulkForm.user_ids.length === 0) return;
setBulkSubmitting(true);
try {
const data: any = await bulkCreate.mutateAsync({
// Counts can arrive either at the envelope root or nested under .data.
type BulkSummary = {
created_days?: number;
skipped_days?: number;
users?: number;
};
const data = (await bulkCreate.mutateAsync({
user_ids: bulkForm.user_ids.map(Number),
date_from: bulkForm.date_from,
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
@@ -190,12 +200,12 @@ export default function PlanWork() {
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
category: bulkForm.category,
note: bulkForm.note,
});
})) as BulkSummary & { data?: BulkSummary };
// apiCall returns the full envelope { success, data, message };
// the summary counts are under .data
const summary = data?.data ?? data;
const summary: BulkSummary = data?.data ?? data;
const skipped =
summary?.skipped_days > 0
(summary?.skipped_days ?? 0) > 0
? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).`
: "";
alert.success(
@@ -392,7 +402,7 @@ export default function PlanWork() {
// optimistic cache patch the hook already applied.
const saveEntry = useCallback(
async (body: any) => {
async (body: PlanEntryBody) => {
try {
await createEntry.mutateAsync(body);
// Pulse the first day of the new entry — for multi-day entries
@@ -409,7 +419,7 @@ export default function PlanWork() {
);
const updateEntryFn = useCallback(
async (id: number, body: any) => {
async (id: number, body: PlanEntryPatchBody) => {
// Find the user for this entry so we know which cell to pulse /
// which cache to roll back. The hook's optimistic patch already
// discovered this; we read it from the current grid.
@@ -473,7 +483,7 @@ export default function PlanWork() {
);
const saveOverride = useCallback(
async (body: any) => {
async (body: PlanOverrideBody) => {
try {
await createOverride.mutateAsync(body);
firePulse(body.user_id, body.shift_date);
@@ -488,7 +498,7 @@ export default function PlanWork() {
);
const updateOverrideFn = useCallback(
async (id: number, body: any) => {
async (id: number, body: PlanOverridePatchBody) => {
// Find the user/date for the pulse + rollback.
let userId: number | null = null;
let date: string | null = null;

View File

@@ -11,7 +11,6 @@ import MenuItem from "@mui/material/MenuItem";
import {
projectDetailOptions,
type ProjectData,
type ProjectNote,
} from "../lib/queries/projects";
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
@@ -298,7 +297,17 @@ export default function ProjectDetail() {
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{/* flexWrap: the title must drop below the Zpět button on phones
instead of being squeezed beside it (mirrors Offer/Invoice
detail headers). */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/projects"
@@ -308,9 +317,22 @@ export default function ProjectDetail() {
>
Zpět
</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4">
Projekt {project.project_number}
Projekt
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{project.project_number}
</Box>
</Typography>
<StatusChip
label={STATUS_LABELS[project.status] || project.status}

View File

@@ -362,8 +362,9 @@ export default function ReceivedInvoices({
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
blobTimeouts.forEach(clearTimeout);
};
}, []);
@@ -1149,11 +1150,9 @@ export default function ReceivedInvoices({
? "Zavřít"
: "Uložit"
}
cancelText={
editInvoice && editInvoice._originalStatus === "paid"
? "Zavřít"
: "Zrušit"
}
// Read-only (paid) detail has only a close action — a single "Zavřít"
// button, not a redundant cancel button beside it.
hideCancel={editInvoice?._originalStatus === "paid"}
loading={saving}
>
{editInvoice &&

View File

@@ -715,7 +715,25 @@ export default function Settings() {
</Box>
);
}
const si = systemInfo as Record<string, any>;
type NasInfo = { configured?: boolean; path?: string | null };
type SystemInfo = {
app_version?: string;
node_version?: string;
platform?: string;
uptime?: string;
environment?: string;
timezone?: string;
memory?: {
rss?: string;
heap_used?: string;
heap_total?: string;
system_free?: string;
system_total?: string;
};
database?: { status?: string; migrations_applied?: number | string };
nas?: Record<string, NasInfo | undefined>;
};
const si = systemInfo as SystemInfo;
const Row = ({
label,
children,
@@ -800,7 +818,7 @@ export default function Settings() {
["Faktury", si.nas?.invoices],
["Objednávky", si.nas?.orders],
["Nabídky", si.nas?.offers],
] as [string, Record<string, any>][]
] as [string, NasInfo | undefined][]
).map(([label, info]) => (
<Row key={label} label={label}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>

View File

@@ -199,7 +199,7 @@ export default function Trips() {
TripForm,
{ message?: string; error?: string }
>({
url: (formData) =>
url: () =>
editingTrip ? `${API_BASE}/trips/${editingTrip.id}` : `${API_BASE}/trips`,
method: () => (editingTrip ? "PUT" : "POST"),
invalidate: ["trips", "vehicles"],

View File

@@ -23,7 +23,7 @@ interface InventoryItem {
}
function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, "");
const cleaned = raw.replace(/[eE+-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useId } from "react";
import { useState, useEffect, useRef, useId, useCallback } from "react";
import {
useParams,
useNavigate,
@@ -65,7 +65,7 @@ interface BatchesResponse {
}
function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, "");
const cleaned = raw.replace(/[eE+-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
@@ -174,7 +174,10 @@ export default function WarehouseIssueForm() {
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const nextKey = useCallback(
() => `${baseId}-item-${counterRef.current++}`,
[baseId],
);
// Pre-fill data from reservation ("Create výdej" button)
const prefill = location.state as {
@@ -262,7 +265,7 @@ export default function WarehouseIssueForm() {
}
formInitialized.current = true;
}
}, [isEdit, issue]);
}, [isEdit, issue, nextKey]);
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,

View File

@@ -50,7 +50,7 @@ interface MovementItem {
}
function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, "");
const cleaned = raw.replace(/[eE+-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
@@ -126,7 +126,10 @@ export default function WarehouseReceiptForm() {
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const nextKey = useCallback(
() => `${baseId}-item-${counterRef.current++}`,
[baseId],
);
const [form, setForm] = useState<ReceiptForm>({
supplier_id: null,
@@ -198,7 +201,7 @@ export default function WarehouseReceiptForm() {
}
formInitialized.current = true;
}
}, [isEdit, receipt]);
}, [isEdit, receipt, nextKey]);
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest";
import { theme } from "./theme";
/** Loose shape for poking into MUI styleOverrides objects in assertions. */
type StyleObj = Record<string, unknown>;
/** Minimal palette shape used by the colorSchemes assertions below. */
interface SchemesPalette {
primary: { main: string };
@@ -44,18 +47,20 @@ describe("MUI theme", () => {
});
it("makes buttons pill-shaped and cards 16px by default", () => {
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
expect(btn?.borderRadius).toBe(999);
const card = theme.components?.MuiCard?.styleOverrides?.root as any;
expect(card?.borderRadius).toBe(16);
const btn = theme.components?.MuiButton?.styleOverrides?.root as StyleObj;
expect(btn.borderRadius).toBe(999);
const card = theme.components?.MuiCard?.styleOverrides?.root as StyleObj;
expect(card.borderRadius).toBe(16);
});
it("adds 200-300ms interaction motion (press + focus ring)", () => {
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
expect(btn?.transition).toContain("transform");
expect(btn?.["&:active"]?.transform).toBe("scale(0.97)");
const btn = theme.components?.MuiButton?.styleOverrides?.root as StyleObj;
expect(btn.transition).toContain("transform");
const active = btn["&:active"] as StyleObj;
expect(active.transform).toBe("scale(0.97)");
const input = theme.components?.MuiOutlinedInput?.styleOverrides
?.root as any;
expect(input?.["&.Mui-focused"]?.boxShadow).toContain("rgba");
?.root as StyleObj;
const focused = input["&.Mui-focused"] as StyleObj;
expect(focused.boxShadow).toContain("rgba");
});
});

View File

@@ -1,4 +1,9 @@
import { createTheme, type Theme } from "@mui/material/styles";
import {
createTheme,
type Theme,
type PaletteColor,
type PaletteColorChannel,
} from "@mui/material/styles";
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
const FONT_HEADING = "'Urbanist', sans-serif";
@@ -33,6 +38,26 @@ export const theme = createTheme({
warning: { main: "#b45309", contrastText: "#fff" },
error: { main: "#b91c1c", contrastText: "#fff" },
info: { main: "#1d4ed8", contrastText: "#fff" },
// Sidebar section hues (full objects so cssVariables emits *Channel
// tokens — the nav tiles use channel-alpha washes).
teal: {
main: "#0e8a7c",
light: "#3aa99c",
dark: "#0a675d",
contrastText: "#fff",
},
violet: {
main: "#6a4cb4",
light: "#8a6fd0",
dark: "#54399a",
contrastText: "#fff",
},
slate: {
main: "#5c6470",
light: "#7d8694",
dark: "#454c56",
contrastText: "#fff",
},
background: { default: "#f4f3f1", paper: "#ffffff" },
text: { primary: "#1a1a1a", secondary: "#555555" },
divider: "rgba(0,0,0,0.1)",
@@ -46,6 +71,25 @@ export const theme = createTheme({
warning: { main: "#f59e0b", contrastText: "#1a1a1a" },
error: { main: "#ef4444", contrastText: "#1a1a1a" },
info: { main: "#3b82f6", contrastText: "#1a1a1a" },
// Sidebar section hues — brighter mains so the glyphs read on dark.
teal: {
main: "#2dd4bf",
light: "#5eead4",
dark: "#14b8a6",
contrastText: "#1a1a1a",
},
violet: {
main: "#a78bfa",
light: "#c4b5fd",
dark: "#8b5cf6",
contrastText: "#1a1a1a",
},
slate: {
main: "#94a3b8",
light: "#cbd5e1",
dark: "#64748b",
contrastText: "#1a1a1a",
},
background: { default: "#0f0f0f", paper: "#1a1a1a" },
text: { primary: "#ffffff", secondary: "#a0a0a0" },
divider: "rgba(255,255,255,0.08)",
@@ -239,6 +283,24 @@ export const fonts = {
mono: FONT_MONO,
};
// Custom sidebar-section palette entries (teal/violet/slate above). The
// channel intersection matters: theme.vars.palette.<hue>.mainChannel is how
// the nav tiles build their alpha washes.
type NavPaletteColor = PaletteColor & PaletteColorChannel;
declare module "@mui/material/styles" {
interface Palette {
teal: NavPaletteColor;
violet: NavPaletteColor;
slate: NavPaletteColor;
}
interface PaletteOptions {
teal?: PaletteOptions["primary"];
violet?: PaletteOptions["primary"];
slate?: PaletteOptions["primary"];
}
}
export type IconBadgeColor =
| "default"
| "primary"

View File

@@ -88,9 +88,8 @@ export default function SidebarNav({ onNavigate, onLogout }: SidebarNavProps) {
sx={{
borderRadius: 2,
mb: 0.25,
py: 0.6,
py: 0.45,
color: "text.secondary",
"& svg": { width: 18, height: 18 },
"&.Mui-selected, &.Mui-selected:hover": {
bgcolor: "background.paper",
color: "primary.main",
@@ -99,8 +98,29 @@ export default function SidebarNav({ onNavigate, onLogout }: SidebarNavProps) {
},
}}
>
<ListItemIcon sx={{ minWidth: 32, color: "inherit" }}>
{item.icon}
<ListItemIcon sx={{ minWidth: 36, color: "inherit" }}>
{item.rawIcon ? (
item.icon
) : (
<Box
sx={(t) => ({
width: 26,
height: 26,
// Echoes OdinMark's rounded-square tile; only Odin
// is solid + animated, these are quiet washes.
borderRadius: "32%",
flexShrink: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
bgcolor: `rgba(${t.vars!.palette[section.hue].mainChannel} / 0.12)`,
color: t.vars!.palette[section.hue].main,
"& svg": { width: 16, height: 16 },
})}
>
{item.icon}
</Box>
)}
</ListItemIcon>
<ListItemText
primary={item.label}

View File

@@ -1,6 +1,15 @@
import { type ReactNode } from "react";
import OdinMark from "../components/odin/OdinMark";
/** Palette key driving a section's tile wash + glyph color (see theme.ts). */
export type NavHue =
| "primary"
| "info"
| "teal"
| "warning"
| "violet"
| "slate";
export interface MenuItem {
path: string;
label: string;
@@ -10,33 +19,51 @@ export interface MenuItem {
matchAlso?: string[];
matchExclude?: string[];
icon: ReactNode;
/** Icon ships its own container (OdinMark) — SidebarNav skips the tile. */
rawIcon?: boolean;
}
export interface MenuSection {
label: string;
hue: NavHue;
items: MenuItem[];
}
/**
* One stroke glyph per item, every glyph unique within the set (pinned by
* navdata-icons.test.ts). 24-viewBox, stroke 2, round caps — rendered at
* 16px inside SidebarNav's 26px section-hue tile.
*/
function glyph(children: ReactNode) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{children}
</svg>
);
}
export const menuSections: MenuSection[] = [
{
label: "Přehled",
hue: "primary",
items: [
{
path: "/",
label: "Přehled",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</svg>
// tachometr — dashboard
icon: glyph(
<>
<circle cx="12" cy="13" r="8.5" />
<path d="M12 13l4-4.5" />
</>,
),
},
{
@@ -44,78 +71,66 @@ export const menuSections: MenuSection[] = [
label: "Odin",
permission: "ai.use",
end: true,
icon: <OdinMark size={22} />,
icon: <OdinMark size={26} />,
rawIcon: true,
},
],
},
{
label: "Docházka",
hue: "info",
items: [
{
path: "/attendance",
label: "Záznam",
permission: "attendance.record",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
// píchačky — hodiny
icon: glyph(
<>
<circle cx="12" cy="12" r="9" />
<polyline points="12 7 12 12 15 15" />
</svg>
</>,
),
},
{
path: "/attendance/history",
label: "Moje historie",
permission: "attendance.history",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="12 8 12 12 14 14" />
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
</svg>
// výkaz s hodinami — timesheet
icon: glyph(
<>
<path d="M14 3H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h6" />
<line x1="7" y1="8" x2="13" y2="8" />
<line x1="7" y1="12" x2="11" y2="12" />
<circle cx="16.5" cy="16" r="4.5" />
<polyline points="16.5 14 16.5 16 18 17" />
</>,
),
},
{
path: "/attendance/requests",
label: "Žádosti",
permission: "attendance.record",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="18" x2="12" y2="12" />
<line x1="9" y1="15" x2="15" y2="15" />
</svg>
// odeslaná žádost — vlaštovka
icon: glyph(
<>
<path d="M22 2L11 13" />
<path d="M22 2l-7 20-4-9-9-4z" />
</>,
),
},
{
path: "/attendance/approval",
label: "Schvalování",
permission: "attendance.approve",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 12l2 2 4-4" />
<circle cx="12" cy="12" r="10" />
</svg>
// razítko
icon: glyph(
<>
<path d="M9.5 12V9.8C9.5 8.4 8 7.8 8 6a4 4 0 0 1 8 0c0 1.8-1.5 2.4-1.5 3.8V12" />
<path d="M5 16a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v2H5z" />
<line x1="5" y1="21" x2="19" y2="21" />
</>,
),
},
{
@@ -124,147 +139,108 @@ export const menuSections: MenuSection[] = [
permission: "attendance.manage",
matchPrefix: "/attendance/admin",
matchAlso: ["/attendance/create", "/attendance/location"],
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
// osoba + fajfka
icon: glyph(
<>
<circle cx="9" cy="8" r="3.5" />
<path d="M3 20a6 6 0 0 1 12 0" />
<path d="M15.5 11.5l2 2 4-4" />
</>,
),
},
{
path: "/attendance/balances",
label: "Správa bilancí",
permission: "attendance.balances",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
// kniha plus/mínus — ruční korekce bilancí
icon: glyph(
<>
<rect x="4" y="3" width="16" height="18" rx="2" />
<line x1="9" y1="8.5" x2="15" y2="8.5" />
<line x1="12" y1="5.5" x2="12" y2="11.5" />
<line x1="9" y1="16" x2="15" y2="16" />
</>,
),
},
{
path: "/plan-work",
label: "Plán prací",
permission: ["attendance.manage", "attendance.record"],
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
// kalendář s plánovacím blokem
icon: glyph(
<>
<rect x="3" y="4" width="18" height="17" rx="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<line x1="3" y1="9.5" x2="21" y2="9.5" />
<path d="M8 14h3v3H8z" />
</>,
),
},
],
},
{
label: "Kniha jízd",
hue: "teal",
items: [
{
path: "/trips",
label: "Záznam",
permission: "trips.record",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="5" cy="18" r="3" />
<circle cx="19" cy="18" r="3" />
<path d="M5 18V12L8 5h8l3 7v6" />
<path d="M10 18h4" />
</svg>
// trasa A→B
icon: glyph(
<>
<circle cx="5" cy="19" r="2" />
<circle cx="19" cy="5" r="2" />
<path d="M5 17C5 11 19 13 19 7" />
</>,
),
},
{
path: "/trips/history",
label: "Moje historie",
permission: "trips.history",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="12 8 12 12 14 14" />
<path d="M3.05 11a9 9 0 1 1 .5 4m-.5 5v-5h5" />
</svg>
// navštívená místa — pin
icon: glyph(
<>
<path d="M12 21s-6-5.2-6-10a6 6 0 0 1 12 0c0 4.8-6 10-6 10z" />
<circle cx="12" cy="11" r="2.5" />
</>,
),
},
{
path: "/trips/admin",
label: "Správa",
permission: "trips.manage",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
// schránka s fajfkou — kniha jízd
icon: glyph(
<>
<rect x="5" y="4" width="14" height="17" rx="2" />
<path d="M9 2h6v4H9z" />
<path d="M9 13l2 2 4-4" />
</>,
),
},
{
path: "/vehicles",
label: "Vozidla",
permission: "vehicles.manage",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="1" y="3" width="15" height="13" rx="2" />
<path d="M16 8h4l3 3v5h-7V8z" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
// auto
icon: glyph(
<>
<path d="M5 17H3v-4l2-5h11l3 5h2v4h-2" />
<circle cx="7.5" cy="17" r="2" />
<circle cx="16.5" cy="17" r="2" />
<line x1="9.5" y1="17" x2="14.5" y2="17" />
</>,
),
},
],
},
{
label: "Administrativa",
hue: "warning",
items: [
{
path: "/offers",
@@ -272,16 +248,12 @@ export const menuSections: MenuSection[] = [
permission: "offers.view",
matchPrefix: "/offers",
matchExclude: ["/offers/customers", "/offers/templates"],
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
// cenovka
icon: glyph(
<>
<path d="M20.6 13.4L11 3H4v7l9.6 10.4a2 2 0 0 0 2.8 0l4.2-4.2a2 2 0 0 0 0-2.8z" />
<circle cx="8" cy="8" r="1.5" />
</>,
),
},
{
@@ -289,17 +261,13 @@ export const menuSections: MenuSection[] = [
label: "Objednávky",
permission: "orders.view",
matchPrefix: "/orders",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
// košík
icon: glyph(
<>
<circle cx="9" cy="20" r="1.6" />
<circle cx="17" cy="20" r="1.6" />
<path d="M2 3h3l2.7 12h10.6l2.7-9H6" />
</>,
),
},
{
@@ -307,16 +275,13 @@ export const menuSections: MenuSection[] = [
label: "Faktury",
permission: "invoices.view",
matchPrefix: "/invoices",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
// účtenka
icon: glyph(
<>
<path d="M6 2h12v20l-3-2-3 2-3-2-3 2z" />
<line x1="9" y1="7" x2="15" y2="7" />
<line x1="9" y1="11" x2="15" y2="11" />
</>,
),
},
{
@@ -324,60 +289,44 @@ export const menuSections: MenuSection[] = [
label: "Projekty",
permission: "projects.view",
matchPrefix: "/projects",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
// složka (NAS projektové složky)
icon: glyph(
<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V18a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />,
),
},
{
path: "/offers/customers",
label: "Zákazníci",
permission: "customers.view",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
// vizitka
icon: glyph(
<>
<rect x="2" y="4" width="20" height="16" rx="2" />
<circle cx="8.5" cy="10.5" r="2.2" />
<path d="M5 16.5a3.5 3.5 0 0 1 7 0" />
<line x1="14.5" y1="9.5" x2="19" y2="9.5" />
<line x1="14.5" y1="13.5" x2="19" y2="13.5" />
</>,
),
},
],
},
{
label: "Sklad",
hue: "violet",
items: [
{
path: "/warehouse",
label: "Přehled",
permission: "warehouse.view",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
// skladová hala
icon: glyph(
<>
<path d="M3 21V9l9-5 9 5v12" />
<path d="M7 21v-7h10v7" />
<line x1="7" y1="17.5" x2="17" y2="17.5" />
</>,
),
},
{
@@ -385,17 +334,13 @@ export const menuSections: MenuSection[] = [
label: "Položky",
permission: "warehouse.view",
matchPrefix: "/warehouse/items",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
// krabice
icon: glyph(
<>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
<line x1="12" y1="22.08" x2="12" y2="12" />
</svg>
</>,
),
},
{
@@ -403,17 +348,13 @@ export const menuSections: MenuSection[] = [
label: "Příjmy",
permission: "warehouse.operate",
matchPrefix: "/warehouse/receipts",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
<path d="M5 19h14" />
</svg>
// šipka do krabice
icon: glyph(
<>
<rect x="4" y="12" width="16" height="9" rx="1" />
<polyline points="8 6 12 10 16 6" />
<line x1="12" y1="2" x2="12" y2="10" />
</>,
),
},
{
@@ -421,17 +362,13 @@ export const menuSections: MenuSection[] = [
label: "Výdeje",
permission: "warehouse.operate",
matchPrefix: "/warehouse/issues",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
<path d="M5 5h14" />
</svg>
// šipka z krabice ven
icon: glyph(
<>
<rect x="4" y="13" width="16" height="8" rx="1" />
<polyline points="8 6 12 2 16 6" />
<line x1="12" y1="2" x2="12" y2="11" />
</>,
),
},
{
@@ -439,18 +376,12 @@ export const menuSections: MenuSection[] = [
label: "Rezervace",
permission: "warehouse.operate",
matchPrefix: "/warehouse/reservations",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" y1="8" x2="19" y2="14" />
<line x1="22" y1="11" x2="16" y2="11" />
</svg>
// krabice se záložkou
icon: glyph(
<>
<rect x="3" y="8" width="13" height="12" rx="1" />
<path d="M16 3h5v9l-2.5-2L16 12z" />
</>,
),
},
{
@@ -458,16 +389,15 @@ export const menuSections: MenuSection[] = [
label: "Inventura",
permission: "warehouse.inventory",
matchPrefix: "/warehouse/inventory",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
// čárový kód — fyzický sken zásob
icon: glyph(
<>
<line x1="4" y1="6" x2="4" y2="18" />
<line x1="8" y1="6" x2="8" y2="18" />
<line x1="12" y1="6" x2="12" y2="18" />
<line x1="17" y1="6" x2="17" y2="18" />
<line x1="20" y1="6" x2="20" y2="18" />
</>,
),
},
{
@@ -475,17 +405,12 @@ export const menuSections: MenuSection[] = [
label: "Reporty",
permission: "warehouse.view",
end: true,
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="20" x2="18" y2="10" />
<line x1="12" y1="20" x2="12" y2="4" />
<line x1="6" y1="20" x2="6" y2="14" />
</svg>
// graf v rámu
icon: glyph(
<>
<rect x="3" y="3" width="18" height="18" rx="2" />
<polyline points="7 14 11 10 14 13 17 8" />
</>,
),
},
{
@@ -493,23 +418,14 @@ export const menuSections: MenuSection[] = [
label: "Kategorie",
permission: "warehouse.manage",
matchPrefix: "/warehouse/categories",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
// tvary — třídění do kategorií
icon: glyph(
<>
<circle cx="7.5" cy="7.5" r="3.5" />
<rect x="13.5" y="4" width="7" height="7" rx="1" />
<path d="M7.5 13.5l3.5 7h-7z" />
<rect x="13.5" y="13.5" width="7" height="7" rx="3.5" />
</>,
),
},
{
@@ -517,18 +433,16 @@ export const menuSections: MenuSection[] = [
label: "Lokace",
permission: "warehouse.manage",
matchPrefix: "/warehouse/locations",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
// regál s krabicemi
icon: glyph(
<>
<path d="M3 21V4" />
<path d="M21 21V4" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="21" x2="21" y2="21" />
<rect x="7" y="8" width="4" height="4" />
<rect x="13" y="17" width="4" height="4" />
</>,
),
},
{
@@ -536,41 +450,34 @@ export const menuSections: MenuSection[] = [
label: "Dodavatelé",
permission: "warehouse.manage",
matchPrefix: "/warehouse/suppliers",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
// kamion
icon: glyph(
<>
<rect x="1" y="6" width="13" height="10" rx="1" />
<path d="M14 9h4l3 4v3h-7" />
<circle cx="6" cy="18.5" r="2" />
<circle cx="17.5" cy="18.5" r="2" />
</>,
),
},
],
},
{
label: "Systém",
hue: "slate",
items: [
{
path: "/users",
label: "Uživatelé",
permission: "users.view",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
// lidé — uživatelské účty
icon: glyph(
<>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</>,
),
},
{
@@ -583,35 +490,27 @@ export const menuSections: MenuSection[] = [
"settings.system",
"settings.templates",
],
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
// ozubené kolo
icon: glyph(
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</>,
),
},
{
path: "/audit-log",
label: "Audit log",
permission: "settings.audit",
icon: (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
// lupa nad záznamy
icon: glyph(
<>
<path d="M4 5h12" />
<path d="M4 10h6" />
<path d="M4 15h5" />
<circle cx="15" cy="14" r="4.5" />
<line x1="18.2" y1="17.2" x2="21.5" y2="20.5" />
</>,
),
},
],

View File

@@ -1,3 +1,5 @@
import { reportServerVersion } from "./appVersion";
class ApiState {
showSessionExpiredAlert = false;
showLogoutAlert = false;
@@ -120,6 +122,8 @@ export const apiFetch = async (
headers,
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 the caller already unmounted (TanStack Query aborted the request),

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { FastifyRequest, FastifyReply } from "fastify";
import { verifyAccessToken } from "../services/auth";
import { error } from "../utils/response";
import { AuthData } from "../types";
export async function requireAuth(
request: FastifyRequest,

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

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

View File

@@ -13,6 +13,8 @@ import { parseBody } from "../../schemas/common";
import { UpdateCompanySettingsSchema } from "../../schemas/settings.schema";
import { invalidateSettingsCache } from "../../services/system-settings";
import os from "os";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { config } from "../../config/env";
import { NasFileManager } from "../../services/nas-file-manager";
import {
@@ -21,6 +23,15 @@ import {
} from "../../services/nas-financials-manager";
import { nasOffersManager } from "../../services/nas-offers-manager";
// Read once at load. A static `import` of the root package.json would pull a
// file outside `src` into the program, expanding tsc's rootDir and shifting the
// whole dist/ layout — so resolve it at runtime relative to the compiled file.
const APP_VERSION = (
JSON.parse(
readFileSync(join(__dirname, "../../../package.json"), "utf8"),
) as { version: string }
).version;
/** Encode custom_fields + supplier_field_order into a single JSON blob (matching PHP format) */
function encodeCustomFields(
fields: unknown,
@@ -325,8 +336,6 @@ export default async function companySettingsRoutes(
settings.custom_fields as string | null,
);
const pkg = require("../../../package.json") as { version: string };
let available_vat_rates: number[] = [0, 10, 12, 15, 21];
try {
const raw = settings.available_vat_rates as string | null;
@@ -359,7 +368,7 @@ export default async function companySettingsRoutes(
available_currencies,
has_logo,
has_logo_dark,
app_version: pkg.version,
app_version: APP_VERSION,
});
});
@@ -368,7 +377,6 @@ export default async function companySettingsRoutes(
"/system-info",
{ preHandler: requirePermission("settings.system") },
async (request, reply) => {
const pkg = require("../../../package.json") as { version: string };
const uptimeSec = process.uptime();
const days = Math.floor(uptimeSec / 86400);
const hours = Math.floor((uptimeSec % 86400) / 3600);
@@ -401,7 +409,7 @@ export default async function companySettingsRoutes(
const projectNas = new NasFileManager();
return success(reply, {
app_version: pkg.version,
app_version: APP_VERSION,
node_version: process.version,
platform: `${os.type()} ${os.release()}`,
uptime: uptimeStr,

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";

View File

@@ -7,6 +7,8 @@ import { htmlToPdf } from "../../utils/html-to-pdf";
import { getRate } from "../../services/exchange-rates";
import { localDateStr } from "../../utils/date";
import { parseId, success } from "../../utils/response";
import { lineNet } from "../../utils/money";
import { parseSelectedCustomFields } from "../../utils/custom-fields";
import {
formatDate,
formatNum,
@@ -33,6 +35,7 @@ function buildAddressLines(
entity: Record<string, unknown> | null,
isSupplier: boolean,
tObj: Record<string, string>,
selectedCustomFields?: number[],
): AddressResult {
if (!entity) return { name: "", lines: [] };
@@ -88,7 +91,9 @@ function buildAddressLines(
fieldMap.company_id = `${tObj.ico}${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${tObj.dic}${entity.vat_id}`;
const filterCustom = Array.isArray(selectedCustomFields);
cfData.forEach((cf, i) => {
if (filterCustom && !selectedCustomFields!.includes(i)) return;
const cfName = (cf.name || "").trim();
const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false;
@@ -161,6 +166,7 @@ const translations: Record<string, Record<string, string>> = {
col_desc: "Popis",
col_qty: "Množství",
col_unit_price: "Jedn. cena",
col_discount: "Sleva",
col_price: "Cena",
col_vat_pct: "%DPH",
col_vat: "DPH",
@@ -208,6 +214,7 @@ const translations: Record<string, Record<string, string>> = {
col_desc: "Description",
col_qty: "Quantity",
col_unit_price: "Unit price",
col_discount: "Discount",
col_price: "Price",
col_vat_pct: "VAT%",
col_vat: "VAT",
@@ -312,6 +319,8 @@ export default async function invoicesPdfRoutes(
where: { invoice_id: id },
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;
if (invoice.customer_id) {
@@ -362,7 +371,11 @@ export default async function invoicesPdfRoutes(
let subtotal = 0;
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;
const rate = Number(item.vat_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 suppLinesHtml = supp.lines
@@ -489,11 +510,16 @@ export default async function invoicesPdfRoutes(
.map((item, i) => {
const qty = Number(item.quantity);
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 lineVat = applyVat ? (lineSubtotal * vatRate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
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)}&nbsp;%` : "—"}</td>`
: "";
const subDesc = item.item_description || "";
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="center">${formatNum(qty, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
<td class="right">${formatNum(unitPrice)}</td>
${discCell}
<td class="right">${formatNum(lineSubtotal)}</td>
<td class="center">${applyVat ? Math.floor(vatRate) : 0}%</td>
<td class="right">${formatNum(lineVat)}</td>
@@ -1052,9 +1079,10 @@ ${indentCSS}
<thead>
<tr>
<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="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="center" style="width:5%">${escapeHtml(t.col_vat_pct)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_vat)}</th>

View File

@@ -13,7 +13,6 @@ import {
markOverdueInvoices,
listInvoices,
getInvoiceListTotals,
getNextInvoiceNumberFormatted,
getNextInvoiceNumberPreview,
getInvoiceStats,
getOrderDataForInvoice,

View File

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

View File

@@ -366,6 +366,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
return error(reply, "Dodavatel nenalezen", 400);
if (order.error === "po_number_taken")
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);
}
await logAudit({
@@ -408,6 +410,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
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);
}
await logAudit({

View File

@@ -5,6 +5,8 @@ import { requirePermission } from "../../middleware/auth";
import { nasOffersManager } from "../../services/nas-offers-manager";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId, success } from "../../utils/response";
import { lineNet } from "../../utils/money";
import { parseSelectedCustomFields } from "../../utils/custom-fields";
import {
formatDate,
formatNum,
@@ -28,6 +30,7 @@ function buildAddressLines(
entity: Record<string, unknown> | null,
isSupplier: boolean,
t: (key: string) => string,
selectedCustomFields?: number[],
): AddressResult {
if (!entity) return { name: "", lines: [] };
@@ -84,7 +87,9 @@ function buildAddressLines(
fieldMap.company_id = `${t("ico")}: ${entity.company_id}`;
if (entity.vat_id) fieldMap.vat_id = `${t("dic")}: ${entity.vat_id}`;
const filterCustom = Array.isArray(selectedCustomFields);
cfData.forEach((cf, i) => {
if (filterCustom && !selectedCustomFields!.includes(i)) return;
const cfName = (cf.name || "").trim();
const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false;
@@ -123,6 +128,7 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
description: { EN: "Description", CZ: "Popis" },
qty: { EN: "Qty", CZ: "Mn." },
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
discount: { EN: "Discount", CZ: "Sleva" },
included: { EN: "Included", CZ: "Zahrnuto" },
total: { EN: "Total", CZ: "Celkem" },
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
@@ -170,9 +176,15 @@ export function renderOfferHtml(
let total = 0;
for (const item of items) {
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
// 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
@@ -202,6 +214,10 @@ export function renderOfferHtml(
settings as unknown as Record<string, unknown>,
true,
t,
parseSelectedCustomFields(
(quotation as { selected_custom_fields?: unknown })
.selected_custom_fields,
),
);
const custLinesHtml = cust.lines
@@ -217,10 +233,15 @@ export function renderOfferHtml(
let itemsHtml = "";
items.forEach((item, i) => {
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
// (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document).
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)}&nbsp;%` : "—"}</td>`
: "";
const subDesc = item.item_description || "";
const evenClass = i % 2 === 1 ? ' class="even"' : "";
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="center">${formatNum(qty, qtyDecimals)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
${discCell}
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`;
});
@@ -621,10 +643,11 @@ ${indentCSS}
<thead>
<tr>
<th class="center" style="width:5%">${escapeHtml(t("no"))}</th>
<th style="width:44%">${escapeHtml(t("description"))}</th>
<th class="center" style="width:13%">${escapeHtml(t("qty"))}</th>
<th class="right" style="width:18%">${escapeHtml(t("unit_price"))}</th>
<th class="right" style="width:20%">${escapeHtml(t("total"))}</th>
<th style="width:${hasDiscount ? 36 : 44}%">${escapeHtml(t("description"))}</th>
<th class="center" style="width:12%">${escapeHtml(t("qty"))}</th>
<th class="right" style="width:16%">${escapeHtml(t("unit_price"))}</th>
${hasDiscount ? `<th class="center" style="width:9%">${escapeHtml(t("discount"))}</th>` : ""}
<th class="right" style="width:18%">${escapeHtml(t("total"))}</th>
</tr>
</thead>
<tbody>

View File

@@ -194,7 +194,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/categories/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as any).id, reply);
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(UpdatePlanCategorySchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
@@ -218,7 +218,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/categories/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as any).id, reply);
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const result = await deletePlanCategory(id);
if ("error" in result) return error(reply, result.error, result.status);
@@ -289,7 +289,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/entries/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as any).id, reply);
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(UpdatePlanEntrySchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
@@ -321,7 +321,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/entries/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as any).id, reply);
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const force = forceFromQuery(request.query);
const result = await deleteEntry(id, request.authData!.userId, force);
@@ -373,7 +373,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/overrides/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as any).id, reply);
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(UpdatePlanOverrideSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
@@ -405,7 +405,7 @@ export default async function planRoutes(app: FastifyInstance) {
"/overrides/:id",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as any).id, reply);
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const force = forceFromQuery(request.query);
const result = await deleteOverride(id, request.authData!.userId, force);

View File

@@ -199,7 +199,7 @@ export default async function projectFilesRoutes(
const subPath = parsedQuery.data.path || "";
const rawFileName = file.filename;
const fileName = rawFileName.replace(/[\/\\:*?"<>|]/g, "_");
const fileName = rawFileName.replace(/[/\\:*?"<>|]/g, "_");
const buffer = await file.toBuffer();
const err = await fm.uploadFile(

View File

@@ -89,7 +89,11 @@ export default async function projectsRoutes(
const result = await createProject(parsed.data);
if ("error" in result)
return error(reply, result.error, (result as any).status ?? 400);
return error(
reply,
result.error,
(result as { status?: number }).status ?? 400,
);
await logAudit({
request,
@@ -127,7 +131,11 @@ export default async function projectsRoutes(
const result = await updateProject(id, parsed.data);
if (!result) return error(reply, "Projekt nenalezen", 404);
if ("error" in result) {
return error(reply, result.error, (result as any).status ?? 400);
return error(
reply,
result.error,
(result as { status?: number }).status ?? 400,
);
}
await logAudit({
@@ -160,7 +168,11 @@ export default async function projectsRoutes(
content: parsed.data.content ?? undefined,
});
if (note && "error" in note) {
return error(reply, note.error, (note as any).status ?? 400);
return error(
reply,
note.error,
(note as { status?: number }).status ?? 400,
);
}
await logAudit({

View File

@@ -82,7 +82,7 @@ export default async function sessionsRoutes(
"/:id",
{ preHandler: requireAuth },
async (request, reply) => {
const id = parseId((request.params as any).id, reply);
const id = parseId(request.params.id, reply);
if (id === null) return;
const authData = request.authData!;

View File

@@ -293,7 +293,7 @@ export default async function tripsRoutes(
"/last-km/:vehicleId",
{ preHandler: requirePermission("trips.manage") },
async (request, reply) => {
const vehicleId = parseId((request.params as any).vehicleId, reply);
const vehicleId = parseId(request.params.vehicleId, reply);
if (vehicleId === null) return;
const [lastTrip, vehicle] = await Promise.all([
@@ -373,7 +373,7 @@ export default async function tripsRoutes(
"/:id",
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
const id = parseId((request.params as any).id, reply);
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(UpdateTripSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
@@ -461,7 +461,7 @@ export default async function tripsRoutes(
"/:id",
{ preHandler: requireAnyPermission("trips.manage", "trips.record") },
async (request, reply) => {
const id = parseId((request.params as any).id, reply);
const id = parseId(request.params.id, reply);
if (id === null) return;
const authData = request.authData!;
const existing = await prisma.trips.findUnique({ where: { id } });

View File

@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
import multipart from "@fastify/multipart";
import prisma from "../../config/database";
import { config } from "../../config/env";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { contentDisposition } from "../../utils/content-disposition";
@@ -2209,7 +2209,7 @@ export default async function warehouseRoutes(
// Build items with auto-filled system_qty and calculated difference
const itemsData = await Promise.all(
body.items.map(async (item) => {
let systemQty = 0;
let systemQty: number;
if (item.location_id != null) {
// Get stock at specific location
@@ -2318,7 +2318,7 @@ export default async function warehouseRoutes(
if (body.items) {
itemsData = await Promise.all(
body.items.map(async (item) => {
let systemQty = 0;
let systemQty: number;
if (item.location_id != null) {
const loc = await prisma.sklad_item_locations.findUnique({

View File

@@ -16,6 +16,8 @@ const InvoiceItemSchema = z.object({
quantity: positiveNumberFromForm.default(1),
unit: z.string().max(20).nullish(),
unit_price: numberFromForm.default(0),
// Per-item discount as a percentage (0100); column is Decimal(5,2).
discount: numberInRange(0, 100).optional().default(0),
vat_rate: numberInRange(0, 100).optional().default(21.0),
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
// (mirrors issued orders — shared DocumentSectionSchema, DB-aligned limits).
sections: z.array(DocumentSectionSchema).optional(),
// Positional indices of company custom fields to print on this document's PDF.
selected_custom_fields: z
.array(z.number().int().nonnegative())
.max(50)
.optional(),
});
export const UpdateInvoiceSchema = z.object({
@@ -72,6 +79,10 @@ export const UpdateInvoiceSchema = z.object({
paid_date: nullableIsoDateString,
items: z.array(InvoiceItemSchema).optional(),
sections: z.array(DocumentSectionSchema).optional(),
selected_custom_fields: z
.array(z.number().int().nonnegative())
.max(50)
.optional(),
});
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;

View File

@@ -41,6 +41,12 @@ export const CreateIssuedOrderSchema = z.object({
// Rich-text CZ/EN sections rendered on their own PDF page (mirrors offers'
// scope_sections — shared DocumentSectionSchema, DB-aligned limits).
sections: z.array(DocumentSectionSchema).optional(),
// Positional indices of company custom fields to print on this document's
// PDF. Omitted/empty ⇒ none. Update schema derives via .partial().
selected_custom_fields: z
.array(z.number().int().nonnegative())
.max(50)
.optional(),
});
// Update = partial create MINUS the number: PO numbers are immutable

View File

@@ -3,6 +3,7 @@ import {
nonNegativeIntFromForm,
nonNegativeNumberFromForm,
positiveNumberFromForm,
numberInRange,
nullableIntIdFromForm,
booleanFromForm,
isoDateString,
@@ -17,6 +18,8 @@ const QuotationItemSchema = z.object({
quantity: positiveNumberFromForm.default(1),
unit: z.string().max(20).nullish(),
unit_price: nonNegativeNumberFromForm.default(0),
// Per-item discount as a percentage (0100); column is Decimal(5,2).
discount: numberInRange(0, 100).optional().default(0),
is_included_in_total: booleanFromForm.optional().default(true),
// Int column — a float position would 500 at Prisma.
position: nonNegativeIntFromForm.optional(),
@@ -45,6 +48,12 @@ export const CreateQuotationSchema = z.object({
scope_description: z.string().max(8000).nullish(),
items: z.array(QuotationItemSchema).optional(),
sections: z.array(ScopeSectionSchema).optional(),
// Positional indices of company custom fields to print on this document's
// PDF. Omitted/empty ⇒ none. Update schema derives via .partial().
selected_custom_fields: z
.array(z.number().int().nonnegative())
.max(50)
.optional(),
});
// Update = partial create MINUS the number: quotation numbers are immutable

View File

@@ -1,4 +1,5 @@
import Fastify from "fastify";
import type { IncomingMessage, ServerResponse } from "http";
import type { ScheduledTask } from "node-cron";
import cors from "@fastify/cors";
import cookie from "@fastify/cookie";
@@ -6,6 +7,7 @@ import rateLimit from "@fastify/rate-limit";
import path from "path";
import { config } from "./config/env";
import { securityHeaders } from "./middleware/security";
import { registerVersionHeader } from "./middleware/version";
import prisma from "./config/database";
import authRoutes from "./routes/admin/auth";
@@ -99,6 +101,9 @@ async function start() {
// --- Security headers ---
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 ---
app.setErrorHandler(
(err: Error & { statusCode?: number }, request, reply) => {
@@ -167,13 +172,26 @@ async function start() {
// --- Frontend: Vite dev middleware (dev only) ---
if (!config.isProduction) {
// `vite` is a dev-only dependency loaded via a runtime import the bundler
// can't see (the Function() trick), so its types aren't resolvable under the
// server's moduleResolution — describe just the slice we use.
type ViteDevServerLike = {
middlewares: (
req: IncomingMessage,
res: ServerResponse,
next: (err?: Error) => void,
) => void;
};
type ViteModuleLike = {
createServer: (opts: {
server: { middlewareMode: boolean };
appType: string;
}) => Promise<ViteDevServerLike>;
};
const viteModule = await (Function(
'return import("vite")',
)() as Promise<any>);
const createViteServer = viteModule.createServer as (
opts: any,
) => Promise<any>;
const vite = await createViteServer({
)() as Promise<ViteModuleLike>);
const vite = await viteModule.createServer({
server: { middlewareMode: true },
appType: "spa",
});

View File

@@ -523,6 +523,8 @@ export async function extractInvoice(
return JSON.parse(text) as ExtractedInvoice;
} catch (e) {
console.error("[ai.service] extractInvoice: model reply was not JSON", e);
throw new Error("Model nevrátil platná data faktury (neplatný JSON)");
throw new Error("Model nevrátil platná data faktury (neplatný JSON)", {
cause: e,
});
}
}

View File

@@ -791,7 +791,7 @@ export async function getProjectReport(year: number) {
// Use detailed project logs (started_at/ended_at or hours/minutes)
for (const log of rec.attendance_project_logs) {
let hours = 0;
let hours: number;
if (log.hours != null || log.minutes != null) {
hours = (Number(log.hours) || 0) + (Number(log.minutes) || 0) / 60;
} else if (log.started_at && log.ended_at) {

View File

@@ -1,7 +1,7 @@
import crypto from "crypto";
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
import { FastifyRequest, FastifyReply } from "fastify";
import { FastifyRequest } from "fastify";
import prisma from "../config/database";
import { config } from "../config/env";
import { AuthData, JwtPayload } from "../types";

View File

@@ -57,7 +57,9 @@ async function fetchRatesForDate(
// an entry that is past TTL — better to serve yesterday's rate than fail).
const stale = rateCache.get(key);
if (stale) return stale.rates;
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB", {
cause: err,
});
} finally {
inflight.delete(key);
}

View File

@@ -1,11 +1,16 @@
import prisma from "../config/database";
import { utcMidnightOfLocalDay } from "../utils/date";
import { lineNet } from "../utils/money";
import { toCzk } from "./exchange-rates";
import {
generateInvoiceNumber,
releaseInvoiceNumber,
assignInvoiceNumber,
} from "./numbering.service";
import {
encodeSelectedCustomFields,
parseSelectedCustomFields,
} from "../utils/custom-fields";
// Status transition rules matching PHP.
// draft -> issued is the finalize step (consumes + assigns the invoice number).
@@ -31,6 +36,8 @@ interface InvoiceItemInput {
quantity?: number;
unit?: string | null;
unit_price?: number;
/** Per-item discount as a percentage (0100). */
discount?: number;
vat_rate?: number;
position?: number;
}
@@ -165,14 +172,24 @@ function buildInvoiceWhere(
}
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,
defaultVatRate: unknown,
) {
const { subtotal, vat: vatAmount } = computeMoney(
items,
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.
(i) =>
i.vat_rate != null && i.vat_rate !== ""
@@ -305,6 +322,7 @@ export function invoiceTotalWithVat(inv: {
invoice_items: Array<{
quantity: { toNumber(): number } | null;
unit_price: { toNumber(): number } | null;
discount?: { toNumber(): number } | null;
vat_rate: { toNumber(): number } | null;
}>;
}) {
@@ -312,8 +330,11 @@ export function invoiceTotalWithVat(inv: {
inv.invoice_items,
inv.apply_vat,
(i) =>
(Number(i.quantity?.toNumber()) || 0) *
(Number(i.unit_price?.toNumber()) || 0),
lineNet(
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
// the invoice default, then 21.
(i) =>
@@ -401,7 +422,12 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const { vat } = computeMoney(
inv.invoice_items,
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,
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,
order_number: invoice.orders?.order_number || null,
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
? String(body.internal_notes)
: null,
selected_custom_fields: encodeSelectedCustomFields(
body.selected_custom_fields,
),
},
});
@@ -551,6 +583,7 @@ export async function createInvoice(body: InvoiceInput) {
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
discount: item.discount ?? 0,
vat_rate: item.vat_rate ?? 21.0,
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;
if (body.tax_date !== undefined)
data.tax_date = body.tax_date ? new Date(String(body.tax_date)) : null;
if (body.selected_custom_fields !== undefined)
data.selected_custom_fields = encodeSelectedCustomFields(
body.selected_custom_fields,
);
}
// Internal notes editable in draft/issued/overdue (never printed)
@@ -688,6 +725,7 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
discount: item.discount ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),

View File

@@ -9,6 +9,10 @@ import {
isIssuedOrderNumberTaken,
} from "./numbering.service";
import { nasOrdersManager } from "./nas-financials-manager";
import {
encodeSelectedCustomFields,
parseSelectedCustomFields,
} from "../utils/custom-fields";
export interface IssuedOrderItemInput {
description?: string | null;
@@ -125,6 +129,7 @@ const NON_STATUS_UPDATE_FIELDS = [
"language",
"order_text",
"internal_notes",
"selected_custom_fields",
"items",
"sections",
] as const;
@@ -143,6 +148,32 @@ export function computeIssuedOrderTotals(
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) {
const { page, limit, skip, sort, order } = params;
const sortField = ALLOWED_SORT_FIELDS.includes(sort) ? sort : "id";
@@ -253,6 +284,9 @@ export async function getIssuedOrder(id: number) {
supplier: order.suppliers,
supplier_name: order.suppliers?.name || null,
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
selected_custom_fields: parseSelectedCustomFields(
rest.selected_custom_fields,
),
};
}
@@ -261,6 +295,12 @@ export async function createIssuedOrder(body: IssuedOrderInput) {
return await prisma.$transaction(async (tx) => {
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
// FK would otherwise surface as a P2003 500 instead of a clean 400.
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
? String(body.internal_notes)
: 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
? String(body.internal_notes)
: null;
if (body.selected_custom_fields !== undefined)
data.selected_custom_fields = encodeSelectedCustomFields(
body.selected_custom_fields,
);
}
if (body.status !== undefined) data.status = String(body.status);
@@ -437,6 +484,27 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
body.status !== undefined &&
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
// 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

View File

@@ -1,5 +1,6 @@
import { Prisma } from "@prisma/client";
import prisma from "../config/database";
import { lineNet } from "../utils/money";
import {
generateOfferNumber,
releaseOfferNumber,
@@ -7,6 +8,10 @@ import {
assignOfferNumber,
} from "./numbering.service";
import { nasOffersManager } from "./nas-offers-manager";
import {
encodeSelectedCustomFields,
parseSelectedCustomFields,
} from "../utils/custom-fields";
interface QuotationItemInput {
description?: string;
@@ -14,6 +19,8 @@ interface QuotationItemInput {
quantity?: number;
unit?: string;
unit_price?: number;
/** Per-item discount as a percentage (0100). */
discount?: number;
is_included_in_total?: boolean;
position?: number;
}
@@ -61,6 +68,7 @@ const NON_STATUS_UPDATE_FIELDS = [
"language",
"scope_title",
"scope_description",
"selected_custom_fields",
"items",
"sections",
] as const;
@@ -115,13 +123,36 @@ function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
return where;
}
// Structural shape for enrichQuotation — it's called with different selects
// (full include vs. the minimal totals select), so a single Prisma payload type
// doesn't fit. Only the fields actually read are pinned; the rest is spread.
type EnrichableLineItem = {
is_included_in_total?: unknown;
quantity?: unknown;
unit_price?: unknown;
discount?: unknown;
};
type EnrichableQuotation = {
quotation_items: EnrichableLineItem[];
scope_sections?: unknown;
customers?: { name?: string | null } | null;
[key: string]: unknown;
};
// Offers are NOT tax documents — totals are NET only (no VAT anywhere).
function enrichQuotation(q: any) {
// Generic over the concrete payload so `...rest` keeps every field the caller's
// select actually returned (the return type must stay as rich as the input).
function enrichQuotation<T extends EnrichableQuotation>(q: T) {
const total = q.quotation_items
.filter((i: any) => i.is_included_in_total !== false)
.filter((i) => i.is_included_in_total !== false)
.reduce(
(s: number, i: any) =>
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
(s: number, i) =>
s +
lineNet(
Number(i.quantity) || 0,
Number(i.unit_price) || 0,
Number(i.discount),
),
0,
);
const { quotation_items, scope_sections, ...rest } = q;
@@ -257,10 +288,13 @@ export async function getOffer(id: number) {
// Computed server-side so the frontend renders only legal status buttons
// (same contract as getIssuedOrder).
valid_transitions: VALID_TRANSITIONS[quotation.status] || [],
selected_custom_fields: parseSelectedCustomFields(
rest.selected_custom_fields,
),
};
}
export async function createOffer(body: Record<string, any>) {
export async function createOffer(body: Record<string, unknown>) {
try {
return await prisma.$transaction(async (tx) => {
const status = body.status ? String(body.status) : "draft";
@@ -316,6 +350,9 @@ export async function createOffer(body: Record<string, any>) {
scope_description: body.scope_description
? String(body.scope_description)
: null,
selected_custom_fields: encodeSelectedCustomFields(
body.selected_custom_fields,
),
},
});
@@ -328,6 +365,7 @@ export async function createOffer(body: Record<string, any>) {
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
discount: item.discount ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
@@ -362,7 +400,7 @@ export async function createOffer(body: Record<string, any>) {
}
}
export async function updateOffer(id: number, body: Record<string, any>) {
export async function updateOffer(id: number, body: Record<string, unknown>) {
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return { error: "not_found" as const };
@@ -445,6 +483,10 @@ export async function updateOffer(id: number, body: Record<string, any>) {
? String(body.scope_description)
: null
: undefined,
selected_custom_fields:
body.selected_custom_fields !== undefined
? encodeSelectedCustomFields(body.selected_custom_fields)
: undefined,
modified_at: new Date(),
};
@@ -463,6 +505,7 @@ export async function updateOffer(id: number, body: Record<string, any>) {
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
discount: item.discount ?? 0,
is_included_in_total: item.is_included_in_total !== false,
position: item.position ?? i,
})),
@@ -585,6 +628,7 @@ export async function duplicateOffer(id: number) {
quantity: item.quantity,
unit: item.unit,
unit_price: item.unit_price,
discount: item.discount,
is_included_in_total: item.is_included_in_total,
position: item.position,
})),

View File

@@ -79,11 +79,36 @@ async function syncProjectStatus(
// silently 0 in the per-currency totals.
//
// Orders are NOT tax documents — totals are NET only (no VAT anywhere).
function enrichOrder(o: any) {
// Structural shape for enrichOrder — like enrichQuotation it runs over several
// different selects (full include vs. the minimal totals select), so only the
// fields actually read are pinned; everything else is spread through.
type EnrichableLineItem = {
is_included_in_total?: unknown;
quantity?: unknown;
unit_price?: unknown;
};
type EnrichableOrder = {
order_items: EnrichableLineItem[];
order_sections?: unknown;
invoices?: Array<{
id?: number | null;
invoice_number?: string | null;
}> | null;
customers?: { name?: string | null } | null;
quotations?: {
quotation_number?: string | null;
project_code?: string | null;
} | null;
[key: string]: unknown;
};
// Generic over the concrete payload so `...rest` keeps every field the caller's
// select actually returned (the return type must stay as rich as the input).
function enrichOrder<T extends EnrichableOrder>(o: T) {
const total = o.order_items
.filter((i: any) => i.is_included_in_total !== false)
.filter((i) => i.is_included_in_total !== false)
.reduce(
(s: number, i: any) =>
(s: number, i) =>
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
0,
);

View File

@@ -680,6 +680,11 @@ export async function deleteEntry(
// Override CRUD
// ---------------------------------------------------------------------------
/** The work_plan_overrides row shape, derived straight from the Prisma client. */
type WorkPlanOverride = Awaited<
ReturnType<typeof prisma.work_plan_overrides.create>
>;
/**
* Create a work_plan_overrides row. Additive: stacks up to
* MAX_RECORDS_PER_CELL active overrides per (user_id, shift_date); a create
@@ -696,7 +701,7 @@ export async function createOverride(
},
actorUserId: number,
force: boolean,
): Promise<Result<any>> {
): Promise<Result<WorkPlanOverride>> {
const lock = assertNotPastDate(input.shift_date, force);
if (lock) return lock;
@@ -762,7 +767,7 @@ export async function updateOverride(
input: { project_id?: number | null; category?: string; note?: string },
actorUserId: number,
force: boolean,
): Promise<Result<any>> {
): Promise<Result<WorkPlanOverride>> {
const existing = await prisma.work_plan_overrides.findUnique({
where: { id },
});

View File

@@ -97,7 +97,7 @@ export async function getProject(id: number) {
};
}
export async function updateProject(id: number, body: Record<string, any>) {
export async function updateProject(id: number, body: Record<string, unknown>) {
const existing = await prisma.projects.findUnique({ where: { id } });
if (!existing) return null;

Some files were not shown because too many files have changed in this diff Show More