Compare commits

..

21 Commits

Author SHA1 Message Date
BOHA
f268907848 chore(release): v2.4.4 - offers/issued-orders unification, PO sections+locking, per-page PDF footer, NAS dedupe
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:57:05 +02:00
BOHA
dd8c699420 fix(nas): issued-order PDFs overwrite in place - no more _1 duplicate copies
saveIssuedPdf ran through uniquePath, so whenever two archive calls raced (the fire-and-forget ?save=1 after a save vs. the /file fallback, or two quick saves) the second write landed as <number>_1.pdf - and those copies were invisible to the reader and cleaner (exact-name match), accumulating forever. Now the issued archive writes to the deterministic Vydane/YYYY/MM/<number>.pdf and overwrites (same model as the offers manager), and cleanIssued also sweeps stale <number>_N.pdf duplicates so existing junk self-heals on the next save/delete. Document numbers are digit-only (never contain _), so the suffix match cannot hit a different document. uniquePath remains for received-invoice uploads where name collisions are legitimate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:55:27 +02:00
BOHA
268a947c85 feat(issued-orders-pdf): true per-page footer (Vystavil + Strana X z Y), sections flow inline
The PDF now carries a real repeating footer on EVERY page via Puppeteer's footerTemplate (Chromium has no CSS margin-box support, so this is the only true footer): 'Vystavil: <logged-in user>' on the left, 'Strana X z Y' / 'Page X of Y' centered, localized by the document language. htmlToPdf gained an optional footerTemplate option (empty header suppresses Chromium's default; bottom margin grows to 18mm). The old body footer pinned by flex min-height is gone. The 'Obsah' sections no longer start a new page with a repeated header - they flow inline right after the items/totals block and paginate naturally (break-inside: avoid per section). Applied at all three render sites (PDF route, ?save=1 archive, /file fallback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:44:11 +02:00
BOHA
75dc97516e feat(issued-orders)!: sections replace flat terms/notes fields; Obsah title
Per user decision the rich-text sections now carry all free-form PDF content on issued orders: dropped columns delivery_terms, payment_terms, issued_by, notes (migration applied to dev+test) and their PDF blocks + form fields. Kept: order_text (moved from the bottom card into the Zakladni udaje grid, replacing the Vystavil field) and internal_notes (now the only field in the bottom card, never printed). Sections card is titled 'Obsah' on issued orders; offers keep 'Rozsah projektu' and are untouched. Legacy clients sending the dropped keys are silently stripped (tested). PDF footer 'Vystavil:' stays - it prints the logged-in user, not the dropped column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:27:08 +02:00
BOHA
d1533ffc4d feat(documents)!: unify offers and issued orders end to end
One coherent pass over the two sibling document models, driven by a 3-lens
1:1 scan (~60 divergences inventoried) + user decisions. Migration adds
issued_orders.locked_by/locked_at and the issued_order_sections table
(mirror of scope_sections; applied to dev + test DBs).

Issued orders gained (offers as reference implementation):
- rich-text SECTIONS with CZ/EN titles, rendered on their own PDF page in
  the PO template's red style (shared DocumentSectionSchema, one-transaction
  create/update incl. items - fixes a torn-write bug)
- edit LOCKING (lock/heartbeat/unlock routes, 423 + holder name, 30s TTL =
  3 missed 10s heartbeats; locked_by enrichment on detail)
- archived-PDF serving: GET /:id/file reads the NAS copy (new
  readIssuedByNumber sweep) with live-render fallback + re-archive; offers'
  /file got the same fallback (kills the 'ulozte nabidku' dead end)
- NAS cleanup on delete, in-tx po_number uniqueness (409), collision-advancing
  number previews (also invoice previews), PUT returns assigned po_number

Offers hardened (issued as reference):
- VALID_TRANSITIONS enforced (no more numberless 'ordered' offers; invalidate
  follows the table; order creation only from active offers) +
  valid_transitions on detail
- explicit 400 instead of silent edits outside draft/active (mirrored on
  issued: explicit 400 replaced silent drops)
- customer existence check + Number(null)->0 clear bug fixed; delete of a
  linked offer -> 409 instead of P2003 500; error-token convention; Zod caps
  DB-aligned (desc 500/unit 20/number 50/project_code 100, isoDateString
  dates, ints for positions); audit old/new values + koncept fallbacks;
  list id tiebreaks; stats include trimmed; NAS delete dedupe

Frontend unification (6 new shared modules):
- components/document/{DocumentItemsEditor,SectionsEditor,LockBanner}
- hooks/{useDocumentLock,useUnsavedChangesGuard,useDocumentPdf(+list variant)}
- OfferDetail 1940->1100 lines, IssuedOrderDetail 1400->980: headline-only
  document number (form field removed), one form layout/readonly convention,
  view-permission opens read-only everywhere (issued's editable-for-viewers
  hole closed), server-driven transition buttons, dirty guard + Enter submit
  on both, useApiMutation everywhere (new opt-in envelope mode), fixed
  infinite spinner on failed detail fetch, draft PDFs hidden (no number yet)
- lists: supplier filter + count line on issued, Mena column dropped + mono
  numbers on offers, shared hardened per-row PDF flow (spinner, double-click
  guard, 401 close, blob cleanup), proper Czech quotes, real CTA empty
  states, query-lib cleanups (["offers","customers"] key, typed list rows,
  shared CurrencyAmount, retry:false on details)
- pdf-shared.ts: one escapeHtml/cleanQuillHtml(strict)/formatNum(NBSP)/
  formatCurrency/formatDate for all four PDF routes; offer PDF keeps its
  monochrome look (fractional qty fix: 1.5 no longer prints as 2); issued
  PDF language now comes from the document column

+49 tests (suite 364 -> 413). Each stage passed an independent review; final
cross-stage integration review verified the FE<->BE contracts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:22:39 +02:00
BOHA
1c1b9dca17 chore(release): v2.4.3 - VAT removed from offers/orders/issued orders (non-tax documents)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:22:28 +02:00
BOHA
7b20c47937 feat(pdf): drop the prices-excl.-VAT notice from offer/order/PO PDFs
The 'Ceny jsou uvedeny bez DPH...' explanatory line is removed at user request from all three non-tax-document PDFs - the 'Celkem bez DPH' total label carries the information by itself. Dead vat_notice keys and .vat-note CSS removed; tests now pin the notice's ABSENCE (file renamed pdf-vat-note -> pdf-no-vat).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:19:08 +02:00
BOHA
7398cad466 feat(vat)!: remove VAT entirely from offers, orders and issued orders
Accounting rule: nabidky, objednavky (incl. confirmation PDF) and objednavky
vydane are NOT tax documents - VAT belongs only on invoices. Per user
decision this is a FULL removal including DB columns.

- migration: DROP orders.vat_rate/apply_vat, issued_orders.vat_rate/apply_vat,
  issued_order_items.vat_rate, quotations.vat_rate/apply_vat (applied to
  dev + test DBs)
- services: net-only totals everywhere (computeIssuedOrderTotals(items) ->
  {total}; enrichOrder/enrichQuotation net; per-currency list totals net)
- PDFs (offers, order confirmation, issued order): no VAT columns/summary,
  single 'Celkem bez DPH' / 'Total excl. VAT' total, note under totals:
  'Ceny jsou uvedeny bez DPH. DPH bude uctovano dle platnych predpisu.';
  duplicate Cena/Celkem column merged (desc width 56%); orders-pdf render
  extracted as exported renderOrderConfirmationHtml for testability
- frontend: Uplatnit DPH checkboxes, VAT selects and per-item VAT columns
  removed from OfferDetail/OrderDetail/IssuedOrderDetail/
  OrderConfirmationModal/ReceivedOrders manual-create; list footers read
  'Celkem bez DPH'; invoice-from-order prefill now takes the company default
  VAT (invoice decides its own VAT)
- tests: suites reworked to net math; new pdf-vat-note.test.ts pins the
  exact cs+en note text and VAT-free layout on all three PDFs

Invoices and received invoices keep their VAT handling unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:13:16 +02:00
BOHA
396a1d37ec chore(release): v2.4.2 - editable issued-order PDF heading (order_text)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:21:44 +02:00
BOHA
ca1f07671f feat(issued-orders): editable PDF heading 'Text objednavky (na PDF)' (order_text)
New nullable issued_orders.order_text column (migration applied to dev+test). The heading above the PDF items table is now editable per order, mirroring invoices' billing_text: empty falls back to the default, which is now 'Objednavame si u Vas:' per user wording. Field sits above Dodaci podminky in the detail form; persisted via create + update strFields (both schemas - the invoices Update-schema gap is not repeated). Tests: persistence round-trip incl. null-clears, PDF custom heading + default fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:16:40 +02:00
BOHA
638264fc7c chore(release): v2.4.1 - dashboard staleness fix, VAT-less order PDFs, release-process docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:08:52 +02:00
BOHA
f68a4dafc4 feat(orders-pdf): without 'Uplatnit DPH' hide VAT columns, total reads 'Celkem bez DPH'
Applies to both the issued-order (PO) PDF and the order-confirmation PDF: when apply_vat is off the %DPH and DPH columns are dropped entirely (widths redistributed) instead of printing 0% / 0,00, the redundant Mezisoucet row is omitted, and the grand total is labeled 'Celkem bez DPH' / 'Total excl. VAT'. The per-line DPH cell already contained only the line VAT (never netto+VAT) - now pinned by a regression test (2x100 @ 21%: DPH cell 42,00, Celkem cell 242,00).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:04:07 +02:00
BOHA
700fb47bbc docs(release): prisma generate + prisma.config.ts are mandatory deploy steps
npm install skips client regeneration when deps are unchanged - a schema-changing release then serves a stale client (P2022 500s; bit v2.4.0). prisma.config.ts carries the Prisma 7 datasource and must ship in the tarball.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:52:02 +02:00
BOHA
c2746d78c9 fix(dashboard): stale punch button/KPIs after attendance changes on other pages
Verified flow: clock in on dashboard -> delete the record in /attendance/admin
-> navigate back: 'Zaznamenat odchod', the Pritomni KPI and the presence card
still showed the pre-delete state until F5. Root cause: every attendance
mutation outside the dashboard invalidated only ["attendance"], never
["dashboard"], so within the dashboard query's 60s staleTime the remount was
served from cache.

- useAttendanceAdmin create/bulk/edit/delete, /attendance punch +
  leave-request, AttendanceCreate, LeaveApproval approve/reject now also
  invalidate ["dashboard"] (CLAUDE.md rule: mutations invalidate every domain
  that embeds their data)
- systemic backstop: dashboardOptions uses refetchOnMount "always" - the
  dashboard aggregates attendance/offers/invoices/orders/projects/leave, and
  domain pages can't all be expected to invalidate it; returning to the
  dashboard must never show pre-mutation data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:40:06 +02:00
BOHA
4e534471d9 chore(release): v2.4.0 - dashboard today-window + cross-login cache fix, issued orders -> suppliers (migration), @db.Date filter sweep
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:34:35 +02:00
BOHA
975a555af5 fix(dates): @db.Date filters built from local midnight queried the wrong day
Prisma truncates a JS Date used in a WHERE filter on a @db.Date column to
its UTC date part. Under TZ=Europe/Prague a local-midnight boundary
(new Date(y,m,d) = 22:00/23:00Z of the previous day) therefore filtered as
the PREVIOUS calendar date. Same class as the dashboard fix; full sweep of
every @db.Date filter in the codebase. New shared helper
utcMidnightOfLocalDay() in src/utils/date.ts.

Fixed (all previously off by one day):
- attendance.service: getStatus today+month windows; getWorkfund (last day
  of each month was double-counted across months); getPrintData (monthly
  print included prev month's last day); listAttendance (admin month view
  included prev month's last day AND dropped the selected month's last
  day); createAttendance duplicate/overlap validation (checked only the
  PREVIOUS day - same-day duplicates were never caught, neighbors falsely
  rejected)
- invoice-alerts: the 'splatnost za 3 dny' advance alert had NEVER fired
  (due==today+3 was outside the fetched window); window computation
  extracted as computeAlertWindow() + pure tests
- invoices.service: month list/totals filter dropped invoices issued on the
  month's last day; getInvoiceStats month/year bounds; markOverdueInvoices
  boundary; auto paid_date could store yesterday during 00:00-02:00
- received-invoices auto paid_date, issued-orders default order_date: same
  night-window write hazard, now via the helper
- attendance.schema: shift_date hardened to isoDateString (bare YYYY-MM-DD)

+9 regression tests (month boundaries, same-day duplicate, last-day-of-month
invoice in list+totals, alert window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:29:28 +02:00
BOHA
6a22195c7d feat(issued-orders): counterparty is now a supplier (dodavatel), not a customer
Issued orders are purchase orders WE send - they must pick from suppliers
(sklad_suppliers), not from customers. Per user decision customer_id was
REPLACED (not kept alongside): migration drops issued_orders.customer_id and
adds supplier_id FK -> sklad_suppliers (existing rows lose their counterparty
- the feature is days old; re-point them in the UI).

- service: input/filters/search (suppliers.name + ico)/enrichment/detail all
  supplier-based; create validates the supplier inside the transaction and
  update before write -> Czech 400 'Dodavatel nenalezen' instead of P2003 500;
  detail returns a minimal supplier field set (no internal notes leak)
- routes: supplier_id on list + stats; new GET /issued-orders/suppliers
  lookup (orders.view/create/edit guard - orders users lack warehouse.manage
  which guards the warehouse suppliers CRUD), active suppliers only,
  name+id ordering
- PDF: Dodavatel block now renders the supplier (name, newline-split address,
  IC/DIC), layout and both language label sets unchanged
- frontend: new SupplierPicker kit component (CustomerPicker untouched),
  IssuedOrderDetail/IssuedOrders switched to supplier_id/supplier_name; the
  picker keeps a fallback option for orders whose supplier was later
  deactivated; WarehouseSuppliers CRUD now also invalidates issued-orders so
  the picker can't go stale
- tests: issued-orders suite switched to supplier fixtures + new coverage
  (lookup shape + 403, nonexistent supplier 400, PDF supplier block)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:29:06 +02:00
BOHA
74ce24e3fa fix(dashboard,auth): today-window on @db.Date + query-cache cleared across logins
Two dashboard bugs reported after clock-in:

1. 'Dochazka dnes' / 'Pritomni dnes' showed everyone absent even after
   refresh: attendance.shift_date is @db.Date and Prisma truncates filter
   Dates to their UTC date part, so the local-midnight boundaries
   (new Date(y,m,d) = 22:00/23:00Z of the previous day) queried
   [yesterday, today) and matched zero of today's punches. Boundaries are
   now UTC-midnight instants of the local (Prague) calendar day.
   Reproduced empirically against real rows; route-level regression test
   added (dashboard.test.ts).

2. After logout + login as a different user, cached dashboards/buttons from
   the previous user were served: query keys are user-agnostic and the
   React Query cache outlives the session. logout(), login() and the 2FA
   verify path now clear the whole cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:28:45 +02:00
BOHA
8ee5a443ef chore(release): v2.3.0 — audit fix pass: attendance/2FA/invoice/trips/warehouse fixes, attachment-blob removal, dep cleanup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:22:49 +02:00
BOHA
b3e2abf9b2 fix(attendance): admin month view no longer truncates at 100 records
The KPI cards and on-screen summary totals (computeUserTotals) are computed
client-side from one month fetch. The hook requested limit=1000 but
parsePagination silently clamped it to 100, so once a month exceeded 100
attendance rows (~5 employees x 21 shifts) the newest-first list dropped the
EARLIEST days of the month — screen totals undercounted while the print path
(complete server fetch) stayed correct. This is the data-volume cliff that
made the summary counting look like it changed without any formula change:
git archaeology confirms every counting formula is bit-identical from v1.9.1
through HEAD.

- routes/admin/attendance.ts: list endpoint passes maxLimit 2000 to
  parsePagination (complete-month view; 2000 ~ 64 employees x 31 days)
- useAttendanceAdmin: month fetch requests limit=2000 with the constraint
  documented
- regression test: a 120-row month returns all 120 records

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:20:40 +02:00
BOHA
1826fc7976 fix: audit fix pass #1 — all 19 verified HIGH findings + critical dep cleanup
Fixes every CRITICAL/HIGH finding from the 2026-06-09 full-codebase audit
(REVIEW_FINDINGS.md); each fix went through independent spec + code-quality
review. Plan and per-task log: docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md

- attendance: schemas accept the combined local datetimes the forms/service
  use (new dateTimeString helpers in schemas/common.ts), breaks persist on
  create, AttendanceCreate submit rebuilt — every submit 400'd since 519edce
- 2fa: backup codes wired to /totp/backup-verify (+ remember-me parity),
  enrollment QR generated locally via qrcode (CSP-blocked external service
  also leaked the secret), dashboard shows per-user enrollment, not policy
- invoices/orders: per-line VAT survives re-saves (numberOr 0-respecting
  coercion in formatters.ts), billing_text persists on update, issued-order
  status transitions update UI gates
- trips: real pagination on all 3 pages, GET /trips/stats server aggregate
  (shared buildTripsWhere + legacy distance coalesce), vehicle_id applies on
  PUT with both-vehicle odometer recompute, print rebuilt (sync window.open,
  escaped template, server totals)
- orders api: attachment_data PDF blob excluded from all non-binary reads
- warehouse: unit field is a Select over UnitEnum, receipt attachments
  downloadable via new authenticated GET route
- downloads: shared RFC 5987 contentDisposition helper — Czech filenames no
  longer 500 (warehouse, received-invoices, orders endpoints)
- misc: block-env hook actually blocks (exit 2 + stderr), project create
  works with empty dates, NaN filter guards on trips endpoints
- deps: remove unused concurrently (clears both critical advisories), pin
  @hono/node-server >=1.19.13 via overrides (clears the 3 moderates without
  the Prisma 6 downgrade), drop deprecated @types stubs

Gates: tsc -b clean - vitest 30 files / 342 tests (31 new) - eslint 0 errors
- build OK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:59:47 +02:00
100 changed files with 9404 additions and 4703 deletions

View File

@@ -1,14 +1,23 @@
// Block direct .env file edits (not .env.example, .env.production, .env.test)
let data = '';
process.stdin.on('data', chunk => data += chunk);
process.stdin.on('end', () => {
//
// Hook exit-code contract (Claude Code PreToolUse):
// exit 0 = allow (stdout JSON is only parsed on exit 0)
// exit 2 = BLOCK — the reason must be written to STDERR
// any other exit code = non-blocking error (the tool call proceeds)
// So to actually block, we must print to stderr and exit 2.
let data = "";
process.stdin.on("data", (chunk) => (data += chunk));
process.stdin.on("end", () => {
try {
const input = JSON.parse(data);
const filePath = input.tool_input?.file_path || '';
const basename = filePath.split('/').pop().split('\\').pop();
if (basename === '.env') {
console.log(JSON.stringify({ decision: 'block', reason: 'Direct .env edits are blocked. Use .env.example instead.' }));
process.exit(1);
const filePath = input.tool_input?.file_path || "";
const basename = filePath.split("/").pop().split("\\").pop();
if (basename === ".env") {
console.error("Direct .env edits are blocked. Use .env.example instead.");
process.exit(2);
}
} catch {}
} catch {
// Deliberate fail-open: on unparseable input exit 0 (allow). Exiting 2
// here would block EVERY Edit/Write if the hook payload format changed.
}
});

View File

@@ -520,13 +520,19 @@ The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. The
2. `npm run build`
3. Commit and tag (`git tag -a vX.Y.Z`)
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts`
(⚠️ `prisma.config.ts` is REQUIRED — Prisma 7 keeps the datasource URL there;
without it, `prisma generate`/`migrate deploy` on prod have no datasource)
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
- Path: `/var/www/app-ts`
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
- Remove old files: `rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json`
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
- Install dependencies: `npm install --omit=dev`
- Regenerate the Prisma client: `npx prisma generate`**MANDATORY**.
`npm install` skips regeneration when dependencies didn't change, leaving a
stale client that still selects dropped/renamed columns → P2022 500s in
prod (bit the v2.4.0 supplier release).
- Apply Prisma migrations: `npx prisma migrate deploy`
- Restart: `pm2 restart app-ts --update-env`

View File

@@ -0,0 +1,188 @@
# 2026-06-10 — Audit fix pass #1: all verified CRITICAL/HIGH findings
Source: `REVIEW_FINDINGS.md` (2026-06-09 full audit, 304 files). Scope of this pass:
the 19 verified per-file **HIGH** findings + the project-level **CRITICAL** dependency
finding. The two project-level HIGH _structural refactors_ (service-layer extraction for
6 route files; PDF template dedup) are explicitly **out of scope** — they are
multi-thousand-line refactors that deserve their own pass.
Execution: subagent-driven development — one implementer per task (sequential, shared
test DB forbids parallel test runs), spec-compliance review then code-quality review
after each. No commits until the user asks. Final full gates:
`npx tsc -b --noEmit` · `npx vitest run` · `npm run lint` · `npm run build`.
---
## Task H — Dependencies (project-level CRITICAL) — done inline by controller
- Remove `concurrently` from devDependencies (unused; carries both critical advisories
GHSA-w7jw-789q-3m8p / shell-quote CVSS 8.1).
- Add `"overrides": { "@hono/node-server": "^1.19.13" }` (clears the 3 moderate
advisories from the prisma → @prisma/dev chain; do NOT take npm's Prisma 6 downgrade).
- Remove deprecated stub types `@types/dompurify`, `@types/bcryptjs`; move `@types/jsdom`
to devDependencies.
- Add `qrcode` + `@types/qrcode` (needed by Task B to replace the CSP-blocked external
QR service).
- `npm install`, verify `npm audit` (criticals+moderates gone), typecheck.
## Task A — Attendance create/edit broken since 519edce (top-5 #1)
Findings (both verified HIGH):
- `src/admin/pages/AttendanceCreate.tsx:80-99` — handleSubmit posts the raw form whose
shape the API does not accept: `arrival_date`/`break_start_date`/`break_start_time`/
`break_end_*`/`departure_date` are not in CreateAttendanceSchema (silently stripped —
breaks and overnight dates never save); times are never combined with dates (unlike
useAttendanceAdmin's combineDatetime), so a validated "HH:MM" would reach
createAttendance which does `new Date('08:00')` → Invalid Date → 500; and since commit
519edce the always-sent empty-string time fields (`arrival_time: ""`) fail timeString
validation, so EVERY submit from this page — including leave records — returns 400.
- `src/admin/pages/AttendanceAdmin.tsx:407-442` (logic in `useAttendanceAdmin.ts`) —
create/edit modal submits send combined `YYYY-MM-DDTHH:MM:00` datetimes for
arrival/departure/break, but since 519edce CreateAttendanceSchema/UpdateAttendanceSchema
validate them with `timeString` (HH:MM only) — every create/edit containing a time is
rejected 400 "Čas musí být ve formátu HH:MM" (and the service itself expects full
datetimes via `new Date(...)`); additionally `break_start`/`break_end` are absent from
CreateAttendanceSchema, so breaks are silently stripped on create.
Direction: first `git show 519edce` to understand the intent; the schema contradicts
both client and service, so the fix is to make the schemas validate the combined
local-datetime wire format the client sends and the service consumes (lenient helper in
`src/schemas/common.ts` per repo convention), add `break_start`/`break_end` to the create
schema, and rewrite AttendanceCreate.tsx's submit to combine date+time and omit empty
optionals. TDD: route-level regression tests (work record with times+breaks, overnight,
leave record without times, update) in `src/__tests__/`.
## Task B — 2FA: backup-code login, enrollment QR, dashboard status (top-5 #2)
Findings (all verified HIGH):
- `src/admin/context/AuthContext.tsx:263-272` — backup-code login broken: verify2FA
always POSTs to /login/totp with the code as totp_code plus an isBackup flag the server
ignores; TotpVerifySchema requires exactly 6 digits so 8-char backup codes always 400.
The real endpoint POST /api/admin/totp/backup-verify (expects backup_code) is never
called anywhere in the frontend — lockout for any user who lost their authenticator.
- `src/admin/components/dashboard/DashProfile.tsx:556` — QR `<img>` from api.qrserver.com
is blocked by the production CSP (img-src 'self' data: blob: + osm tiles), so prod 2FA
enrollment shows a broken image. (Also a privacy bug: the TOTP secret is sent to a
third-party URL.) Fix: generate the QR locally with the `qrcode` package
(`QRCode.toDataURL(otpauthUrl)`) — data: is already CSP-allowed.
- `src/admin/pages/Dashboard.tsx:89-91` — totpEnabled derived from require2FAOptions()
which returns the COMPANY-WIDE require_2fa policy flag, not the user's enrollment; the
per-user endpoint (totp.ts:190-197) is never queried. Wrong "2FA Aktivní/Neaktivní" and
wrong button shown.
Direction: read `src/routes/admin/totp.ts` first; wire Login/AuthContext so a backup code
goes to backup-verify and completes the login-token flow. If the server endpoint doesn't
fit the loginToken flow, extend it minimally (single-use code consumption + logAudit
preserved). Server test for backup-verify login path if server is touched.
## Task C — Invoice/issued-order edit integrity (top-5 #3)
Findings (all verified HIGH):
- `src/admin/pages/InvoiceDetail.tsx:728` — edit hydration overwrites each item's
persisted per-line VAT with the invoice-level rate (`vat_rate: Number(inv.vat_rate) || 21`)
though invoice_items.vat_rate is a real per-item column — re-saving a mixed-rate invoice
silently corrupts per-line VAT in the DB; `|| 21` also turns legitimate 0% into 21%
(same falsy-zero at line 689).
- `src/admin/pages/InvoiceDetail.tsx:1891-1902` — editing "Text fakturace (na PDF)" is
silently lost: payload includes billing_text but UpdateInvoiceSchema
(src/schemas/invoices.schema.ts:46-67) has no billing_text field, so Zod strips it
(updateInvoice supports it via strFields). Add the field to the schema + server test.
- `src/admin/pages/IssuedOrderDetail.tsx:606-611` — hydration falsy-fallbacks:
`quantity: Number(it.quantity) || 1` turns saved 0 into 1; `vat_rate: Number(it.vat_rate)
|| Number(d.vat_rate) || 21` turns a saved 0% line into 21% — displayed wrong and
silently persisted on next save.
- `src/admin/pages/IssuedOrderDetail.tsx:826-839` — handleStatusChange never mirrors the
new status into form.status, so StatusChip, the `editable` flag and the delete-button
gate use the stale status after every transition.
Use Number.isFinite-style coercion that respects 0 (e.g. `const n = Number(x);
Number.isFinite(n) ? n : fallback`).
## Task D — Trips: truncated lists/totals + dropped vehicle edit (top-5 #4a)
Findings (all verified HIGH):
- `src/admin/lib/queries/trips.ts:52-63` — tripListOptions consumes the paginated
endpoint via plain jsonQuery, discarding pagination meta; Trips.tsx uses default limit
25 and TripsAdmin perPage:100 (server hard cap) — both silently truncate with no
pagination controls.
- `src/admin/lib/queries/trips.ts:95-103` — tripHistoryOptions sends no page/per_page →
25 rows; TripsHistory.tsx computes monthly km/business totals from the truncated rows.
- `src/admin/pages/Trips.tsx:324-336` — stat cards computed from the 25-row page;
header labels stats "current month" though the query has no month filter.
- `src/admin/pages/TripsAdmin.tsx:295,665-676` — edit modal sends vehicle_id but
UpdateTripSchema (src/schemas/trips.schema.ts:23-31) has no vehicle_id and the PUT
handler never applies it — vehicle change silently dropped. Add to schema
(nullableIntIdFromForm) + apply in route + audit + server test.
- `src/admin/pages/TripsHistory.tsx:98-104,121-128` — same 25-row truncation for the
month view and its stat cards.
Direction for totals: prefer a server-side aggregate (the repo already has per-currency
totals endpoints for invoices/orders as the pattern) over fetching all pages client-side;
lists get real pagination using the existing `usePaginatedQuery`/`Pagination` kit pieces
used elsewhere. Keep the month-filter semantics of TripsHistory correct (totals must
cover the WHOLE month, not one page).
## Task E — orders.service.ts: PDF blob in every response (top-5 #4b)
Finding (verified HIGH): `src/services/orders.service.ts:154-176,196-216,228-263,570,683`
— attachment_data (full PDF blob, Bytes) is fetched on every query with no select/omit
and spread into API responses by enrichOrder/getOrder; getOrderTotals loads every blob
for the whole filtered set just to sum totals; updateOrder/deleteOrder pre-reads pull the
blob to check status/number. A dedicated /:id/attachment endpoint already exists.
Exclude the blob from list/detail/totals/pre-reads (keep has_attachment-style boolean if
the FE needs it — check FE usage), keep the attachment endpoint working, extend
`src/__tests__/orders-list.test.ts` to assert attachment_data is absent.
## Task F — Warehouse: unit enum 400s + dead attachment download
Findings (both verified HIGH):
- `src/admin/pages/WarehouseItemDetail.tsx:451-462` — "Jednotka" is free-text but the
server requires UnitEnum ("ks","m","kg","bal","sada","m2","m3","l") — any other value
400s create/update. Make it a Select over the enum values (single source: mirror the
server enum list).
- `src/admin/pages/WarehouseReceiptDetail.tsx:251-259` — attachment link targets
GET /warehouse/receipts/:id/attachments/:attachmentId which does not exist (only POST
upload + DELETE are registered in warehouse.ts), and a plain <a href> sends no
Authorization header anyway. Add the GET download route (requirePermission, correct
content-type/disposition) + authenticated blob download in the UI (fetch via apiFetch →
object URL, like other binary downloads in the repo). Server test for the new route.
## Task G — Small fixes
- `.claude/hooks/block-env.js:10-11` (verified HIGH) — block decision never takes
effect: prints {decision:'block'} JSON to stdout then exits 1; Claude Code only parses
stdout JSON on exit 0 and only blocks on exit 2 (reason on stderr). Fix: exit 2 with the
reason on stderr.
- `src/admin/pages/Projects.tsx:153-154` (verified HIGH) — create modal submits
start_date/end_date initialized to "" which isoDateString.nullish() rejects → creating
a project without filling BOTH dates always 400s. Send null/omit when empty.
---
## Status
- [x] H — dependencies (controller, inline) — criticals+moderates cleared; only the
2 known-mitigated quill lows remain
- [x] A — attendance create/edit — dateTimeString helpers, break fields, rewritten
AttendanceCreate payload; 6 regression tests
- [x] B — 2FA — backup-verify wired (+ remember-me parity server-side), local QR via
qrcode lib, per-user totp status; 6 tests
- [x] C — invoice/issued-order edit integrity — numberOr (shared in formatters.ts),
billing_text in UpdateInvoiceSchema, status mirror; 2 tests
- [x] D — trips — paginated queries + Pagination UI on 3 pages, GET /trips/stats
(buildTripsWhere shared), vehicle_id on PUT + both-vehicle odometer recompute,
print rebuilt (sync window.open, escaped string template); 7 tests
- [x] E — orders attachment_data excluded from all non-binary reads (incl.
numbering/invoices/orders-pdf callers); 4 tests
- [x] F — warehouse unit Select + GET receipt attachment route + authenticated blob
download; RFC 5987 content-disposition helper (also fixes received-invoices +
orders Czech-filename 500); 6 tests
- [x] G — block-env hook exit 2/stderr (verified empirically), Projects empty dates
→ null
- [x] Final gates: tsc -b clean · vitest 30 files / 342 tests green · lint 0 errors ·
build OK. Whole-diff 3-lens review: see final report.

155
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "2.1.5",
"version": "2.4.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "2.1.5",
"version": "2.4.4",
"license": "ISC",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",
@@ -26,7 +26,6 @@
"@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
@@ -52,8 +51,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/bcryptjs": "^2.4.6",
"@types/dompurify": "^3.0.5",
"@types/jsdom": "^28.0.1",
"@types/jsonwebtoken": "^9.0.10",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.5.0",
@@ -64,7 +62,6 @@
"@types/react-dom": "^19.2.3",
"@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -1729,9 +1726,9 @@
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -3010,13 +3007,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -3042,16 +3032,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -3076,6 +3056,7 @@
"version": "28.0.1",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.1.tgz",
"integrity": "sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -3088,6 +3069,7 @@
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz",
"integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
@@ -3236,14 +3218,15 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
@@ -4154,36 +4137,6 @@
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@@ -4297,31 +4250,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
@@ -4713,6 +4641,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -5835,16 +5764,6 @@
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
"license": "MIT"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -7258,6 +7177,7 @@
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
@@ -8087,16 +8007,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -8244,19 +8154,6 @@
"node": ">=8"
}
},
"node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -8577,22 +8474,6 @@
"node": ">=14.18.0"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -8817,16 +8698,6 @@
"node": ">=20"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.2.0",
"version": "2.4.4",
"description": "",
"main": "dist/server.js",
"scripts": {
@@ -47,7 +47,6 @@
"@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
@@ -73,8 +72,7 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/bcryptjs": "^2.4.6",
"@types/dompurify": "^3.0.5",
"@types/jsdom": "^28.0.1",
"@types/jsonwebtoken": "^9.0.10",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.5.0",
@@ -85,7 +83,6 @@
"@types/react-dom": "^19.2.3",
"@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -98,5 +95,8 @@
"typescript-eslint": "^8.61.0",
"vite": "^8.0.0",
"vitest": "^4.1.0"
},
"overrides": {
"@hono/node-server": "^1.19.13"
}
}

View File

@@ -0,0 +1,16 @@
-- DropForeignKey
ALTER TABLE `issued_orders` DROP FOREIGN KEY `issued_orders_ibfk_1`;
-- DropIndex
DROP INDEX `issued_orders_customer_id` ON `issued_orders`;
-- AlterTable
ALTER TABLE `issued_orders` DROP COLUMN `customer_id`,
ADD COLUMN `supplier_id` INTEGER NULL;
-- CreateIndex
CREATE INDEX `issued_orders_supplier_id` ON `issued_orders`(`supplier_id`);
-- AddForeignKey
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_supplier_fk` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `issued_orders` ADD COLUMN `order_text` VARCHAR(500) NULL;

View File

@@ -0,0 +1,15 @@
-- AlterTable
ALTER TABLE `issued_order_items` DROP COLUMN `vat_rate`;
-- AlterTable
ALTER TABLE `issued_orders` DROP COLUMN `apply_vat`,
DROP COLUMN `vat_rate`;
-- AlterTable
ALTER TABLE `orders` DROP COLUMN `apply_vat`,
DROP COLUMN `vat_rate`;
-- AlterTable
ALTER TABLE `quotations` DROP COLUMN `apply_vat`,
DROP COLUMN `vat_rate`;

View File

@@ -0,0 +1,21 @@
-- AlterTable
ALTER TABLE `issued_orders` ADD COLUMN `locked_at` DATETIME(0) NULL,
ADD COLUMN `locked_by` INTEGER NULL;
-- CreateTable
CREATE TABLE `issued_order_sections` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`issued_order_id` INTEGER NOT NULL,
`position` INTEGER NULL DEFAULT 0,
`title` VARCHAR(500) NULL,
`title_cz` VARCHAR(500) NULL,
`content` TEXT NULL,
`modified_at` DATETIME(0) NULL,
INDEX `issued_order_sections_order_id`(`issued_order_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `issued_order_sections` ADD CONSTRAINT `issued_order_sections_fk` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE `issued_orders` DROP COLUMN `delivery_terms`,
DROP COLUMN `issued_by`,
DROP COLUMN `notes`,
DROP COLUMN `payment_terms`;

View File

@@ -193,7 +193,6 @@ model customers {
sync_version Int? @default(0)
invoices invoices[]
orders orders[]
issued_orders issued_orders[]
projects projects[]
quotations quotations[]
}
@@ -348,8 +347,6 @@ model orders {
status String? @default("prijata") @db.VarChar(30)
currency String? @default("CZK") @db.VarChar(10)
language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
@@ -370,29 +367,43 @@ model orders {
model issued_orders {
id Int @id @default(autoincrement())
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
customer_id Int?
supplier_id Int?
status issued_orders_status @default(draft)
currency String? @default("CZK") @db.VarChar(10)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
order_date DateTime? @db.Date
delivery_date DateTime? @db.Date
language String? @default("cs") @db.VarChar(5)
delivery_terms String? @db.VarChar(500)
payment_terms String? @db.VarChar(500)
issued_by String? @db.VarChar(255)
notes String? @db.Text
order_text String? @db.VarChar(500)
internal_notes String? @db.Text
locked_by Int?
locked_at DateTime? @db.DateTime(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
issued_order_items issued_order_items[]
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_ibfk_1")
issued_order_sections issued_order_sections[]
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
@@index([customer_id], map: "issued_orders_customer_id")
@@index([supplier_id], map: "issued_orders_supplier_id")
@@index([status, order_date], map: "idx_issued_orders_status_date")
}
// Mirrors scope_sections (offers) 1:1 — free-form bilingual rich-text sections
// rendered on their own PDF page. Kept as a separate table (not shared) so the
// FK cascade and per-document ordering stay simple.
model issued_order_sections {
id Int @id @default(autoincrement())
issued_order_id Int
position Int? @default(0)
title String? @db.VarChar(500)
title_cz String? @db.VarChar(500)
content String? @db.Text
modified_at DateTime? @db.DateTime(0)
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_sections_fk")
@@index([issued_order_id], map: "issued_order_sections_order_id")
}
model issued_order_items {
id Int @id @default(autoincrement())
issued_order_id Int
@@ -401,7 +412,6 @@ model issued_order_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)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
position Int? @default(0)
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
@@ -494,8 +504,6 @@ model quotations {
valid_until DateTime? @db.Date
currency String? @default("CZK") @db.VarChar(10)
language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
order_id Int?
status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500)
@@ -796,7 +804,8 @@ model sklad_suppliers {
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[]
receipts sklad_receipts[]
issued_orders issued_orders[]
@@map("sklad_suppliers")
}

View File

@@ -0,0 +1,445 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import { securityHeaders } from "../middleware/security";
import attendanceRoutes from "../routes/admin/attendance";
// ---------------------------------------------------------------------------
// Regression tests for the attendance create/edit wire format.
//
// The admin create/edit forms send COMBINED local datetimes
// ("YYYY-MM-DDTHH:MM:00", built by combineDatetime from a date + time input)
// for arrival_time / departure_time / break_start / break_end, and the
// service parses them with `new Date(...)`. Commit 519edce validated these
// fields with `timeString` (bare HH:MM), which rejected every create/edit
// containing a time, and CreateAttendanceSchema lacked break_start/break_end
// entirely, so breaks were silently stripped on create. These tests pin the
// combined-datetime contract at the HTTP route level.
// ---------------------------------------------------------------------------
// All fixtures live on far-future dates so they can never collide with real
// rows in the throwaway test DB; cleanup deletes exactly this range.
const RANGE_START = new Date("2098-01-01T00:00:00");
const RANGE_END = new Date("2099-01-01T00:00:00");
let app: Awaited<ReturnType<typeof buildApp>>;
let adminUserId: number;
let adminToken: string;
async function buildApp() {
const a = Fastify({ logger: false });
await a.register(cookie);
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
a.addHook("onRequest", securityHeaders);
await a.register(attendanceRoutes, { prefix: "/api/admin/attendance" });
return a;
}
/**
* Generate a JWT access token. `requireAuth` re-loads auth data from the DB
* via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here.
*/
function generateToken(user: {
id: number;
username: string;
roleName: string | null;
}): string {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
config.jwt.secret,
{ expiresIn: "15m" },
);
}
async function authPost(path: string, token: string, body: unknown) {
return app.inject({
method: "POST",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
async function authPut(path: string, token: string, body: unknown) {
return app.inject({
method: "PUT",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
async function cleanupFixtureRows() {
await prisma.attendance.deleteMany({
where: {
user_id: adminUserId,
shift_date: { gte: RANGE_START, lt: RANGE_END },
},
});
}
beforeAll(async () => {
app = await buildApp();
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminUserId = admin.id;
adminToken = generateToken({
id: admin.id,
username: admin.username,
roleName: admin.roles?.name ?? null,
});
await cleanupFixtureRows();
});
beforeEach(async () => {
await cleanupFixtureRows();
});
afterAll(async () => {
await cleanupFixtureRows();
if (app) await app.close();
});
describe("POST /api/admin/attendance (combined datetimes)", () => {
it("creates a work record with arrival+departure datetimes and persists them", async () => {
const arrival = "2098-03-10T08:00:00";
const departure = "2098-03-10T16:30:00";
const res = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-03-10",
leave_type: "work",
arrival_time: arrival,
departure_time: departure,
notes: "attendance_wire_test work",
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: body.data.id },
});
expect(rec).toBeTruthy();
expect(rec!.leave_type).toBe("work");
expect(rec!.arrival_time?.getTime()).toBe(new Date(arrival).getTime());
expect(rec!.departure_time?.getTime()).toBe(new Date(departure).getTime());
});
it("persists break_start/break_end on create (previously silently stripped)", async () => {
const res = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-03-11",
leave_type: "work",
arrival_time: "2098-03-11T08:00:00",
departure_time: "2098-03-11T16:30:00",
break_start: "2098-03-11T12:00:00",
break_end: "2098-03-11T12:30:00",
notes: "attendance_wire_test break",
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: body.data.id },
});
expect(rec!.break_start?.getTime()).toBe(
new Date("2098-03-11T12:00:00").getTime(),
);
expect(rec!.break_end?.getTime()).toBe(
new Date("2098-03-11T12:30:00").getTime(),
);
});
it("creates a leave record with NO time fields", async () => {
const res = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-03-12",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test leave",
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: body.data.id },
});
expect(rec!.leave_type).toBe("vacation");
expect(Number(rec!.leave_hours)).toBe(8);
expect(rec!.arrival_time).toBeNull();
expect(rec!.departure_time).toBeNull();
});
it('tolerates "" for unset datetime fields (old form payloads) → null', async () => {
// The AttendanceCreate page historically always sent empty strings for
// the unfilled time fields — even for leave records — which 400'd EVERY
// submit after 519edce. The schema must treat "" as "not set".
const res = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-03-13",
leave_type: "sick",
leave_hours: 8,
arrival_time: "",
departure_time: "",
break_start: "",
break_end: "",
notes: "attendance_wire_test empty strings",
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: body.data.id },
});
expect(rec!.leave_type).toBe("sick");
expect(rec!.arrival_time).toBeNull();
expect(rec!.departure_time).toBeNull();
expect(rec!.break_start).toBeNull();
expect(rec!.break_end).toBeNull();
});
});
describe("GET /api/admin/attendance (complete month, no truncation)", () => {
it("returns every record of a month with more than 100 rows", async () => {
// The admin month view computes the KPI cards and summary totals from
// this single fetch, so it must cover the COMPLETE month. With the old
// 100-row pagination cap, months with >100 records were silently
// truncated (newest-first) and the screen totals dropped the earliest
// days while the print stayed complete.
const rows = [];
for (let day = 1; day <= 30; day++) {
for (let s = 0; s < 4; s++) {
rows.push({
user_id: adminUserId,
shift_date: new Date(2098, 4, day, 12, 0, 0),
leave_type: "work" as const,
arrival_time: new Date(2098, 4, day, 6 + s, 0, 0),
departure_time: new Date(2098, 4, day, 10 + s, 0, 0),
notes: "attendance_wire_test bulk month",
});
}
}
await prisma.attendance.createMany({ data: rows });
const res = await app.inject({
method: "GET",
url: "/api/admin/attendance?year=2098&month=5&limit=2000",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data).toHaveLength(120);
expect(body.pagination.total).toBe(120);
});
});
describe("GET /api/admin/attendance (month window boundaries, @db.Date)", () => {
it("includes the month's own LAST day and excludes the neighbor month's first day", async () => {
// shift_date is @db.Date — Prisma compares the filter Dates by their UTC
// date part. The old LOCAL-midnight month bounds (22:00/23:00 UTC of the
// previous day) shifted the whole window a day back: the June list
// included May 31 and DROPPED June 30. Fixture rows sit exactly on the
// 2098-06 / 2098-07 boundary.
const juneLast = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 5, 30, 12, 0, 0), // 2098-06-30, local noon
leave_type: "work",
arrival_time: new Date(2098, 5, 30, 8, 0, 0),
departure_time: new Date(2098, 5, 30, 16, 0, 0),
notes: "attendance_wire_test june-last-day",
},
});
const julyFirst = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 6, 1, 12, 0, 0), // 2098-07-01, local noon
leave_type: "work",
arrival_time: new Date(2098, 6, 1, 8, 0, 0),
departure_time: new Date(2098, 6, 1, 16, 0, 0),
notes: "attendance_wire_test july-first-day",
},
});
const june = await app.inject({
method: "GET",
url: "/api/admin/attendance?year=2098&month=6&limit=100",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(june.statusCode).toBe(200);
const juneIds = june.json().data.map((r: { id: number }) => r.id);
expect(juneIds).toContain(juneLast.id);
expect(juneIds).not.toContain(julyFirst.id);
const july = await app.inject({
method: "GET",
url: "/api/admin/attendance?year=2098&month=7&limit=100",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(july.statusCode).toBe(200);
const julyIds = july.json().data.map((r: { id: number }) => r.id);
expect(julyIds).toContain(julyFirst.id);
expect(julyIds).not.toContain(juneLast.id);
});
});
describe("POST /api/admin/attendance (same-day duplicate window, @db.Date)", () => {
it("rejects a second leave record on the SAME day", async () => {
const first = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-10",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test dup-leave-first",
});
expect(first.statusCode).toBe(201);
// Old bug: the duplicate/overlap window was built from LOCAL midnights of
// the UTC-midnight shiftDate, so the validation queried ONLY the previous
// day — a same-day duplicate leave sailed through undetected.
const second = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-10",
leave_type: "sick",
leave_hours: 8,
notes: "attendance_wire_test dup-leave-second",
});
expect(second.statusCode).toBe(400);
expect(second.json().success).toBe(false);
});
it("does NOT falsely reject a leave on the day AFTER an existing record", async () => {
const first = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-10",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test neighbor-day-first",
});
expect(first.statusCode).toBe(201);
// Old bug (the other half): creating a record for the NEXT day queried
// the window [Aug 10, Aug 11) — i.e. yesterday's records — and bounced
// with a false "záznam o nepřítomnosti již existuje".
const nextDay = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-11",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test neighbor-day-next",
});
expect(nextDay.statusCode).toBe(201);
expect(nextDay.json().success).toBe(true);
});
});
describe("PUT /api/admin/attendance/:id (combined datetimes)", () => {
it("updates a record with combined datetimes (incl. overnight departure)", async () => {
const created = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 2, 14, 12, 0, 0),
leave_type: "work",
arrival_time: new Date("2098-03-14T08:00:00"),
departure_time: new Date("2098-03-14T16:30:00"),
notes: "attendance_wire_test update",
},
});
const res = await authPut(
`/api/admin/attendance/${created.id}`,
adminToken,
{
leave_type: "work",
arrival_time: "2098-03-14T22:00:00",
departure_time: "2098-03-15T06:00:00",
break_start: "2098-03-15T02:00:00",
break_end: "2098-03-15T02:30:00",
notes: "attendance_wire_test updated",
},
);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: created.id },
});
expect(rec!.arrival_time?.getTime()).toBe(
new Date("2098-03-14T22:00:00").getTime(),
);
expect(rec!.departure_time?.getTime()).toBe(
new Date("2098-03-15T06:00:00").getTime(),
);
expect(rec!.break_start?.getTime()).toBe(
new Date("2098-03-15T02:00:00").getTime(),
);
expect(rec!.break_end?.getTime()).toBe(
new Date("2098-03-15T02:30:00").getTime(),
);
expect(rec!.notes).toBe("attendance_wire_test updated");
});
it("clears times when null is sent (leave-type edit)", async () => {
const created = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 2, 16, 12, 0, 0),
leave_type: "work",
arrival_time: new Date("2098-03-16T08:00:00"),
departure_time: new Date("2098-03-16T16:30:00"),
notes: "attendance_wire_test to-leave",
},
});
const res = await authPut(
`/api/admin/attendance/${created.id}`,
adminToken,
{
leave_type: "vacation",
leave_hours: 8,
arrival_time: null,
departure_time: null,
break_start: null,
break_end: null,
notes: "attendance_wire_test to-leave",
},
);
expect(res.statusCode).toBe(200);
expect(res.json().success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: created.id },
});
expect(rec!.leave_type).toBe("vacation");
expect(rec!.arrival_time).toBeNull();
expect(rec!.departure_time).toBeNull();
});
});

View File

@@ -0,0 +1,100 @@
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 dashboardRoutes from "../routes/admin/dashboard";
// ---------------------------------------------------------------------------
// Regression test for the "Docházka dnes" / "Přítomní dnes" dashboard window.
//
// attendance.shift_date is @db.Date and Prisma truncates a filter Date to its
// UTC date part. The dashboard used local-midnight boundaries
// (new Date(y, m, d) = 22:00/23:00 UTC of the PREVIOUS day under
// TZ=Europe/Prague), which truncated to [yesterday, today) — today's punches
// matched zero rows and everyone showed as absent. The boundaries must be
// UTC-midnight instants of the LOCAL calendar day.
//
// Unlike the other suites this one has to use the REAL current date (the
// route computes "today" internally), so fixtures are tracked by id and
// deleted in afterAll.
// ---------------------------------------------------------------------------
let app: Awaited<ReturnType<typeof buildApp>>;
let adminUserId: number;
let adminToken: string;
const createdIds: number[] = [];
async function buildApp() {
const a = Fastify({ logger: false });
await a.register(dashboardRoutes, { prefix: "/api/admin/dashboard" });
return a;
}
beforeAll(async () => {
app = await buildApp();
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminUserId = admin.id;
adminToken = jwt.sign(
{ sub: admin.id, username: admin.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
if (createdIds.length) {
await prisma.attendance.deleteMany({ where: { id: { in: createdIds } } });
}
if (app) await app.close();
});
describe("GET /api/admin/dashboard — today's attendance window", () => {
it("counts a punched-in user as present (local-noon @db.Date row)", async () => {
const now = new Date();
// The attendance regime stores shift_date at LOCAL NOON (so the UTC date
// part equals the Prague calendar date) — same as punchAction does.
const row = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
12,
0,
0,
),
leave_type: "work",
arrival_time: now,
departure_time: null,
notes: "dashboard_window_test",
},
});
createdIds.push(row.id);
const res = await app.inject({
method: "GET",
url: "/api/admin/dashboard",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
const attendance = body.data.attendance;
expect(attendance).toBeTruthy();
const me = attendance.users.find(
(u: { user_id: number }) => u.user_id === adminUserId,
);
expect(me).toBeTruthy();
expect(me.status).toBe("in");
expect(attendance.present_today).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -1,5 +1,8 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import {
previewIssuedOrderNumber,
previewInvoiceNumber,
@@ -19,23 +22,36 @@ import {
createOffer,
updateOffer,
deleteOffer,
invalidateOffer,
getOffer,
listOffers,
} from "../services/offers.service";
import quotationsRoutes from "../routes/admin/quotations";
// Track every row we create so the suite leaves the (real) app_test DB clean.
const issuedOrderIds: number[] = [];
const invoiceIds: number[] = [];
const offerIds: number[] = [];
const customerIds: number[] = [];
const linkedOrderIds: number[] = [];
afterEach(async () => {
for (const id of issuedOrderIds)
await prisma.issued_orders.deleteMany({ where: { id } });
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
// Linked orders FIRST — orders.quotation_id is a Restrict FK on quotations.
for (const id of linkedOrderIds)
await prisma.orders.deleteMany({ where: { id } });
for (const id of offerIds)
await prisma.quotations.deleteMany({ where: { id } });
for (const id of customerIds)
await prisma.customers.deleteMany({ where: { id } });
issuedOrderIds.length = 0;
invoiceIds.length = 0;
linkedOrderIds.length = 0;
offerIds.length = 0;
customerIds.length = 0;
// Reset every sequence this suite may have touched so preview values are
// stable across tests and we don't leak consumed numbers into other files.
await prisma.number_sequences.deleteMany({
@@ -43,6 +59,14 @@ afterEach(async () => {
});
});
/** createOffer + narrow the token union + track cleanup. */
async function mkOffer(body: Record<string, unknown> = {}) {
const res = await createOffer(body);
if ("error" in res) throw new Error(`createOffer failed: ${res.error}`);
offerIds.push(res.id);
return res;
}
/* -------------------------------------------------------------------------- */
/* Issued orders */
/* -------------------------------------------------------------------------- */
@@ -51,6 +75,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("a draft has no number and does NOT consume the sequence", async () => {
const before = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({}); // defaults to draft
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
expect(draft.status).toBe("draft");
expect(draft.po_number).toBeNull();
@@ -62,6 +88,8 @@ 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({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
const res = await updateIssuedOrder(draft.id, { status: "sent" });
@@ -77,6 +105,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
await updateIssuedOrder(draft.id, { status: "sent" });
@@ -104,6 +134,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("two drafts coexist with null numbers (no unique violation)", async () => {
const a = await createIssuedOrder({});
const b = await createIssuedOrder({});
if ("error" in a) throw new Error(`createIssuedOrder failed: ${a.error}`);
if ("error" in b) throw new Error(`createIssuedOrder failed: ${b.error}`);
issuedOrderIds.push(a.id, b.id);
expect(a.po_number).toBeNull();
expect(b.po_number).toBeNull();
@@ -112,6 +144,8 @@ describe("issued-order drafts — deferred numbering", () => {
it("deleting a draft releases nothing (no number was consumed)", async () => {
const before = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
await deleteIssuedOrder(draft.id);
const after = (await previewIssuedOrderNumber()).number;
expect(after).toBe(before);
@@ -273,3 +307,282 @@ describe("offer drafts — deferred numbering", () => {
expect(after).toBe(before);
});
});
/* -------------------------------------------------------------------------- */
/* Offers — status transition table (service) */
/* -------------------------------------------------------------------------- */
describe("offer status transitions (service)", () => {
it("rejects draft -> ordered so a numberless 'ordered' offer can never exist", async () => {
const draft = await mkOffer({});
const res = await updateOffer(draft.id, { status: "ordered" });
expect("error" in res && res.error).toBe("invalid_transition");
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
expect(row!.status).toBe("draft");
expect(row!.quotation_number).toBeNull();
});
it("allows active -> invalidated via update", async () => {
const offer = await mkOffer({ status: "active" });
const res = await updateOffer(offer.id, { status: "invalidated" });
expect("error" in res).toBe(false);
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
expect(row!.status).toBe("invalidated");
});
it("invalidateOffer respects the table: draft rejected, ordered ok, terminal stays terminal", async () => {
const draft = await mkOffer({});
const rejected = await invalidateOffer(draft.id);
expect("error" in rejected && rejected.error).toBe("invalid_transition");
const ordered = await mkOffer({ status: "ordered" });
const ok = await invalidateOffer(ordered.id);
expect("error" in ok).toBe(false);
// invalidated is terminal — a second invalidate is rejected too.
const again = await invalidateOffer(ordered.id);
expect("error" in again && again.error).toBe("invalid_transition");
});
it("getOffer exposes valid_transitions computed from the current status", async () => {
const draft = await mkOffer({});
expect((await getOffer(draft.id))?.valid_transitions).toEqual(["active"]);
const active = await mkOffer({ status: "active" });
expect((await getOffer(active.id))?.valid_transitions).toEqual([
"ordered",
"invalidated",
]);
});
});
/* -------------------------------------------------------------------------- */
/* Offers — editable-state guard (service) */
/* -------------------------------------------------------------------------- */
describe("offer editable-state guard (service)", () => {
it("explicitly rejects a field edit on an ordered offer (not silently dropped)", async () => {
const offer = await mkOffer({ status: "ordered", project_code: "PŮVODNÍ" });
const res = await updateOffer(offer.id, { project_code: "ZMĚNA" });
expect("error" in res && res.error).toBe("not_editable");
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
expect(row!.project_code).toBe("PŮVODNÍ");
});
it("rejects a field edit on an invalidated offer", async () => {
const offer = await mkOffer({ status: "invalidated" });
const res = await updateOffer(offer.id, { scope_title: "X" });
expect("error" in res && res.error).toBe("not_editable");
});
it("still allows the status-only ordered -> invalidated payload", async () => {
const offer = await mkOffer({ status: "ordered" });
const res = await updateOffer(offer.id, { status: "invalidated" });
expect("error" in res).toBe(false);
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
expect(row!.status).toBe("invalidated");
});
});
/* -------------------------------------------------------------------------- */
/* Offers — customer FK handling (service) */
/* -------------------------------------------------------------------------- */
describe("offer customer FK handling (service)", () => {
async function mkCustomer() {
const c = await prisma.customers.create({
data: { name: "drafts_test_zákazník" },
});
customerIds.push(c.id);
return c;
}
it("create rejects a nonexistent customer with a clean token (no P2003 500)", async () => {
const res = await createOffer({ customer_id: 99999999 });
expect("error" in res && res.error).toBe("customer_not_found");
});
it("update rejects a nonexistent customer with a clean token", async () => {
const offer = await mkOffer({});
const res = await updateOffer(offer.id, { customer_id: 99999999 });
expect("error" in res && res.error).toBe("customer_not_found");
});
it("explicit null CLEARS the customer (the old Number(null) -> 0 bug)", async () => {
const customer = await mkCustomer();
const offer = await mkOffer({ customer_id: customer.id });
expect(offer.customer_id).toBe(customer.id);
const res = await updateOffer(offer.id, { customer_id: null });
expect("error" in res).toBe(false);
const row = await prisma.quotations.findUnique({ where: { id: offer.id } });
expect(row!.customer_id).toBeNull();
});
});
/* -------------------------------------------------------------------------- */
/* Offers — deterministic list ordering */
/* -------------------------------------------------------------------------- */
describe("listOffers deterministic ordering", () => {
it("tiebreaks same-created_at rows by id (stable in both directions)", async () => {
const MARK = `TIEBREAK-${Date.now()}`;
const ids: number[] = [];
for (let i = 0; i < 3; i++) {
const o = await mkOffer({ project_code: MARK });
ids.push(o.id);
}
// created_at is second-precision; pin all three to the SAME timestamp so
// only the id tiebreak can order them.
await prisma.quotations.updateMany({
where: { id: { in: ids } },
data: { created_at: new Date("2026-01-15T10:00:00") },
});
const base = { page: 1, limit: 10, skip: 0, search: MARK };
const desc = await listOffers({
...base,
sort: "created_at",
order: "desc",
});
expect(desc.data.map((o: { id: number }) => o.id)).toEqual(
[...ids].sort((a, b) => b - a),
);
const asc = await listOffers({ ...base, sort: "created_at", order: "asc" });
expect(asc.data.map((o: { id: number }) => o.id)).toEqual(
[...ids].sort((a, b) => a - b),
);
});
});
/* -------------------------------------------------------------------------- */
/* Offers — route-level hardening (Czech messages + status codes) */
/* -------------------------------------------------------------------------- */
let app: ReturnType<typeof Fastify> | null = null;
let adminToken = "";
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(quotationsRoutes, { prefix: "/api/admin/offers" });
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 () => {
if (app) await app.close();
});
describe("offers routes — hardening (HTTP)", () => {
const auth = () => ({ Authorization: `Bearer ${adminToken}` });
it("PUT rejects a field edit on an ordered offer with the explicit Czech 400", async () => {
const offer = await mkOffer({ status: "ordered" });
const res = await app!.inject({
method: "PUT",
url: `/api/admin/offers/${offer.id}`,
headers: auth(),
payload: { project_code: "ZMĚNA" },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe("Nabídku v tomto stavu nelze upravovat");
});
it("PUT rejects draft -> ordered with the transition message", async () => {
const draft = await mkOffer({});
const res = await app!.inject({
method: "PUT",
url: `/api/admin/offers/${draft.id}`,
headers: auth(),
payload: { status: "ordered" },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe(
'Neplatný přechod stavu z "draft" na "ordered"',
);
});
it("POST 400s a 501-char item description (schema cap, not a DB 500)", async () => {
const res = await app!.inject({
method: "POST",
url: "/api/admin/offers",
headers: auth(),
payload: { items: [{ description: "x".repeat(501) }] },
});
expect(res.statusCode).toBe(400);
expect(res.json().success).toBe(false);
});
it("POST 400s a nonexistent customer with 'Zákazník nenalezen'", async () => {
const res = await app!.inject({
method: "POST",
url: "/api/admin/offers",
headers: auth(),
payload: { customer_id: 99999999 },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe("Zákazník nenalezen");
});
it("DELETE returns a Czech 409 when the offer is linked to an order (real Restrict FK)", async () => {
const offer = await mkOffer({ status: "active" });
const order = await prisma.orders.create({
data: {
order_number: `DRAFTSLINK-${Date.now()}`,
quotation_id: offer.id,
},
});
linkedOrderIds.push(order.id);
const res = await app!.inject({
method: "DELETE",
url: `/api/admin/offers/${offer.id}`,
headers: auth(),
});
expect(res.statusCode).toBe(409);
expect(res.json().error).toBe(
"Nabídku nelze smazat, je navázána na objednávku nebo projekt",
);
// The offer survived the rejected delete.
expect(
await prisma.quotations.findUnique({ where: { id: offer.id } }),
).not.toBeNull();
});
it("PUT writes an audit row carrying oldValues and newValues ('koncept' fallback)", async () => {
const draft = await mkOffer({ project_code: "PŘED" });
const res = await app!.inject({
method: "PUT",
url: `/api/admin/offers/${draft.id}`,
headers: auth(),
payload: { project_code: "PO" },
});
expect(res.statusCode).toBe(200);
const audit = await prisma.audit_logs.findFirst({
where: {
entity_type: "quotation",
entity_id: draft.id,
action: "update",
},
orderBy: { id: "desc" },
});
expect(audit).not.toBeNull();
expect(audit!.old_values).toBeTruthy();
expect(audit!.new_values).toBeTruthy();
expect(JSON.parse(audit!.old_values!).project_code).toBe("PŘED");
expect(JSON.parse(audit!.new_values!).project_code).toBe("PO");
// Unnumbered drafts read "koncept", never "null".
expect(audit!.description).toContain("koncept");
expect(audit!.description).not.toContain("null");
});
});

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { computeAlertWindow } from "../services/invoice-alerts";
import { utcMidnightOfLocalDay } from "../utils/date";
// Pure tests — no DB rows are touched. due_date is @db.Date: Prisma truncates
// a Date used in a WHERE filter to its UTC date part, so the alert window
// boundaries must be UTC midnights of the LOCAL calendar day. The old code
// used local midnights (= 22:00/23:00 UTC of the PREVIOUS day), which shifted
// the [today, today+3] due_date window a day early — the "splatnost za 3 dny"
// advance alert (due == today+3) could then never fire. All assertions below
// are timezone-independent (inputs are built from local components).
describe("utcMidnightOfLocalDay", () => {
it("preserves the LOCAL calendar day shortly after local midnight (the night window)", () => {
// 00:30 local on 2098-07-01 — in Prague (UTC+2) this instant is
// 2098-06-30T22:30Z, i.e. its UTC date part is the PREVIOUS day, which is
// exactly what Prisma would truncate a bare Date to.
const justAfterMidnight = new Date(2098, 6, 1, 0, 30, 0);
expect(utcMidnightOfLocalDay(justAfterMidnight).getTime()).toBe(
Date.UTC(2098, 6, 1),
);
});
it("returns UTC midnight of the local day for a mid-day instant", () => {
const noon = new Date(2098, 0, 15, 12, 0, 0);
expect(utcMidnightOfLocalDay(noon).getTime()).toBe(Date.UTC(2098, 0, 15));
});
});
describe("computeAlertWindow", () => {
it("builds [today, today+3] as UTC midnights of the LOCAL day, with matching strings", () => {
// 00:30 local — the window where the old local-midnight computation
// produced yesterday's UTC date and the whole window slid a day early.
const now = new Date(2098, 6, 1, 0, 30, 0);
const w = computeAlertWindow(now);
expect(w.today.getTime()).toBe(Date.UTC(2098, 6, 1));
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 6, 4));
expect(w.todayStr).toBe("2098-07-01");
expect(w.in3daysStr).toBe("2098-07-04");
});
it("rolls the +3-day bound over a month boundary", () => {
const now = new Date(2098, 0, 30, 8, 0, 0); // 2098-01-30
const w = computeAlertWindow(now);
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 1, 2)); // 2098-02-02
expect(w.in3daysStr).toBe("2098-02-02");
});
});

View File

@@ -1,6 +1,14 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { Prisma } from "@prisma/client";
import { invoiceTotalWithVat } from "../services/invoices.service";
import prisma from "../config/database";
import {
invoiceTotalWithVat,
createInvoice,
updateInvoice,
listInvoices,
getInvoiceListTotals,
} from "../services/invoices.service";
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
@@ -118,3 +126,138 @@ describe("invoiceTotalWithVat", () => {
expect(invoiceTotalWithVat(inv)).toBe(968);
});
});
/* -------------------------------------------------------------------------- */
/* PUT regression — billing_text must survive UpdateInvoiceSchema */
/* -------------------------------------------------------------------------- */
// The PUT route does `parseBody(UpdateInvoiceSchema, body)` and hands the
// PARSED data to `updateInvoice`. UpdateInvoiceSchema is a plain z.object,
// which STRIPS unknown keys — so a field missing from the schema is silently
// dropped before the service ever sees it, while the client still gets a
// success response. This block mirrors that exact flow against the real DB.
describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema", () => {
const invoiceIds: number[] = [];
afterEach(async () => {
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
invoiceIds.length = 0;
});
it("persists an edited billing_text (the schema must not strip it)", async () => {
const draft = await createInvoice({ billing_text: "Původní text" });
invoiceIds.push(draft.id);
// Mirror the route: raw body -> UpdateInvoiceSchema -> updateInvoice.
const parsed = UpdateInvoiceSchema.parse({
billing_text: "Fakturujeme Vám dle objednávky č. 123",
});
expect(parsed.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
const res = await updateInvoice(draft.id, parsed);
expect("error" in res).toBe(false);
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
});
it("clears billing_text when null is sent, and leaves it untouched when absent", async () => {
const draft = await createInvoice({ billing_text: "Text na PDF" });
invoiceIds.push(draft.id);
// Absent from the body -> updateInvoice's `!== undefined` guard skips it.
await updateInvoice(draft.id, UpdateInvoiceSchema.parse({ notes: "x" }));
let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBe("Text na PDF");
// Explicit null -> cleared (the strFields path maps falsy -> null).
await updateInvoice(
draft.id,
UpdateInvoiceSchema.parse({ billing_text: null }),
);
row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBeNull();
});
});
/* -------------------------------------------------------------------------- */
/* Month filter boundaries — issue_date is @db.Date */
/* -------------------------------------------------------------------------- */
// issue_date is @db.Date: Prisma compares a Date filter by its UTC date part.
// The old LOCAL-midnight month bounds (= 22:00/23:00 UTC of the previous day)
// shifted the window a day back — the list/totals included the previous
// month's last day and DROPPED invoices issued on the selected month's LAST
// day. buildInvoiceWhere is shared by listInvoices AND getInvoiceListTotals,
// so both are exercised. Fixtures use the test-only "TST" currency + far-future
// 2098 dates so totals can be asserted exactly without colliding with other
// suites' rows.
describe("listInvoices / getInvoiceListTotals — month filter (@db.Date boundaries)", () => {
const cleanup = async () => {
// invoice_items cascade on invoice delete.
await prisma.invoices.deleteMany({ where: { currency: "TST" } });
};
beforeEach(cleanup);
afterEach(cleanup);
const listParams = {
page: 1,
limit: 100,
skip: 0,
sort: "id",
order: "asc" as const,
search: "",
};
async function createBoundaryFixtures() {
// Drafts: no invoice number is consumed, but buildInvoiceWhere has no
// status filter so they appear in the list/totals like any invoice.
const lastDayJan = await createInvoice({
status: "draft",
issue_date: "2098-01-31",
currency: "TST",
vat_rate: 21,
items: [{ quantity: 1, unit_price: 100, vat_rate: 21 }], // total 121
});
const firstDayFeb = await createInvoice({
status: "draft",
issue_date: "2098-02-01",
currency: "TST",
vat_rate: 21,
items: [{ quantity: 1, unit_price: 200, vat_rate: 21 }], // total 242
});
return { lastDayJan, firstDayFeb };
}
it("a month's list contains its own LAST day and not the neighbor month's first day", async () => {
const { lastDayJan, firstDayFeb } = await createBoundaryFixtures();
const jan = await listInvoices({ ...listParams, month: 1, year: 2098 });
const janIds = jan.data.map((i) => i.id);
expect(janIds).toContain(lastDayJan.id);
expect(janIds).not.toContain(firstDayFeb.id);
const feb = await listInvoices({ ...listParams, month: 2, year: 2098 });
const febIds = feb.data.map((i) => i.id);
expect(febIds).toContain(firstDayFeb.id);
expect(febIds).not.toContain(lastDayJan.id);
});
it("per-currency totals include the month's last day exactly once", async () => {
await createBoundaryFixtures();
const jan = await getInvoiceListTotals({ month: 1, year: 2098 });
const janTst = jan.totals.find((t) => t.currency === "TST");
// 100 + 21 % VAT — proves the Jan 31 invoice is counted and the
// Feb 1 invoice (242) is NOT pulled into January.
expect(janTst?.amount).toBe(121);
const feb = await getInvoiceListTotals({ month: 2, year: 2098 });
const febTst = feb.totals.find((t) => t.currency === "TST");
expect(febTst?.amount).toBe(242);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -47,8 +47,6 @@ const baseOrder = {
status: "prijata" as const,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1,
};
@@ -117,8 +115,6 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
status: "active",
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
},
});
createdQuotationIds.push(quotation.id);
@@ -146,6 +142,46 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
expect(project?.project_number).not.toBe(order?.order_number);
expect(project?.order_id).toBe(orderId);
});
it("rejects a DRAFT offer (a numberless 'ordered' offer must never exist)", async () => {
const draft = await prisma.quotations.create({
data: { status: "draft", currency: "CZK", language: "cs" },
});
createdQuotationIds.push(draft.id);
const res = await createOrderFromQuotation({ quotationId: draft.id });
expect("error" in res).toBe(true);
if ("error" in res) {
expect(res.status).toBe(400);
expect(res.error).toContain('ve stavu "draft"');
}
// The offer is untouched — still a numberless draft.
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
expect(row!.status).toBe("draft");
expect(row!.quotation_number).toBeNull();
expect(row!.order_id).toBeNull();
});
it("rejects an INVALIDATED offer (terminal status — no ordered transition)", async () => {
const invalidated = await prisma.quotations.create({
data: {
quotation_number: `Q-INVAL-${Date.now()}`,
status: "invalidated",
currency: "CZK",
language: "cs",
},
});
createdQuotationIds.push(invalidated.id);
const res = await createOrderFromQuotation({
quotationId: invalidated.id,
});
expect("error" in res).toBe(true);
if ("error" in res) {
expect(res.status).toBe(400);
expect(res.error).toContain('ve stavu "invalidated"');
}
});
});
describe("deleteOrder releases the project number (release regression)", () => {

View File

@@ -53,4 +53,29 @@ describe("NasDocumentManager issued-PDF round-trip", () => {
blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")),
).toBeNull();
});
it("re-saving the same number OVERWRITES in place — never a _1 duplicate", () => {
const first = nas.saveIssuedPdf("25P0004", 2026, 6, Buffer.from("v1"));
const second = nas.saveIssuedPdf("25P0004", 2026, 6, Buffer.from("v2"));
// Same canonical path both times, newest content wins.
expect(second).toBe(first);
const dir = path.join(tmpBase, "Vydané", "2026", "06");
expect(fs.readdirSync(dir)).toEqual(["25P0004.pdf"]);
expect(nas.readIssued(second!)!.data.toString()).toBe("v2");
});
it("cleanIssued also sweeps stale _N duplicates left by the old racy save", () => {
nas.saveIssuedPdf("25P0005", 2026, 6, Buffer.from("canonical"));
// Simulate duplicates the pre-fix uniquePath save created during races.
const dir = path.join(tmpBase, "Vydané", "2026", "06");
fs.writeFileSync(path.join(dir, "25P0005_1.pdf"), "stale");
fs.writeFileSync(path.join(dir, "25P0005_2.pdf"), "stale");
// A DIFFERENT number must not be touched by the suffix match.
nas.saveIssuedPdf("25P0006", 2026, 6, Buffer.from("other"));
nas.cleanIssued("25P0005");
expect(fs.readdirSync(dir).sort()).toEqual(["25P0006.pdf"]);
});
});

View File

@@ -54,18 +54,16 @@ function amountFor(
}
describe("getOfferTotals per-currency aggregation", () => {
it("sums offer TOTAL incl. VAT per currency over the full filtered set", async () => {
// Two CZK offers + one EUR. 21% VAT applied on the whole subtotal
// (enrichQuotation math).
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
it("sums offer NET total per currency over the full filtered set", async () => {
// Two CZK offers + one EUR. NET only (enrichQuotation math — offers are
// not tax documents, no VAT anywhere).
// CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 => EUR total 300
const mk = async (currency: string, qty: number, price: number) => {
const res = await createOffer({
status: "draft", // draft so no offer number is consumed
currency,
vat_rate: 21,
apply_vat: true,
project_code: OFFER_MARKER,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
@@ -79,8 +77,8 @@ describe("getOfferTotals per-currency aggregation", () => {
await mk("EUR", 3, 100);
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(300);
});
it("respects the where: a status filter narrows the result", async () => {
@@ -88,8 +86,6 @@ describe("getOfferTotals per-currency aggregation", () => {
const res = await createOffer({
status,
currency: "CZK",
vat_rate: 21,
apply_vat: true,
project_code: OFFER_MARKER,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
@@ -103,23 +99,23 @@ describe("getOfferTotals per-currency aggregation", () => {
await mk("draft");
await mk("invalidated");
// Marker only: all three count -> 3 x 1210 = 3630.
// Marker only: all three count -> 3 x 1000 = 3000 (net).
const all = await getOfferTotals({ search: OFFER_MARKER });
expect(amountFor(all.totals, "CZK")).toBe(3630);
expect(amountFor(all.totals, "CZK")).toBe(3000);
// Status filter narrows to the two drafts -> 2 x 1210 = 2420.
// Status filter narrows to the two drafts -> 2 x 1000 = 2000.
const onlyDraft = await getOfferTotals({
search: OFFER_MARKER,
status: "draft",
});
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2420);
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2000);
// Invalidated alone -> 1210.
// Invalidated alone -> 1000.
const onlyInvalid = await getOfferTotals({
search: OFFER_MARKER,
status: "invalidated",
});
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1210);
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1000);
});
});

View File

@@ -8,8 +8,9 @@ import {
// Per-currency total aggregation for both order lists. These hit the real
// `app_test` DB via the service layer (suite convention) and prove the /stats
// endpoints sum order TOTAL (incl. VAT) per currency across the WHOLE filtered
// set, and that the where (month/year + status) is respected.
// endpoints sum order NET total per currency across the WHOLE filtered set
// (orders are not tax documents — no VAT anywhere), and that the where
// (month/year + status) is respected.
//
// A far-future month/year is used so seeded/other-test rows can't fall in the
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
@@ -46,19 +47,17 @@ function amountFor(
}
describe("getOrderTotals (received orders) per-currency aggregation", () => {
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
// Two CZK orders + one EUR, all in the same far-future month. 21% VAT,
// applied on the whole subtotal (enrichOrder math).
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
it("sums NET total per currency over the full filtered set", async () => {
// Two CZK orders + one EUR, all in the same far-future month. NET only
// (enrichOrder math — orders carry no VAT).
// CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 => EUR total 300
const mk = async (currency: string, qty: number, price: number) => {
const res = await createOrder({
status: "prijata",
currency,
language: "cs",
vat_rate: 21,
apply_vat: true,
create_project: false,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
@@ -83,8 +82,8 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(300);
});
it("respects the where: a different month and a status filter change the result", async () => {
@@ -93,8 +92,6 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
status,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
create_project: false,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
@@ -122,49 +119,45 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
await mk("stornovana", 0);
await mk("prijata", 1);
// Whole month: both same-month orders count -> 2 x 1210 = 2420.
// Whole month: both same-month orders count -> 2 x 1000 = 2000 (net).
const whole = await getOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(whole.totals, "CZK")).toBe(2420);
expect(amountFor(whole.totals, "CZK")).toBe(2000);
// Status filter narrows to the single 'prijata' in the target month -> 1210.
// Status filter narrows to the single 'prijata' in the target month -> 1000.
const onlyPrijata = await getOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
status: "prijata",
});
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1210);
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1000);
// Next month sees only the one 'prijata' there -> 1210.
// Next month sees only the one 'prijata' there -> 1000.
const nextMonth = await getOrderTotals({
month: STATS_MONTH + 1,
year: STATS_YEAR,
});
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
expect(amountFor(nextMonth.totals, "CZK")).toBe(1000);
});
});
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => {
// order_date is settable at create, so no post-create pin needed. VAT is
// applied per-line (computeIssuedOrderTotals) but with a single line per
// order here the result matches the on-the-whole subtotal math.
// CZK #1: 2 x 1000 @21% = 2420
// CZK #2: 1 x 500 @21% = 605 => CZK total 3025
// EUR : 3 x 100 @21% = 363 => EUR total 363
it("sums NET total per currency over the full filtered set", async () => {
// order_date is settable at create, so no post-create pin needed. NET only
// (computeIssuedOrderTotals — issued orders carry no VAT).
// CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 => EUR total 300
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const mk = async (currency: string, qty: number, price: number) => {
const o = await createIssuedOrder({
currency,
vat_rate: 21,
apply_vat: true,
order_date: dateStr,
items: [
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
],
items: [{ description: "X", quantity: qty, unit_price: price }],
});
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
createdIssuedIds.push(o.id);
return o.id;
};
@@ -177,69 +170,63 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(300);
});
it("respects the where: a different month and a status filter change the result", async () => {
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
// Target month: one draft, one already-sent (both 1210). Next month: one.
// Target month: one draft, one already-sent (both 1000 net). Next month: one.
const draft = await createIssuedOrder({
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: inMonth,
items: [
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
createdIssuedIds.push(draft.id);
const sent = await createIssuedOrder({
status: "sent",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: inMonth,
items: [
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in sent)
throw new Error(`createIssuedOrder failed: ${sent.error}`);
createdIssuedIds.push(sent.id);
const next = await createIssuedOrder({
currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: nextMonth,
items: [
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in next)
throw new Error(`createIssuedOrder failed: ${next.error}`);
createdIssuedIds.push(next.id);
// Whole target month: both count -> 2 x 1210 = 2420.
// Whole target month: both count -> 2 x 1000 = 2000 (net).
const whole = await getIssuedOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(whole.totals, "CZK")).toBe(2420);
expect(amountFor(whole.totals, "CZK")).toBe(2000);
// Status filter narrows to the single 'sent' in the target month -> 1210.
// Status filter narrows to the single 'sent' in the target month -> 1000.
const onlySent = await getIssuedOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
status: "sent",
});
expect(amountFor(onlySent.totals, "CZK")).toBe(1210);
expect(amountFor(onlySent.totals, "CZK")).toBe(1000);
// Next month sees only the one order there -> 1210.
// Next month sees only the one order there -> 1000.
const nextStats = await getIssuedOrderTotals({
month: STATS_MONTH + 1,
year: STATS_YEAR,
});
expect(amountFor(nextStats.totals, "CZK")).toBe(1210);
expect(amountFor(nextStats.totals, "CZK")).toBe(1000);
});
});

View File

@@ -1,6 +1,17 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { createOrder, listOrders } from "../services/orders.service";
import { config } from "../config/env";
import ordersRoutes from "../routes/admin/orders";
import {
createOrder,
listOrders,
getOrder,
getOrderAttachment,
} from "../services/orders.service";
const createdOrderIds: number[] = [];
const createdCustomerIds: number[] = [];
@@ -19,8 +30,6 @@ const baseOrder = {
status: "prijata" as const,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1,
};
@@ -80,6 +89,166 @@ describe("listOrders search filter", () => {
});
});
describe("order attachment payload hygiene", () => {
// The PO attachment blob must be served ONLY by GET /:id/attachment —
// list/detail payloads carry attachment_name as the existence signal.
const PDF_BYTES = Buffer.from("%PDF-1.4 orders-list-test-attachment-bytes");
const TEST_ADMIN = "orders_list_test_admin";
let app: Awaited<ReturnType<typeof buildOrdersApp>>;
let adminToken: string;
let adminUserId: number;
async function buildOrdersApp() {
const fastify = Fastify({ logger: false });
await fastify.register(cookie);
await fastify.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
// multipart is registered inside ordersRoutes itself
await fastify.register(ordersRoutes, { prefix: "/api/admin/orders" });
return fastify;
}
beforeAll(async () => {
app = await buildOrdersApp();
// Clean up any leftover test user from a previous failed run
await prisma.users.deleteMany({ where: { username: TEST_ADMIN } });
const adminRole = await prisma.roles.findFirst({
where: { name: "admin" },
});
if (!adminRole)
throw new Error("Admin role not found — seed the database first");
const adminUser = await prisma.users.create({
data: {
username: TEST_ADMIN,
email: `${TEST_ADMIN}@test.local`,
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "Orders",
last_name: "ListTest",
is_active: true,
role_id: adminRole.id,
},
});
adminUserId = adminUser.id;
adminToken = jwt.sign(
{ sub: adminUser.id, username: adminUser.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
await prisma.users.deleteMany({ where: { id: adminUserId } });
await app.close();
});
/** One order WITH a stored PO attachment, one WITHOUT. */
async function makeOrderPair() {
const customer = await makeCustomer();
const withRes = await createOrder(
{ ...baseOrder, customer_id: customer.id, create_project: false },
PDF_BYTES,
"po-test.pdf",
);
if (!("id" in withRes)) failResult(withRes);
createdOrderIds.push(withRes.id!);
const withoutRes = await createOrder({
...baseOrder,
customer_id: customer.id,
create_project: false,
});
if (!("id" in withoutRes)) failResult(withoutRes);
createdOrderIds.push(withoutRes.id!);
return { withId: withRes.id!, withoutId: withoutRes.id! };
}
it("listOrders rows never contain attachment_data; attachment_name signals existence", async () => {
const { withId, withoutId } = await makeOrderPair();
const list = await listOrders(baseListParams);
const withRow = list.data.find((o) => o.id === withId);
const withoutRow = list.data.find((o) => o.id === withoutId);
expect(withRow).toBeTruthy();
expect(withoutRow).toBeTruthy();
expect("attachment_data" in withRow!).toBe(false);
expect("attachment_data" in withoutRow!).toBe(false);
expect(withRow!.attachment_name).toBe("po-test.pdf");
expect(withoutRow!.attachment_name).toBeNull();
});
it("getOrder detail never contains attachment_data but keeps attachment_name", async () => {
const { withId, withoutId } = await makeOrderPair();
const withDetail = await getOrder(withId);
const withoutDetail = await getOrder(withoutId);
expect(withDetail).toBeTruthy();
expect(withoutDetail).toBeTruthy();
expect("attachment_data" in withDetail!).toBe(false);
expect("attachment_data" in withoutDetail!).toBe(false);
expect(withDetail!.attachment_name).toBe("po-test.pdf");
expect(withoutDetail!.attachment_name).toBeNull();
});
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
const { withId, withoutId } = await makeOrderPair();
const headers = { authorization: `Bearer ${adminToken}` };
const listRes = await app.inject({
method: "GET",
url: "/api/admin/orders?sort=id&order=desc&per_page=100",
headers,
});
expect(listRes.statusCode).toBe(200);
const listJson = JSON.parse(listRes.body);
expect(listJson.data.map((o: { id: number }) => o.id)).toContain(withId);
expect(listRes.body).not.toContain("attachment_data");
const detailRes = await app.inject({
method: "GET",
url: `/api/admin/orders/${withId}`,
headers,
});
expect(detailRes.statusCode).toBe(200);
expect(detailRes.body).not.toContain("attachment_data");
expect(JSON.parse(detailRes.body).data.attachment_name).toBe("po-test.pdf");
// The dedicated endpoint is the ONE place the blob is served — byte-exact.
const attRes = await app.inject({
method: "GET",
url: `/api/admin/orders/${withId}/attachment`,
headers,
});
expect(attRes.statusCode).toBe(200);
expect(attRes.headers["content-type"]).toContain("application/pdf");
expect(Buffer.from(attRes.rawPayload).equals(PDF_BYTES)).toBe(true);
const noAttRes = await app.inject({
method: "GET",
url: `/api/admin/orders/${withoutId}/attachment`,
headers,
});
expect(noAttRes.statusCode).toBe(404);
});
it("getOrderAttachment round-trips the stored bytes (and null without one)", async () => {
const { withId, withoutId } = await makeOrderPair();
const att = await getOrderAttachment(withId);
expect(att).toBeTruthy();
expect(att!.filename).toBe("po-test.pdf");
expect(att!.data.equals(PDF_BYTES)).toBe(true);
expect(await getOrderAttachment(withoutId)).toBeNull();
});
});
describe("listOrders month filter", () => {
it("returns only orders whose created_at is in the given month", async () => {
const customer = await makeCustomer();

View File

@@ -0,0 +1,247 @@
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 { renderOrderConfirmationHtml } from "../routes/admin/orders-pdf";
import offersPdfRoutes from "../routes/admin/offers-pdf";
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
import { createInvoice } from "../services/invoices.service";
// Offers, received orders (their confirmation PDF) and issued orders are NOT
// tax documents — VAT must never appear on them. These tests pin that contract
// for the confirmation and offer PDFs: no VAT columns/rows/notices, and the
// grand total is "Celkem bez DPH" / "Total excl. VAT". (The issued-order PDF
// is covered in issued-orders.test.ts.)
// The explanatory prices-excl.-VAT notice was removed at user request — the
// total label alone carries the information. Pin its absence too.
const CS_NOTE = "Ceny jsou uvedeny bez DPH";
const EN_NOTE = "Prices are exclusive of VAT";
describe("renderOrderConfirmationHtml (order confirmation PDF)", () => {
const order = {
order_number: "26730042",
customer_order_number: "PO-XYZ",
created_at: new Date("2026-06-09T12:00:00"),
currency: "CZK",
notes: null,
customers: null,
};
const items = [
{
description: "Materiál",
quantity: 2,
unit: "ks",
unit_price: 100,
is_included_in_total: true,
},
];
it("cs: NET total labeled 'Celkem bez DPH', no VAT columns or notice", () => {
const html = renderOrderConfirmationHtml(order, items, null, "cs", "Jan");
expect(html).not.toContain("%DPH");
expect(html).not.toContain(">DPH<");
expect(html).not.toContain("Mezisoučet");
// Line total = netto (2 × 100), no VAT-on-top anywhere.
expect(html).toContain('<td class="right total-cell">200,00</td>');
expect(html).toContain("Celkem bez DPH");
expect(html).not.toContain(CS_NOTE);
});
it("en: 'Total excl. VAT', no VAT columns or notice", () => {
const html = renderOrderConfirmationHtml(order, items, null, "en", "Jan");
expect(html).not.toContain("VAT%");
expect(html).toContain("Total excl. VAT");
expect(html).not.toContain(EN_NOTE);
});
it("excludes not-included lines from the NET total", () => {
const html = renderOrderConfirmationHtml(
order,
[
...items,
{
description: "Mimo cenu",
quantity: 1,
unit: "ks",
unit_price: 999,
is_included_in_total: false,
},
],
null,
"cs",
"Jan",
);
// Grand total stays 200,00 — the excluded line doesn't count.
expect(html).toContain("200,00 CZK");
});
it("uses the shared NBSP-thousands formatter (pdf-shared extraction marker)", () => {
const html = renderOrderConfirmationHtml(
order,
[
{
description: "Velká položka",
quantity: 2,
unit: "ks",
unit_price: 1000,
is_included_in_total: true,
},
],
null,
"cs",
"Jan",
);
// 2 × 1000 = 2 000,00 with the NBSP (U+00A0) thousands separator.
expect(html).toContain("2 000,00 CZK");
expect(html).not.toContain("1 199,00 CZK");
});
});
/* -------------------------------------------------------------------------- */
/* Offer PDF route (returns the HTML for the print view) */
/* -------------------------------------------------------------------------- */
let app: ReturnType<typeof Fastify> | null = null;
let adminToken = "";
const createdQuotationIds: number[] = [];
const createdInvoiceIds: 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 createdQuotationIds)
await prisma.quotations.deleteMany({ where: { id } });
for (const id of createdInvoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
if (app) await app.close();
});
describe("GET /api/admin/offers-pdf/:id (offer PDF html)", () => {
it("renders NET-only totals with 'Celkem bez DPH', no notice", async () => {
// Direct prisma insert (explicit number → no sequence consumed).
const quotation = await prisma.quotations.create({
data: {
quotation_number: `Q-VATNOTE-${Date.now()}`,
status: "active",
currency: "CZK",
language: "cs",
quotation_items: {
create: [
{
description: "Položka",
quantity: 2,
unit: "ks",
unit_price: 1000,
is_included_in_total: true,
position: 0,
},
],
},
},
});
createdQuotationIds.push(quotation.id);
const res = await app!.inject({
method: "GET",
url: `/api/admin/offers-pdf/${quotation.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const html = res.body;
expect(html).toContain("Celkem bez DPH");
expect(html).not.toContain(CS_NOTE);
expect(html).not.toContain("%DPH");
expect(html).not.toContain("Mezisoučet");
expect(html).not.toContain("Celkem k úhradě");
});
it("renders a fractional quantity as '1,50', not rounded to '2'", async () => {
const quotation = await prisma.quotations.create({
data: {
quotation_number: `Q-FRACQTY-${Date.now()}`,
status: "active",
currency: "CZK",
language: "cs",
quotation_items: {
create: [
{
description: "Půldenní sazba",
quantity: 1.5,
unit: "ks",
unit_price: 1000,
is_included_in_total: true,
position: 0,
},
],
},
},
});
createdQuotationIds.push(quotation.id);
const res = await app!.inject({
method: "GET",
url: `/api/admin/offers-pdf/${quotation.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const html = res.body;
// The old toFixed(0) ROUNDED 1.5 → "2"; integers still render without
// decimals elsewhere (covered by the previous test's qty 2).
expect(html).toContain("1,50 / ks");
expect(html).not.toContain(">2 / ks<");
});
});
/* -------------------------------------------------------------------------- */
/* Invoice PDF route — post-pdf-shared-extraction render marker */
/* -------------------------------------------------------------------------- */
describe("GET /api/admin/invoices-pdf/:id (invoice PDF html)", () => {
it("still renders the invoice HTML after the shared-helper extraction", async () => {
// Draft → no number consumed; CZK → no CNB exchange-rate lookup.
const invoice = await createInvoice({
status: "draft",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
items: [
{
description: "PDF-SHARED-MARKER",
quantity: 2,
unit_price: 1000,
vat_rate: 21,
},
],
});
createdInvoiceIds.push(invoice.id);
const res = await app!.inject({
method: "GET",
url: `/api/admin/invoices-pdf/${invoice.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const html = res.body;
expect(html).toContain("FAKTURA - DAŇOVÝ DOKLAD");
expect(html).toContain("PDF-SHARED-MARKER");
// Shared NBSP formatNum: 2 × 1000 = 2 000,00 base.
expect(html).toContain("2 000,00");
expect(html).toContain("Celkem k úhradě");
});
});

View File

@@ -58,34 +58,6 @@ describe("Zod form coercion + NaN rejection", () => {
});
describe("CreateQuotationSchema", () => {
it("rejects NaN string in top-level vat_rate", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: "bad",
});
expect(result.success).toBe(false);
});
it("accepts valid vat_rate", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: 21,
});
expect(result.success).toBe(true);
});
it("coerces a valid numeric STRING vat_rate to a number", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: "21",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.vat_rate).toBe(21);
expect(typeof result.data.vat_rate).toBe("number");
}
});
it("rejects NaN string in item quantity", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
@@ -300,8 +272,8 @@ describe("schema hardening — does not reject previously-valid input", () => {
expect(b.success).toBe(true);
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
// Genuinely malformed dates are still rejected.
expect(CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success).toBe(
false,
);
expect(
CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success,
).toBe(false);
});
});

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import * as OTPAuthLib from "otpauth";
import { buildApp, extractCookie } from "./helpers";
import prisma from "../config/database";
import { config } from "../config/env";
import { encrypt } from "../utils/encryption";
let app: Awaited<ReturnType<typeof buildApp>>;
// Fixture prefix so cleanup never touches real data.
const N = "totp_backup_test_";
const TEST_USERNAME = `${N}user`;
// Plaintext backup codes seeded for the fixture user (the enable route stores
// bcrypt hashes of 8-char uppercase hex codes — mirror that format).
const BACKUP_CODE_1 = "AAAA1111";
const BACKUP_CODE_2 = "BBBB2222";
let testUserId: number;
/** Pull the raw Set-Cookie header line for a given cookie name (with attrs). */
function rawSetCookie(res: { headers: Record<string, unknown> }, name: string) {
const cookies = res.headers["set-cookie"];
if (!cookies) return undefined;
const arr = Array.isArray(cookies) ? cookies : [cookies];
return arr.find((c) => typeof c === "string" && c.startsWith(`${name}=`)) as
| string
| undefined;
}
/** Create a pending TOTP login token the same way /login does (sha256 hash). */
async function issueLoginToken(userId: number): Promise<string> {
const raw = crypto.randomBytes(32).toString("hex");
const hash = crypto.createHash("sha256").update(raw).digest("hex");
await prisma.totp_login_tokens.create({
data: {
user_id: userId,
token_hash: hash,
expires_at: new Date(Date.now() + 5 * 60_000),
},
});
return raw;
}
// /totp/backup-verify carries its own per-IP rate limit
// (config.rateLimit.loginTotp, default 5/min) and this file legitimately makes
// more attempts than that — give each request a distinct source IP.
let ipCounter = 0;
function postBackupVerify(payload: Record<string, unknown>) {
ipCounter += 1;
return app.inject({
method: "POST",
url: "/api/admin/totp/backup-verify",
payload,
remoteAddress: `10.99.0.${ipCounter}`,
});
}
async function fixtureUser() {
const user = await prisma.users.findUniqueOrThrow({
where: { id: testUserId },
});
return user;
}
beforeAll(async () => {
app = await buildApp();
// Clean up any leftover fixture from a previous failed run.
const leftovers = await prisma.users.findMany({
where: { username: { startsWith: N } },
select: { id: true },
});
if (leftovers.length) {
const ids = leftovers.map((u) => u.id);
await prisma.refresh_tokens.deleteMany({ where: { user_id: { in: ids } } });
await prisma.totp_login_tokens.deleteMany({
where: { user_id: { in: ids } },
});
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
}
const role = await prisma.roles.findFirst();
if (!role) throw new Error("Test setup: no roles found — seed the database");
const hashedCodes = [
await bcrypt.hash(BACKUP_CODE_1, config.security.bcryptCost),
await bcrypt.hash(BACKUP_CODE_2, config.security.bcryptCost),
];
const user = await prisma.users.create({
data: {
username: TEST_USERNAME,
email: `${N}user@test.local`,
password_hash: await bcrypt.hash(
"irrelevant",
config.security.bcryptCost,
),
first_name: "TotpBackup",
last_name: "Test",
is_active: true,
totp_enabled: true,
totp_secret: encrypt(new OTPAuthLib.Secret().base32),
totp_backup_codes: JSON.stringify(hashedCodes),
role_id: role.id,
},
});
testUserId = user.id;
});
afterAll(async () => {
await prisma.refresh_tokens
.deleteMany({ where: { user_id: testUserId } })
.catch(() => {});
await prisma.totp_login_tokens
.deleteMany({ where: { user_id: testUserId } })
.catch(() => {});
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
await app.close();
});
describe("POST /api/admin/totp/backup-verify", () => {
it("returns 400 for missing fields", async () => {
const res = await postBackupVerify({});
expect(res.statusCode).toBe(400);
expect(res.json().success).toBe(false);
});
it("returns 401 for an invalid login token", async () => {
const res = await postBackupVerify({
login_token: "not-a-real-token",
backup_code: BACKUP_CODE_1,
});
expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false);
});
it("rejects a wrong backup code without consuming the stored codes", async () => {
const loginToken = await issueLoginToken(testUserId);
const res = await postBackupVerify({
login_token: loginToken,
backup_code: "ZZZZ9999",
});
expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false);
// Both backup codes must still be stored — nothing consumed.
const user = await fixtureUser();
expect(JSON.parse(user.totp_backup_codes as string)).toHaveLength(2);
// Reset the failed-attempt counter so this test can't lock the fixture
// user for later tests (max_login_attempts comes from system settings).
await prisma.users.update({
where: { id: testUserId },
data: { failed_login_attempts: 0 },
});
await prisma.totp_login_tokens.deleteMany({
where: { user_id: testUserId },
});
});
it("issues tokens for a valid login token + backup code and consumes both (single-use)", async () => {
const loginToken = await issueLoginToken(testUserId);
const res = await postBackupVerify({
login_token: loginToken,
backup_code: BACKUP_CODE_1,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(typeof body.data.access_token).toBe("string");
expect(body.data.access_token.length).toBeGreaterThan(0);
expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry);
expect(body.data.user.userId).toBe(testUserId);
// Same login completion as the TOTP path: httpOnly refresh cookie set.
const cookie = extractCookie(res, "refresh_token");
expect(cookie).toBeTruthy();
const raw = rawSetCookie(res, "refresh_token")!;
expect(raw).toMatch(/HttpOnly/i);
expect(raw).toMatch(/SameSite=Strict/i);
// The used backup code must be removed (single-use).
const user = await fixtureUser();
const remaining: string[] = JSON.parse(user.totp_backup_codes as string);
expect(remaining).toHaveLength(1);
// The login token must be consumed: replaying it (even with the second,
// still-valid backup code) is rejected.
const replay = await postBackupVerify({
login_token: loginToken,
backup_code: BACKUP_CODE_2,
});
expect(replay.statusCode).toBe(401);
});
it("rejects reuse of an already-consumed backup code", async () => {
const loginToken = await issueLoginToken(testUserId);
const res = await postBackupVerify({
login_token: loginToken,
backup_code: BACKUP_CODE_1,
});
expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false);
await prisma.users.update({
where: { id: testUserId },
data: { failed_login_attempts: 0 },
});
await prisma.totp_login_tokens.deleteMany({
where: { user_id: testUserId },
});
});
it("honors remember_me with a long-lived refresh token (parity with /login/totp)", async () => {
const loginToken = await issueLoginToken(testUserId);
const res = await postBackupVerify({
login_token: loginToken,
backup_code: BACKUP_CODE_2,
remember_me: true,
});
expect(res.statusCode).toBe(200);
const raw = rawSetCookie(res, "refresh_token")!;
expect(raw).toMatch(
new RegExp(`Max-Age=${config.jwt.refreshTokenRememberExpiry}`, "i"),
);
const refreshRow = await prisma.refresh_tokens.findFirst({
where: { user_id: testUserId },
orderBy: { id: "desc" },
});
expect(refreshRow?.remember_me).toBe(true);
});
});

541
src/__tests__/trips.test.ts Normal file
View File

@@ -0,0 +1,541 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import { securityHeaders } from "../middleware/security";
import tripsRoutes from "../routes/admin/trips";
// ---------------------------------------------------------------------------
// Route-level tests for the trips domain:
//
// 1. PUT /trips/:id with vehicle_id actually moves the trip to the new vehicle
// (UpdateTripSchema previously had no vehicle_id, so the edit modal's
// vehicle change was silently dropped while the UI reported success) and
// recomputes actual_km on BOTH vehicles.
// 2. GET /trips/stats aggregates count/total/business/private km over the
// WHOLE filtered set (not one 25-row page), honors the month filter in
// both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes
// non-managers to their own trips exactly like the list does.
// ---------------------------------------------------------------------------
/** Note-prefix for test-created rows so cleanup never touches real data. */
const N = "trips_test_";
let app: Awaited<ReturnType<typeof buildApp>>;
let adminUserId: number;
let adminToken: string;
let scopeUserId: number;
let scopeRoleId: number;
let scopeToken: string;
let vehicleAId: number;
let vehicleBId: number;
async function buildApp() {
const a = Fastify({ logger: false });
await a.register(cookie);
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
a.addHook("onRequest", securityHeaders);
await a.register(tripsRoutes, { prefix: "/api/admin/trips" });
return a;
}
/**
* Generate a JWT access token. `requireAuth` re-loads auth data from the DB
* via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here.
*/
function generateToken(user: {
id: number;
username: string;
roleName: string | null;
}): string {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
config.jwt.secret,
{ expiresIn: "15m" },
);
}
async function authGet(path: string, token: string) {
return app.inject({
method: "GET",
url: path,
headers: { Authorization: `Bearer ${token}` },
});
}
async function authPut(path: string, token: string, body: unknown) {
return app.inject({
method: "PUT",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
function tripRow(params: {
userId: number;
vehicleId: number;
date: string;
startKm: number;
dist: number;
isBusiness: boolean;
}) {
return {
user_id: params.userId,
vehicle_id: params.vehicleId,
trip_date: new Date(params.date),
start_km: params.startKm,
end_km: params.startKm + params.dist,
route_from: "Praha",
route_to: "Brno",
is_business: params.isBusiness,
notes: `${N}fixture`,
};
}
async function cleanupFixtureTrips() {
await prisma.trips.deleteMany({ where: { notes: { contains: N } } });
}
beforeAll(async () => {
app = await buildApp();
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminUserId = admin.id;
adminToken = generateToken({
id: admin.id,
username: admin.username,
roleName: admin.roles?.name ?? null,
});
// Dedicated non-admin user with trips.record + trips.history (but NOT
// trips.manage) for the own-trips scoping tests.
const stamp = Date.now().toString(36);
const role = await prisma.roles.create({
data: {
name: `${N}role_${stamp}`,
display_name: "Trips Scope Test",
},
});
scopeRoleId = role.id;
const perms = await prisma.permissions.findMany({
where: { name: { in: ["trips.record", "trips.history"] } },
select: { id: true },
});
if (perms.length !== 2)
throw new Error(
"Test setup: trips.record/trips.history permissions missing",
);
await prisma.role_permissions.createMany({
data: perms.map((p) => ({ role_id: role.id, permission_id: p.id })),
});
const scopeUser = await prisma.users.create({
data: {
username: `${N}user_${stamp}`,
first_name: "Trips",
last_name: "Scope",
email: `${N}${stamp}@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
role_id: role.id,
is_active: true,
},
});
scopeUserId = scopeUser.id;
if (scopeUserId === adminUserId)
throw new Error("scope user must be distinct from admin");
scopeToken = generateToken({
id: scopeUser.id,
username: scopeUser.username,
roleName: role.name,
});
// Two dedicated vehicles (spz is unique, VarChar(20)).
const vehicleA = await prisma.vehicles.create({
data: { spz: `TTA-${stamp}`, name: "Trips Test A", initial_km: 500 },
});
const vehicleB = await prisma.vehicles.create({
data: { spz: `TTB-${stamp}`, name: "Trips Test B", initial_km: 200 },
});
vehicleAId = vehicleA.id;
vehicleBId = vehicleB.id;
await cleanupFixtureTrips();
});
beforeEach(async () => {
await cleanupFixtureTrips();
});
afterAll(async () => {
await cleanupFixtureTrips();
await prisma.vehicles
.deleteMany({ where: { id: { in: [vehicleAId, vehicleBId] } } })
.catch(() => {});
if (scopeUserId)
await prisma.users
.deleteMany({ where: { id: scopeUserId } })
.catch(() => {});
if (scopeRoleId)
await prisma.roles
.deleteMany({ where: { id: scopeRoleId } })
.catch(() => {});
if (app) await app.close();
});
describe("PUT /api/admin/trips/:id — vehicle_id", () => {
it("changes the trip's vehicle and recomputes actual_km on both vehicles", async () => {
const trip = await prisma.trips.create({
data: tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-04-10",
startKm: 1000,
dist: 500,
isBusiness: true,
}),
});
// The edit form sends the id as a string (Select value) — intIdFromForm
// must coerce it.
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
vehicle_id: String(vehicleBId),
});
expect(res.statusCode).toBe(200);
expect(res.json().success).toBe(true);
const updated = await prisma.trips.findUnique({ where: { id: trip.id } });
expect(updated!.vehicle_id).toBe(vehicleBId);
// New vehicle picks up the trip's odometer reading…
const vehB = await prisma.vehicles.findUnique({
where: { id: vehicleBId },
});
expect(vehB!.actual_km).toBe(1500);
// …and the old vehicle falls back to initial_km (no trips left on it).
const vehA = await prisma.vehicles.findUnique({
where: { id: vehicleAId },
});
expect(vehA!.actual_km).toBe(500);
});
it("recomputes actual_km when end_km changes and the vehicle stays the same", async () => {
// Two trips on vehicle A — the recompute must take MAX(end_km) over ALL
// of the vehicle's trips, not just the edited one.
const trip1 = await prisma.trips.create({
data: tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-09-10",
startKm: 1000,
dist: 500, // end_km 1500
isBusiness: true,
}),
});
const trip2 = await prisma.trips.create({
data: tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-09-12",
startKm: 1800,
dist: 200, // end_km 2000
isBusiness: true,
}),
});
// Raising the MAX trip's end_km (vehicle UNCHANGED) moves the odometer up…
const raise = await authPut(`/api/admin/trips/${trip2.id}`, adminToken, {
end_km: 2200,
});
expect(raise.statusCode).toBe(200);
let vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } });
expect(vehA!.actual_km).toBe(2200);
// …while editing the non-MAX trip recomputes but keeps MAX(end_km).
const lower = await authPut(`/api/admin/trips/${trip1.id}`, adminToken, {
end_km: 1600,
});
expect(lower.statusCode).toBe(200);
const updated = await prisma.trips.findUnique({ where: { id: trip1.id } });
expect(updated!.end_km).toBe(1600);
expect(updated!.vehicle_id).toBe(vehicleAId);
vehA = await prisma.vehicles.findUnique({ where: { id: vehicleAId } });
expect(vehA!.actual_km).toBe(2200);
});
it("writes an audit row with oldValues/newValues on update", async () => {
const trip = await prisma.trips.create({
data: tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-10-10",
startKm: 100,
dist: 50, // end_km 150
isBusiness: true,
}),
});
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
end_km: 180,
route_to: "Ostrava",
});
expect(res.statusCode).toBe(200);
const audit = await prisma.audit_logs.findFirst({
where: { action: "update", entity_type: "trip", entity_id: trip.id },
orderBy: { id: "desc" },
});
expect(audit).not.toBeNull();
expect(audit!.user_id).toBe(adminUserId);
// oldValues = the full pre-update row, newValues = the changed fields.
expect(audit!.old_values).toBeTruthy();
expect(audit!.new_values).toBeTruthy();
const oldValues = JSON.parse(audit!.old_values!);
const newValues = JSON.parse(audit!.new_values!);
expect(oldValues.end_km).toBe(150);
expect(oldValues.route_to).toBe("Brno");
expect(oldValues.vehicle_id).toBe(vehicleAId);
expect(newValues).toEqual({ end_km: 180, route_to: "Ostrava" });
});
it("rejects an invalid vehicle_id with 400", async () => {
const trip = await prisma.trips.create({
data: tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-04-11",
startKm: 100,
dist: 50,
isBusiness: true,
}),
});
const res = await authPut(`/api/admin/trips/${trip.id}`, adminToken, {
vehicle_id: "abc",
});
expect(res.statusCode).toBe(400);
const unchanged = await prisma.trips.findUnique({ where: { id: trip.id } });
expect(unchanged!.vehicle_id).toBe(vehicleAId);
});
});
describe("GET /api/admin/trips/stats", () => {
it("aggregates over the WHOLE filtered set, beyond one 25-row page", async () => {
// 20 business trips of 10 km + 10 private trips of 7 km in 2098-05.
const rows = [];
for (let i = 0; i < 20; i++) {
rows.push(
tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-05-10",
startKm: 1000 + i * 10,
dist: 10,
isBusiness: true,
}),
);
}
for (let i = 0; i < 10; i++) {
rows.push(
tripRow({
userId: adminUserId,
vehicleId: vehicleBId,
date: "2098-05-15",
startKm: 2000 + i * 7,
dist: 7,
isBusiness: false,
}),
);
}
await prisma.trips.createMany({ data: rows });
// The list truncates to the default 25-row page but reports the real total…
const listRes = await authGet(
"/api/admin/trips?month=5&year=2098",
adminToken,
);
expect(listRes.statusCode).toBe(200);
const listBody = listRes.json();
expect(listBody.data).toHaveLength(25);
expect(listBody.pagination.total).toBe(30);
expect(listBody.pagination.total_pages).toBe(2);
// …while the stats endpoint covers all 30 rows.
const res = await authGet(
"/api/admin/trips/stats?month=5&year=2098",
adminToken,
);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data).toEqual({
count: 30,
total_km: 20 * 10 + 10 * 7,
business_km: 200,
private_km: 70,
});
});
it("honors the month filter in both wire formats", async () => {
await prisma.trips.createMany({
data: [
tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-06-10",
startKm: 100,
dist: 10,
isBusiness: true,
}),
tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-06-12",
startKm: 110,
dist: 20,
isBusiness: false,
}),
tripRow({
userId: adminUserId,
vehicleId: vehicleAId,
date: "2098-07-05",
startKm: 130,
dist: 40,
isBusiness: true,
}),
],
});
// Combined "YYYY-MM" format (TripsHistory)
const june = await authGet(
"/api/admin/trips/stats?month=2098-06",
adminToken,
);
expect(june.statusCode).toBe(200);
expect(june.json().data).toEqual({
count: 2,
total_km: 30,
business_km: 10,
private_km: 20,
});
// Split month+year format (TripsAdmin)
const july = await authGet(
"/api/admin/trips/stats?month=7&year=2098",
adminToken,
);
expect(july.statusCode).toBe(200);
expect(july.json().data).toEqual({
count: 1,
total_km: 40,
business_km: 40,
private_km: 0,
});
});
it("scopes non-managers to their own trips (user_id param ignored)", async () => {
await prisma.trips.createMany({
data: [
// 2 trips for the scope user, 3 for the admin — same month.
tripRow({
userId: scopeUserId,
vehicleId: vehicleAId,
date: "2098-08-05",
startKm: 100,
dist: 10,
isBusiness: true,
}),
tripRow({
userId: scopeUserId,
vehicleId: vehicleAId,
date: "2098-08-06",
startKm: 110,
dist: 15,
isBusiness: false,
}),
tripRow({
userId: adminUserId,
vehicleId: vehicleBId,
date: "2098-08-07",
startKm: 200,
dist: 100,
isBusiness: true,
}),
tripRow({
userId: adminUserId,
vehicleId: vehicleBId,
date: "2098-08-08",
startKm: 300,
dist: 100,
isBusiness: true,
}),
tripRow({
userId: adminUserId,
vehicleId: vehicleBId,
date: "2098-08-09",
startKm: 400,
dist: 100,
isBusiness: true,
}),
],
});
// Non-manager: only own trips, even when explicitly requesting another user.
const own = await authGet(
"/api/admin/trips/stats?month=8&year=2098",
scopeToken,
);
expect(own.statusCode).toBe(200);
expect(own.json().data).toEqual({
count: 2,
total_km: 25,
business_km: 10,
private_km: 15,
});
const spoofed = await authGet(
`/api/admin/trips/stats?month=8&year=2098&user_id=${adminUserId}`,
scopeToken,
);
expect(spoofed.statusCode).toBe(200);
expect(spoofed.json().data.count).toBe(2);
// The list applies the exact same scoping.
const list = await authGet(
"/api/admin/trips?month=8&year=2098",
scopeToken,
);
expect(list.statusCode).toBe(200);
expect(list.json().data).toHaveLength(2);
// Manager (admin) sees everything in the month.
const all = await authGet(
"/api/admin/trips/stats?month=8&year=2098",
adminToken,
);
expect(all.statusCode).toBe(200);
expect(all.json().data.count).toBe(5);
expect(all.json().data.total_km).toBe(325);
// Manager can filter to a single user.
const filtered = await authGet(
`/api/admin/trips/stats?month=8&year=2098&user_id=${scopeUserId}`,
adminToken,
);
expect(filtered.statusCode).toBe(200);
expect(filtered.json().data.count).toBe(2);
});
});

View File

@@ -1,4 +1,13 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
describe,
it,
expect,
beforeAll,
afterAll,
beforeEach,
afterEach,
vi,
} from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
@@ -6,6 +15,7 @@ import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import warehouseRoutes from "../routes/admin/warehouse";
import { nasInvoicesManager } from "../services/nas-financials-manager";
import { securityHeaders } from "../middleware/security";
// ---------------------------------------------------------------------------
@@ -879,6 +889,177 @@ describe("Receipt flow", () => {
});
});
// ---------------------------------------------------------------------------
// 5b. Receipt attachment download
// ---------------------------------------------------------------------------
describe("Receipt attachment download", () => {
let receiptId: number;
let otherReceiptId: number;
let attachmentId: number;
let diacriticAttachmentId: number;
const fileBytes = Buffer.from("%PDF-1.4 fake attachment bytes", "utf8");
const filePath = "Přijaté/2026/06/wh_test_priloha.pdf";
const diacriticFileName = "příloha č. 1.pdf";
beforeAll(async () => {
// Item + two receipts (attachment belongs to the first one only)
const itemRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: { name: `${N}item_att_download`, unit: "ks" },
});
const itemId = itemRes.json().data.id;
const receiptRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}att_download_a`,
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
},
});
receiptId = receiptRes.json().data.id;
const otherReceiptRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}att_download_b`,
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
},
});
otherReceiptId = otherReceiptRes.json().data.id;
// Attachment row created directly — the upload endpoint needs a writable
// NAS, which is not available in the test environment. The NAS read is
// stubbed per-test on the singleton (same temp-dir/stub seam as the
// nas-document-manager tests); the DB rows and route logic stay real.
const attachment = await prisma.sklad_receipt_attachments.create({
data: {
receipt_id: receiptId,
file_name: "wh_test_priloha.pdf",
file_mime: "application/pdf",
file_size: fileBytes.length,
file_path: filePath,
},
});
attachmentId = attachment.id;
// Diacritic filename — the norm for a Czech company. Node throws on
// header values with chars > U+00FF, so this exercises the RFC 5987 path.
const diacriticAttachment = await prisma.sklad_receipt_attachments.create({
data: {
receipt_id: receiptId,
file_name: diacriticFileName,
file_mime: "application/pdf",
file_size: fileBytes.length,
file_path: "Přijaté/2026/06/wh_test_priloha_diakritika.pdf",
},
});
diacriticAttachmentId = diacriticAttachment.id;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("downloads an existing attachment with correct content-type and bytes", async () => {
const readSpy = vi
.spyOn(nasInvoicesManager, "readReceived")
.mockReturnValue({ data: fileBytes, fileName: "wh_test_priloha.pdf" });
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/pdf");
expect(res.headers["content-disposition"]).toBe(
`attachment; filename="wh_test_priloha.pdf"; filename*=UTF-8''wh_test_priloha.pdf`,
);
expect(res.rawPayload.equals(fileBytes)).toBe(true);
expect(readSpy).toHaveBeenCalledWith(filePath);
});
it("downloads an attachment with a Czech diacritic filename (RFC 5987)", async () => {
vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue({
data: fileBytes,
fileName: diacriticFileName,
});
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${diacriticAttachmentId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(200);
const disposition = res.headers["content-disposition"];
// The real UTF-8 name travels percent-encoded in filename* (RFC 5987) …
expect(disposition).toContain(
`filename*=UTF-8''${encodeURIComponent(diacriticFileName)}`,
);
// … alongside an ASCII-only fallback, so the header never carries
// chars > U+00FF (which Node's setHeader rejects → 500).
expect(disposition).toBe(
`attachment; filename="p__loha _. 1.pdf"; filename*=UTF-8''p%C5%99%C3%ADloha%20%C4%8D.%201.pdf`,
);
expect(res.rawPayload.equals(fileBytes)).toBe(true);
});
it("returns 404 when the attachment belongs to a different receipt", async () => {
const readSpy = vi.spyOn(nasInvoicesManager, "readReceived");
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${otherReceiptId}/attachments/${attachmentId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
// Ownership is rejected before any NAS access
expect(readSpy).not.toHaveBeenCalled();
});
it("returns 404 for a nonexistent attachment id", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/99999999`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it("returns 404 when the file is missing on the NAS", async () => {
vi.spyOn(nasInvoicesManager, "readReceived").mockReturnValue(null);
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
headers: authHeader(adminToken),
});
expect(res.statusCode).toBe(404);
expect(res.json().success).toBe(false);
});
it("rejects download without warehouse.view permission (403)", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
headers: authHeader(noPermToken),
});
expect(res.statusCode).toBe(403);
});
});
// ---------------------------------------------------------------------------
// 6. Issue flow
// ---------------------------------------------------------------------------

View File

@@ -7,8 +7,8 @@ import { useTheme } from "@mui/material/styles";
import { Modal, Button, TextField, Field } from "../ui";
import { useAlert } from "../context/AlertContext";
// Editable line-item. quantity/unit_price/vat_rate are held as the raw typed
// string while editing (so a field can be cleared — empty renders fine in a
// Editable line-item. quantity/unit_price are held as the raw typed string
// while editing (so a field can be cleared — empty renders fine in a
// type="number" input) and are coerced to numbers in handleEditGenerate before
// being handed to onGenerate (the parent posts them verbatim).
interface ConfirmationItem {
@@ -17,7 +17,6 @@ interface ConfirmationItem {
unit: string;
unit_price: string | number;
is_included_in_total: boolean;
vat_rate: string | number;
}
// Numeric shape handed to the parent (the raw editing strings are coerced in
@@ -28,21 +27,14 @@ interface GeneratedItem {
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}
interface OrderConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onGenerate: (
lang: string,
applyVat: boolean,
items?: GeneratedItem[],
) => Promise<void>;
onGenerate: (lang: string, items?: GeneratedItem[]) => Promise<void>;
initialItems: ConfirmationItem[];
orderNumber: string;
defaultVatRate: number;
applyVat: boolean;
}
export default function OrderConfirmationModal({
@@ -51,15 +43,12 @@ export default function OrderConfirmationModal({
onGenerate,
initialItems,
orderNumber,
defaultVatRate,
applyVat,
}: OrderConfirmationModalProps) {
const alert = useAlert();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [step, setStep] = useState<"choose" | "edit">("choose");
const [lang, setLang] = useState<string>("cs");
const [applyVatState, setApplyVatState] = useState(applyVat);
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
const [loading, setLoading] = useState(false);
@@ -72,17 +61,16 @@ export default function OrderConfirmationModal({
if (!isOpen) return;
setStep("choose");
setLang("cs");
setApplyVatState(applyVat);
setItems(initialItems);
// initialItems/applyVat are captured at open time; intentionally not in the
// dep array so an unrelated parent re-render doesn't clobber the user's edits.
// initialItems are captured at open time; intentionally not in the dep
// array so an unrelated parent re-render doesn't clobber the user's edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const handleUseExisting = async () => {
setLoading(true);
try {
await onGenerate(lang, applyVatState, undefined);
await onGenerate(lang, undefined);
// Only close on success — a generation error must keep the modal open.
onClose();
} catch (err) {
@@ -104,9 +92,8 @@ export default function OrderConfirmationModal({
unit: it.unit,
unit_price: Number(it.unit_price) || 0,
is_included_in_total: it.is_included_in_total,
vat_rate: Number(it.vat_rate) || 0,
}));
await onGenerate(lang, applyVatState, coercedItems);
await onGenerate(lang, coercedItems);
// Only close on success — on error keep the user's edited items intact.
onClose();
} catch (err) {
@@ -145,10 +132,9 @@ export default function OrderConfirmationModal({
unit: "ks",
unit_price: 0,
is_included_in_total: true,
vat_rate: defaultVatRate,
},
]);
}, [defaultVatRate]);
}, []);
return (
<Modal
@@ -186,27 +172,6 @@ export default function OrderConfirmationModal({
</Box>
</Field>
<Field label="DPH">
<Box sx={{ display: "flex", gap: 1 }}>
<Button
size="small"
variant={applyVatState ? "contained" : "outlined"}
color={applyVatState ? "primary" : "inherit"}
onClick={() => setApplyVatState(true)}
>
S DPH
</Button>
<Button
size="small"
variant={!applyVatState ? "contained" : "outlined"}
color={!applyVatState ? "primary" : "inherit"}
onClick={() => setApplyVatState(false)}
>
Bez DPH
</Button>
</Box>
</Field>
<Field label="Obsah potvrzení">
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Jak chcete připravit potvrzení objednávky?
@@ -291,7 +256,7 @@ export default function OrderConfirmationModal({
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 1,
}}
>
@@ -318,15 +283,6 @@ export default function OrderConfirmationModal({
}
slotProps={{ htmlInput: { step: "0.01" } }}
/>
<TextField
label="%DPH"
type="number"
value={item.vat_rate ?? ""}
onChange={(e) =>
updateItem(i, "vat_rate", e.target.value)
}
slotProps={{ htmlInput: { step: "1" } }}
/>
</Box>
</Box>
))}
@@ -356,7 +312,6 @@ export default function OrderConfirmationModal({
<th>Mn.</th>
<th>Jedn.</th>
<th>Cena</th>
<th>%DPH</th>
<th style={{ width: "40px" }} />
</tr>
</thead>
@@ -403,17 +358,6 @@ export default function OrderConfirmationModal({
slotProps={{ htmlInput: { step: "0.01" } }}
/>
</td>
<td>
<TextField
type="number"
value={item.vat_rate ?? ""}
onChange={(e) =>
updateItem(i, "vat_rate", e.target.value)
}
sx={{ width: 80 }}
slotProps={{ htmlInput: { step: "1" } }}
/>
</td>
<td>
<IconButton
size="small"

View File

@@ -1,6 +1,7 @@
import { useState, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useQueryClient } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import QRCode from "qrcode";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
@@ -134,6 +135,28 @@ export default function DashProfile({
// locks <html> scroll like the kit dialogs.
useDialogScrollLock(show2FASetup);
// Generate the enrollment QR LOCALLY from the otpauth URI. Never send the
// URI to an external QR service: the production CSP (img-src) blocks it and
// it would leak the TOTP secret to a third party. A data: URL is allowed by
// the CSP. Derived via useQuery (not an effect) per repo conventions.
const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({
queryKey: ["totp", "qr", totpQrUri],
enabled: !!totpQrUri,
staleTime: Infinity,
// The URI embeds the TOTP secret — drop it from the cache as soon as the
// enrollment UI unmounts instead of keeping it for the default 5-min GC.
gcTime: 0,
retry: false,
queryFn: async () => {
try {
return await QRCode.toDataURL(totpQrUri!, { width: 200, margin: 2 });
} catch (err) {
console.error("DashProfile: generování QR kódu selhalo", err);
throw err;
}
},
});
const [showModal, setShowModal] = useState(false);
const [formData, setFormData] = useState<ProfileFormData>({
username: "",
@@ -549,11 +572,11 @@ export default function DashProfile({
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
Authy, Microsoft Authenticator apod.)
</Typography>
{totpQrUri && (
{totpQrUri && totpQrDataUrl && (
<Box sx={{ textAlign: "center", mb: 2 }}>
<Box
component="img"
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
src={totpQrDataUrl}
alt="TOTP QR Code"
sx={{
width: 200,
@@ -561,10 +584,23 @@ export default function DashProfile({
borderRadius: 2,
border: 1,
borderColor: "divider",
// The QR must stay scannable in dark mode — keep it on a
// white tile instead of inheriting the dark paper bg.
bgcolor: "common.white",
}}
/>
</Box>
)}
{totpQrUri && totpQrFailed && (
<Typography
variant="body2"
color="warning.main"
sx={{ mb: 2, textAlign: "center" }}
>
QR kód se nepodařilo vygenerovat. Zadejte prosím klíč do
aplikace ručně.
</Typography>
)}
{totpSecret && (
<Box sx={{ mb: 2 }}>
<Typography

View File

@@ -0,0 +1,653 @@
import type { ReactNode } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Checkbox from "@mui/material/Checkbox";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import {
restrictToVerticalAxis,
restrictToParentElement,
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { Button, Card, TextField } from "../../ui";
import { formatCurrency } from "../../utils/formatters";
/**
* One sortable line item of a document (offer / issued order). The numeric
* fields are held as the raw typed string while editing so the input can be
* cleared (empty renders fine in a type="number" input); coerce via
* `Number(x) || 0` only where used (live totals + save payload).
*/
export interface DocumentItem {
_key: string;
id?: number;
description: string;
item_description: string;
quantity: string | number;
unit: string;
unit_price: string | number;
/** Offers only — undefined (issued orders) counts as included. */
is_included_in_total?: boolean;
}
let documentItemKeyCounter = 0;
/** Unique client-side key for a (new or hydrated) document item row. */
export const nextDocumentItemKey = (): string =>
`item-${++documentItemKeyCounter}`;
export const emptyDocumentItem = (): DocumentItem => ({
_key: nextDocumentItemKey(),
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
is_included_in_total: true,
});
/** NET total over the included items (documents here carry no VAT). */
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),
0,
);
const DragIcon = (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="9" cy="5" r="1.5" />
<circle cx="15" cy="5" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
<circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="19" r="1.5" />
<circle cx="15" cy="19" r="1.5" />
</svg>
);
const RemoveIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
function SortableItemRow({
item,
index,
currency,
readOnly,
canDelete,
showIncludedInTotal,
itemDescriptionMaxLength,
onUpdate,
onRemove,
}: {
item: DocumentItem;
index: number;
currency: string;
readOnly: boolean;
canDelete: boolean;
showIncludedInTotal: boolean;
itemDescriptionMaxLength: number;
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
onRemove: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item._key, disabled: readOnly });
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
background: isDragging ? "var(--mui-palette-action-hover)" : undefined,
position: "relative" as const,
zIndex: isDragging ? 10 : undefined,
};
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
if (isMobile) {
return (
<Box
ref={setNodeRef}
sx={{
border: 1,
borderColor: "divider",
borderRadius: 2,
p: 1.5,
display: "flex",
flexDirection: "column",
gap: 1.25,
bgcolor: "background.paper",
...style,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{!readOnly && (
<IconButton
size="small"
{...attributes}
{...listeners}
title="Přetáhnout"
aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary" }}
>
{DragIcon}
</IconButton>
)}
<Typography
variant="caption"
sx={{ color: "text.secondary", fontWeight: 700 }}
>
Položka {index + 1}
</Typography>
<Box sx={{ flex: 1 }} />
{!readOnly && (
<IconButton
color="error"
size="small"
onClick={onRemove}
title="Odebrat"
aria-label="Odebrat"
disabled={!canDelete}
>
{RemoveIcon}
</IconButton>
)}
</Box>
<TextField
label="Popis"
value={item.description}
onChange={(e) => onUpdate("description", e.target.value)}
placeholder="Popis položky…"
InputProps={{ readOnly }}
slotProps={{ htmlInput: { maxLength: 500 } }}
/>
<TextField
label="Podrobný popis"
value={item.item_description}
onChange={(e) => onUpdate("item_description", e.target.value)}
placeholder="Volitelný"
InputProps={{ readOnly }}
multiline
rows={2}
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
sx={{ "& textarea": { resize: "vertical" } }}
/>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 1,
}}
>
<TextField
label="Množství"
type="number"
value={item.quantity ?? ""}
onChange={(e) => onUpdate("quantity", e.target.value)}
slotProps={{ htmlInput: { min: "0", step: "any" } }}
InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "center" } }}
/>
<TextField
label="Jednotka"
value={item.unit}
onChange={(e) => onUpdate("unit", e.target.value)}
placeholder="ks"
InputProps={{ readOnly }}
slotProps={{ htmlInput: { maxLength: 20 } }}
sx={{ "& input": { textAlign: "center" } }}
/>
<TextField
label="Jedn. cena"
type="number"
value={item.unit_price ?? ""}
onChange={(e) => onUpdate("unit_price", e.target.value)}
slotProps={{ htmlInput: { min: "0", step: "any" } }}
InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "right" } }}
/>
</Box>
<Box
sx={{
display: "flex",
justifyContent: showIncludedInTotal ? "space-between" : "flex-end",
alignItems: "baseline",
gap: 1,
pt: 0.5,
borderTop: 1,
borderColor: "divider",
}}
>
{showIncludedInTotal && (
<Box
component="label"
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
cursor: readOnly ? "default" : "pointer",
}}
>
<Checkbox
checked={item.is_included_in_total !== false}
onChange={(e) =>
onUpdate("is_included_in_total", e.target.checked)
}
disabled={readOnly}
/>
<Typography variant="body2">V ceně</Typography>
</Box>
)}
<Box
sx={{
display: "flex",
alignItems: "baseline",
gap: 1,
}}
>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Celkem
</Typography>
<Typography
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 600,
}}
>
{formatCurrency(lineTotal, currency)}
</Typography>
</Box>
</Box>
</Box>
);
}
return (
<TableRow ref={setNodeRef} sx={style}>
{/* Stable column layout: every cell renders in read-only mode too —
only the controls inside are hidden. */}
<TableCell sx={{ width: "2rem", px: 0.5 }}>
{!readOnly && (
<IconButton
size="small"
{...attributes}
{...listeners}
title="Přetáhnout"
aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary" }}
>
{DragIcon}
</IconButton>
)}
</TableCell>
<TableCell
align="center"
sx={{ color: "text.secondary", fontWeight: 500 }}
>
{index + 1}
</TableCell>
<TableCell sx={{ verticalAlign: "top" }}>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
<TextField
value={item.description}
onChange={(e) => onUpdate("description", e.target.value)}
placeholder="Popis položky…"
InputProps={{ readOnly }}
slotProps={{ htmlInput: { maxLength: 500 } }}
sx={{ "& input": { fontWeight: 500 } }}
/>
<TextField
value={item.item_description}
onChange={(e) => onUpdate("item_description", e.target.value)}
placeholder="Podrobný popis (volitelný)"
InputProps={{ readOnly }}
multiline
rows={2}
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
sx={{
"& textarea": {
fontSize: "0.8rem",
opacity: 0.8,
resize: "vertical",
},
}}
/>
</Box>
</TableCell>
<TableCell>
<TextField
type="number"
value={item.quantity ?? ""}
onChange={(e) => onUpdate("quantity", e.target.value)}
slotProps={{ htmlInput: { min: "0", step: "any" } }}
InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "center" } }}
/>
</TableCell>
<TableCell>
<TextField
value={item.unit}
onChange={(e) => onUpdate("unit", e.target.value)}
placeholder="ks"
InputProps={{ readOnly }}
slotProps={{ htmlInput: { maxLength: 20 } }}
sx={{ "& input": { textAlign: "center" } }}
/>
</TableCell>
<TableCell>
<TextField
type="number"
value={item.unit_price ?? ""}
onChange={(e) => onUpdate("unit_price", e.target.value)}
slotProps={{ htmlInput: { min: "0", step: "any" } }}
InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "right" } }}
/>
</TableCell>
{showIncludedInTotal && (
<TableCell align="center">
<Checkbox
checked={item.is_included_in_total !== false}
onChange={(e) => onUpdate("is_included_in_total", e.target.checked)}
disabled={readOnly}
/>
</TableCell>
)}
<TableCell
align="right"
sx={{
fontWeight: 600,
whiteSpace: "nowrap",
fontFamily: "'DM Mono', Menlo, monospace",
}}
>
{formatCurrency(lineTotal, currency)}
</TableCell>
<TableCell sx={{ width: "2.5rem" }}>
{!readOnly && (
<IconButton
color="error"
size="small"
onClick={onRemove}
title="Odebrat"
aria-label="Odebrat"
disabled={!canDelete}
>
{RemoveIcon}
</IconButton>
)}
</TableCell>
</TableRow>
);
}
interface DocumentItemsEditorProps {
items: DocumentItem[];
onChange: (items: DocumentItem[]) => void;
currency: string;
readOnly: boolean;
/** Validation error shown under the section title. */
error?: string;
/** Render the offers-only "V ceně" (is_included_in_total) column. */
showIncludedInTotal?: 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;
}
/**
* Sortable line-items table shared by the document detail pages (offers,
* issued orders): drag-and-drop reorder, mobile card layout, live NET totals
* ("Celkem bez DPH" — these documents are not tax documents).
*/
export default function DocumentItemsEditor({
items,
onChange,
currency,
readOnly,
error,
showIncludedInTotal = false,
itemDescriptionMaxLength = 5000,
templatesSlot,
}: DocumentItemsEditorProps) {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 5 },
}),
useSensor(KeyboardSensor),
);
const updateItem = (
index: number,
field: keyof DocumentItem,
value: string | boolean,
) =>
onChange(
items.map((it, i) => (i === index ? { ...it, [field]: value } : it)),
);
const addItem = () => onChange([...items, emptyDocumentItem()]);
const removeItem = (index: number) => {
if (items.length <= 1) return;
onChange(items.filter((_, i) => i !== index));
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = items.findIndex((i) => i._key === String(active.id));
const newIndex = items.findIndex((i) => i._key === String(over.id));
if (oldIndex === -1 || newIndex === -1) return;
onChange(arrayMove(items, oldIndex, newIndex));
};
const total = documentItemsTotal(items);
return (
<Card sx={{ mb: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
mb: 2,
}}
>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
Položky
</Typography>
{error && (
<Typography variant="caption" color="error">
{error}
</Typography>
)}
</Box>
{!readOnly && (
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
{templatesSlot}
<Button
type="button"
onClick={addItem}
variant="outlined"
color="inherit"
size="small"
>
+ Přidat položku
</Button>
</Box>
)}
</Box>
<DndContext
sensors={dndSensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((i) => i._key)}
strategy={verticalListSortingStrategy}
>
{isMobile ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, index) => (
<SortableItemRow
key={item._key}
item={item}
index={index}
currency={currency}
readOnly={readOnly}
canDelete={items.length > 1}
showIncludedInTotal={showIncludedInTotal}
itemDescriptionMaxLength={itemDescriptionMaxLength}
onUpdate={(field, value) => updateItem(index, field, value)}
onRemove={() => removeItem(index)}
/>
))}
</Box>
) : (
<TableContainer sx={{ overflowX: "auto" }}>
<Table
size="small"
sx={{
minWidth: 720,
"& td, & th": { borderColor: "divider" },
}}
>
<TableHead>
<TableRow>
<TableCell sx={{ width: "2rem" }} />
<TableCell sx={{ width: "2rem" }} align="center">
#
</TableCell>
<TableCell>Popis</TableCell>
<TableCell sx={{ width: "7rem" }} align="center">
Množství
</TableCell>
<TableCell sx={{ width: "5.5rem" }} align="center">
Jednotka
</TableCell>
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
{showIncludedInTotal && (
<TableCell sx={{ width: "4rem" }} align="center">
V ceně
</TableCell>
)}
<TableCell sx={{ width: "8rem" }} align="right">
Celkem
</TableCell>
<TableCell sx={{ width: "2.5rem" }} />
</TableRow>
</TableHead>
<TableBody>
{items.map((item, index) => (
<SortableItemRow
key={item._key}
item={item}
index={index}
currency={currency}
readOnly={readOnly}
canDelete={items.length > 1}
showIncludedInTotal={showIncludedInTotal}
itemDescriptionMaxLength={itemDescriptionMaxLength}
onUpdate={(field, value) =>
updateItem(index, field, value)
}
onRemove={() => removeItem(index)}
/>
))}
</TableBody>
</Table>
</TableContainer>
)}
</SortableContext>
</DndContext>
{/* Totals (NET only — offers/issued orders are not tax documents) */}
<Box
sx={{
mt: 2,
ml: "auto",
maxWidth: 360,
display: "flex",
flexDirection: "column",
gap: 0.5,
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
pt: 0.5,
mt: 0.5,
borderTop: 1,
borderColor: "divider",
}}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Celkem bez DPH:
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 700,
}}
>
{formatCurrency(total, currency)}
</Typography>
</Box>
</Box>
</Card>
);
}

View File

@@ -0,0 +1,55 @@
import Box from "@mui/material/Box";
import type { DocumentLockInfo } from "../../hooks/useDocumentLock";
/**
* Warning banner shown when another user holds the document edit lock
* (extracted from OfferDetail; shared by the document detail pages).
* Renders nothing when the lock is free / held by the current user.
*
* `entityWord` is the Czech accusative for the document ("Nabídku" /
* "Objednávku") — both are feminine, so the rest of the sentence is shared.
*/
export default function LockBanner({
lockedBy,
entityWord,
}: {
lockedBy: DocumentLockInfo | null;
entityWord: string;
}) {
if (!lockedBy) return null;
return (
<Box
sx={{
bgcolor:
"color-mix(in srgb, var(--mui-palette-warning-main) 15%, transparent)",
border: 1,
borderColor: "warning.main",
borderRadius: 1,
px: 2,
py: 1.5,
display: "flex",
alignItems: "center",
gap: 1,
mb: 2,
fontSize: "0.875rem",
color: "warning.main",
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span>
{entityWord} právě upravuje <strong>{lockedBy.full_name}</strong>.
Můžete ji pouze prohlížet.
</span>
</Box>
);
}

View File

@@ -0,0 +1,315 @@
import type { ReactNode } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import RichEditor from "../RichEditor";
import {
Button,
Card,
TextField,
Field,
StatusChip,
EmptyState,
} from "../../ui";
/** One bilingual rich-text PDF section (offers' scope ≡ issued orders' sections). */
export interface DocumentSection {
title: string;
title_cz: string;
content: string;
}
export const emptyDocumentSection = (): DocumentSection => ({
title: "",
title_cz: "",
content: "",
});
const RemoveIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
const UpIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="18 15 12 9 6 15" />
</svg>
);
const DownIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
interface SectionsEditorProps {
sections: DocumentSection[];
onChange: (sections: DocumentSection[]) => void;
readOnly: boolean;
/** Card heading. */
title?: string;
/**
* Document language — "CZ"/"cs" prefers the Czech section title in the
* "Sekce N — …" header line, anything else the English one.
*/
language?: string;
/** Optional page-specific control (e.g. scope-template Select) next to the add button. */
templatesSlot?: ReactNode;
}
/**
* Bilingual rich-text sections editor shared by the document detail pages
* (lifted from OfferDetail's "Rozsah projektu" card): bordered section boxes
* with EN/CZ title fields (StatusChip badges), Quill content, up/down
* reorder and add/remove. Offers pass their template Select via
* `templatesSlot`; issued orders don't (no templates on POs).
*/
export default function SectionsEditor({
sections,
onChange,
readOnly,
title = "Rozsah projektu",
language,
templatesSlot,
}: SectionsEditorProps) {
const preferCz =
(language ?? "").toLowerCase().startsWith("cs") ||
(language ?? "").toUpperCase() === "CZ";
const updateSection = (
index: number,
field: keyof DocumentSection,
value: string,
) =>
onChange(
sections.map((s, i) => (i === index ? { ...s, [field]: value } : s)),
);
const moveSection = (index: number, delta: -1 | 1) => {
const target = index + delta;
if (target < 0 || target >= sections.length) return;
const arr = [...sections];
[arr[index], arr[target]] = [arr[target], arr[index]];
onChange(arr);
};
return (
<Card sx={{ mb: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{title}
</Typography>
{!readOnly && (
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
{templatesSlot}
<Button
type="button"
onClick={() => onChange([...sections, emptyDocumentSection()])}
variant="outlined"
color="inherit"
size="small"
>
+ Přidat sekci
</Button>
</Box>
)}
</Box>
{sections.length === 0 ? (
<EmptyState
title={
templatesSlot
? 'Žádné sekce rozsahu. Klikněte na "Přidat sekci" nebo vyberte šablonu.'
: 'Žádné sekce rozsahu. Klikněte na "Přidat sekci".'
}
/>
) : (
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
mt: 1,
}}
>
{sections.map((section, idx) => (
<Box
key={idx}
sx={{
border: 1,
borderColor: "divider",
borderRadius: 2,
p: 2,
bgcolor: "action.hover",
}}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 1.5,
}}
>
<Typography
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Sekce {idx + 1}
{(preferCz ? section.title_cz : section.title) && (
<Box component="span" sx={{ fontWeight: 400, ml: 1 }}>
{" "}
{preferCz
? section.title_cz || section.title
: section.title}
</Box>
)}
</Typography>
{!readOnly && (
<Box sx={{ display: "flex", gap: 0.25 }}>
{idx > 0 && (
<IconButton
size="small"
onClick={() => moveSection(idx, -1)}
title="Posunout nahoru"
aria-label="Posunout nahoru"
>
{UpIcon}
</IconButton>
)}
{idx < sections.length - 1 && (
<IconButton
size="small"
onClick={() => moveSection(idx, 1)}
title="Posunout dolů"
aria-label="Posunout dolů"
>
{DownIcon}
</IconButton>
)}
<IconButton
size="small"
color="error"
onClick={() =>
onChange(sections.filter((_, i) => i !== idx))
}
title="Odebrat sekci"
aria-label="Odebrat sekci"
>
{RemoveIcon}
</IconButton>
</Box>
)}
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
mb: 0.5,
}}
>
<StatusChip label="EN" color="info" />
<Typography
component="label"
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Název sekce
</Typography>
</Box>
<TextField
value={section.title}
onChange={(e) =>
updateSection(idx, "title", e.target.value)
}
placeholder="Název sekce (anglicky)"
InputProps={{ readOnly }}
slotProps={{ htmlInput: { maxLength: 500 } }}
/>
</Box>
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
mb: 0.5,
}}
>
<StatusChip label="CZ" color="success" />
<Typography
component="label"
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Název sekce
</Typography>
</Box>
<TextField
value={section.title_cz}
onChange={(e) =>
updateSection(idx, "title_cz", e.target.value)
}
placeholder="Název sekce (česky)"
InputProps={{ readOnly }}
slotProps={{ htmlInput: { maxLength: 500 } }}
/>
</Box>
</Box>
<Field label="Obsah">
<RichEditor
value={section.content}
onChange={(val) => updateSection(idx, "content", val)}
placeholder="Obsah sekce..."
minHeight="120px"
readOnly={readOnly}
/>
</Field>
</Box>
))}
</Box>
)}
</Card>
);
}

View File

@@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react";
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
import { queryClient } from "../lib/queryClient";
const API_BASE = "/api/admin";
@@ -122,7 +123,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
},
[],
);
);
const silentRefresh = useCallback(async (): Promise<boolean> => {
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
@@ -232,6 +233,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
remember,
};
}
// Query keys are user-agnostic (["dashboard"], ["attendance"], …),
// so anything cached from a previously logged-in user would be
// served to this one — drop the whole cache on every login.
queryClient.clear();
setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user);
@@ -260,19 +265,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
) => {
setError(null);
try {
const response = await fetch(`${API_BASE}/login/totp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
login_token: loginToken,
totp_code: code,
remember_me: remember,
isBackup,
}),
});
// Backup codes have their own endpoint + schema (8-char codes would
// fail /login/totp's strict 6-digit TotpVerifySchema). Both endpoints
// complete the same login flow: tokens issued + refresh cookie set.
const response = await fetch(
isBackup
? `${API_BASE}/totp/backup-verify`
: `${API_BASE}/login/totp`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(
isBackup
? {
login_token: loginToken,
backup_code: code,
remember_me: remember,
}
: {
login_token: loginToken,
totp_code: code,
remember_me: remember,
},
),
},
);
const data = await response.json();
if (data.success) {
// Same reason as in login(): the cache may hold the previous
// user's data under user-agnostic keys.
queryClient.clear();
setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user);
@@ -312,6 +335,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null;
}
// The React Query cache outlives the session — without this, the next
// user to log in on this browser sees the previous user's cached
// dashboards/lists until each query refetches.
queryClient.clear();
}
}, [getAccessTokenFn]);

View File

@@ -6,6 +6,7 @@ import {
calcProjectMinutesTotal,
calcFormWorkMinutes,
calculateWorkMinutes,
combineDatetime,
getDatePart,
getTimePart,
formatDate,
@@ -133,9 +134,6 @@ interface DeleteConfirmState {
const API_BASE = "/api/admin";
const combineDatetime = (date: string, time: string): string | null =>
date && time ? `${date}T${time}:00` : null;
/**
* Compute per-user totals from raw attendance records.
* This replaces the server-side `user_totals` that the PHP backend returned.
@@ -869,7 +867,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
try {
const [yearStr, monthStr] = month.split("-");
let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000`;
// The KPI cards + summary totals are computed from THIS fetch — it
// must cover the complete month (the server allows limit up to 2000
// for exactly this view; 2000 ≈ 64 employees × 31 days).
let recordsUrl = `${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=2000`;
if (filterUserId) recordsUrl += `&user_id=${filterUserId}`;
const [recordsResponse, balancesResponse] = await Promise.all([
@@ -1047,6 +1048,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowCreateModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -1119,6 +1124,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowBulkModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -1254,6 +1263,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setShowEditModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -1284,6 +1297,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) {
setDeleteConfirm({ show: false, record: null });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
// The dashboard embeds attendance (Přítomní dnes / Docházka dnes /
// punch-button state) — without this it serves a stale cache for up
// to its staleTime after records change here.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
await fetchData(false);
alert.success(
result.message || result.data?.message || "Záznam smazán",

View File

@@ -0,0 +1,81 @@
import { useCallback, useEffect, useRef, useState } from "react";
import apiFetch from "../utils/api";
/** Lock holder as enriched by the detail endpoints (offers, issued orders). */
export interface DocumentLockInfo {
user_id: number;
username: string;
full_name: string;
}
/**
* Client side of the document edit lock shared by the offer / issued-order
* detail pages (extracted from OfferDetail). The server exposes the same
* trio under the entity base URL: POST `:base/lock`, `:base/heartbeat`,
* `:base/unlock` (lock expires after 30 s without a heartbeat — 3 missed
* 10 s beats, so background-tab interval throttling can't hand the lock away).
*
* Responsibilities:
* - `lockedBy` state — the page hydrates it from the detail response via
* `setLockedBy(detail.locked_by ?? null)`.
* - `acquire()` — fire-and-forget lock acquisition; the page calls it from
* its one-shot hydration effect when the doc is editable, the user holds
* the edit permission and nobody else holds the lock.
* - 10 s heartbeat while `enabled` (and not locked by another user), with
* hard-401 self-stop: a 401 here means the access token expired AND the
* refresh failed (apiFetch retries recoverable 401s itself), so we stop
* the interval instead of spamming a dead session every 10 s.
* - unlock on unmount/disable, with an AbortController so a stale in-flight
* unlock can be cancelled by the next one.
*/
export function useDocumentLock({
baseUrl,
enabled,
}: {
/** Entity endpoint base (e.g. `/api/admin/offers/42`); null in create mode. */
baseUrl: string | null;
/**
* External conditions for holding the lock: edit mode + editable status +
* edit permission. The hook adds `!isLockedByOther` itself.
*/
enabled: boolean;
}) {
const [lockedBy, setLockedBy] = useState<DocumentLockInfo | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unlockAbortRef = useRef<AbortController | null>(null);
const isLockedByOther = lockedBy !== null;
const acquire = useCallback(() => {
if (!baseUrl) return;
apiFetch(`${baseUrl}/lock`, { method: "POST" }).catch(() => {});
}, [baseUrl]);
// Heartbeat to keep the lock alive + release on unmount/disable.
useEffect(() => {
if (!baseUrl || !enabled || isLockedByOther) return;
heartbeatRef.current = setInterval(() => {
apiFetch(`${baseUrl}/heartbeat`, { method: "POST" })
.then((res) => {
if (res.status === 401 && heartbeatRef.current) {
clearInterval(heartbeatRef.current);
heartbeatRef.current = null;
}
})
.catch(() => {});
}, 10 * 1000); // every 10 seconds
return () => {
if (heartbeatRef.current) clearInterval(heartbeatRef.current);
if (unlockAbortRef.current) unlockAbortRef.current.abort();
const controller = new AbortController();
unlockAbortRef.current = controller;
apiFetch(`${baseUrl}/unlock`, {
method: "POST",
signal: controller.signal,
}).catch(() => {});
};
}, [baseUrl, enabled, isLockedByOther]);
return { lockedBy, isLockedByOther, setLockedBy, acquire };
}

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, useState } from "react";
import apiFetch from "../utils/api";
import { useAlert } from "../context/AlertContext";
/**
* Shared "Zobrazit PDF" handlers for the document pages (offers, issued
* orders) — both call their GET `…/:id/file` endpoint (archived NAS PDF with
* live-render fallback). Hardened flow extracted from OfferDetail:
* - pre-opens the tab synchronously (popup blockers only allow it inside the
* click's call stack), then streams the blob URL into it;
* - double-click guard via a ref (state updates are async, a ref is not);
* - 401 → silently close the tab (apiFetch already flagged the dead session);
* - blob URLs are revoked after 60 s; pending revoke timeouts are cleared on
* unmount so they can't fire into a dead page.
*/
/**
* List variant — ONE hook instance serves every row. `openPdf(id, url)`
* fetches that row's PDF; `pdfLoadingId` reports which row is in flight (use
* it for the per-row spinner + disabled state).
*/
export function useDocumentListPdf() {
const alert = useAlert();
const [pdfLoadingId, setPdfLoadingId] = useState<number | null>(null);
const loadingRef = useRef(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => {
// The array object is stable (we only push to it, never reassign), so
// capturing it here keeps the cleanup in sync with the ref's content.
const timeouts = blobTimeoutsRef.current;
return () => {
timeouts.forEach(clearTimeout);
};
}, []);
const openPdf = useCallback(
async (id: number, url: string) => {
if (loadingRef.current) return;
const newWindow = window.open("", "_blank");
loadingRef.current = true;
setPdfLoadingId(id);
try {
const response = await apiFetch(url);
if (response.status === 401) {
newWindow?.close();
return;
}
if (!response.ok) {
newWindow?.close();
alert.error("Nepodařilo se vygenerovat PDF");
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrl;
const timeoutId = setTimeout(() => URL.revokeObjectURL(blobUrl), 60000);
blobTimeoutsRef.current.push(timeoutId);
} catch {
newWindow?.close();
alert.error("Chyba připojení");
} finally {
loadingRef.current = false;
setPdfLoadingId(null);
}
},
[alert],
);
return { openPdf, pdfLoadingId };
}
/**
* Detail variant — a single fixed URL, `openPdf()` takes no arguments.
* Implemented on top of the list variant so the hardened flow lives once.
*/
export function useDocumentPdf(url: string | null) {
const { openPdf: openListPdf, pdfLoadingId } = useDocumentListPdf();
const openPdf = useCallback(async () => {
if (!url) return;
await openListPdf(0, url);
}, [url, openListPdf]);
return { openPdf, pdfLoading: pdfLoadingId !== null };
}

View File

@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef } from "react";
/**
* Dirty-form guard shared by the document detail pages (extracted from
* OfferDetail). Serializes `snapshot` (typically `{ form, items, sections }`)
* every render, captures a baseline on the first `ready` render — or
* explicitly via `markClean` — and registers a `beforeunload` warning while
* the current snapshot differs from the baseline.
*
* `markClean(value?)`:
* - with a value: baseline = that value (use from a hydration effect, where
* the freshly-mapped objects are ahead of the not-yet-rendered state);
* - without: baseline = the last RENDERED snapshot (use after a successful
* save, where the handler's closure state equals the rendered state).
*/
export function useUnsavedChangesGuard(snapshot: unknown, ready: boolean) {
const serialized = JSON.stringify(snapshot);
const serializedRef = useRef(serialized);
serializedRef.current = serialized;
const baselineRef = useRef<string | null>(null);
if (ready && baselineRef.current === null) baselineRef.current = serialized;
const isDirty =
baselineRef.current !== null && serialized !== baselineRef.current;
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
const markClean = useCallback((value?: unknown) => {
baselineRef.current =
value !== undefined ? JSON.stringify(value) : serializedRef.current;
}, []);
return { isDirty, markClean };
}

View File

@@ -6,6 +6,11 @@ export const dashboardOptions = () =>
queryKey: ["dashboard"],
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
staleTime: 60_000,
// The dashboard aggregates MANY domains (attendance, offers, invoices,
// orders, projects, leave). Mutations in those domains can't all be
// expected to invalidate ["dashboard"], so always refetch on mount —
// navigating back to the dashboard must never show pre-mutation data.
refetchOnMount: "always",
});
// require2FAOptions lives in ./settings.ts (the single definition consumers

View File

@@ -1,19 +1,35 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
// Single shared definition (type-only import, erased at compile time) — the
// canonical CurrencyAmount lives in offers.ts; re-exported here so existing
// importers keep working.
import type { CurrencyAmount } from "./offers";
export type { CurrencyAmount };
export interface IssuedOrder {
id: number;
po_number: string | null;
customer_id: number | null;
customer_name: string | null;
supplier_id: number | null;
supplier_name: string | null;
status: string;
currency: string | null;
order_date: string | null;
subtotal: number;
vat_amount: number;
// NET total — issued orders carry no VAT (not tax documents).
total: number;
}
/** Active supplier row from the lightweight PO-picker lookup endpoint. */
export interface Supplier {
id: number;
name: string;
ico: string | null;
dic: string | null;
address: string | null;
email: string | null;
phone: string | null;
}
export interface IssuedOrderItem {
id?: number;
description: string | null;
@@ -21,26 +37,45 @@ export interface IssuedOrderItem {
quantity: number | string | null;
unit: string | null;
unit_price: number | string | null;
vat_rate: number | string | null;
position?: number;
}
/** Rich-text CZ/EN PDF section (mirrors offers' scope sections). */
export interface IssuedOrderSection {
id?: number;
title: string | null;
title_cz: string | null;
content: string | null;
position?: number;
}
export interface IssuedOrderDetail extends IssuedOrder {
apply_vat: boolean | null;
vat_rate: number | string | null;
exchange_rate: number | string | null;
delivery_date: string | null;
language: string | null;
delivery_terms: string | null;
payment_terms: string | null;
issued_by: string | null;
notes: string | null;
order_text: string | null;
internal_notes: string | null;
items: IssuedOrderItem[];
customer: Record<string, unknown> | null;
sections: IssuedOrderSection[];
supplier: Record<string, unknown> | null;
valid_transitions: string[];
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
locked_by: {
user_id: number;
username: string;
full_name: string;
} | null;
}
// Suppliers lookup for the PO supplier picker — hits the issued-orders-scoped
// endpoint (orders permissions), NOT the warehouse.manage-guarded CRUD list.
export const issuedOrderSuppliersOptions = () =>
queryOptions({
queryKey: ["issued-orders", "suppliers"],
queryFn: () => jsonQuery<Supplier[]>("/api/admin/issued-orders/suppliers"),
staleTime: 2 * 60_000,
});
export const issuedOrderListOptions = (filters: {
search?: string;
sort?: string;
@@ -48,6 +83,7 @@ export const issuedOrderListOptions = (filters: {
page?: number;
perPage?: number;
status?: string;
supplier_id?: number;
month?: number;
year?: number;
}) =>
@@ -61,6 +97,8 @@ export const issuedOrderListOptions = (filters: {
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
if (filters.supplier_id)
params.set("supplier_id", String(filters.supplier_id));
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
const qs = params.toString();
@@ -70,14 +108,10 @@ export const issuedOrderListOptions = (filters: {
},
});
export interface CurrencyAmount {
amount: number;
currency: string;
}
export const issuedOrderStatsOptions = (filters: {
search?: string;
status?: string;
supplier_id?: number;
month?: number;
year?: number;
}) =>
@@ -87,6 +121,8 @@ export const issuedOrderStatsOptions = (filters: {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.status) params.set("status", filters.status);
if (filters.supplier_id)
params.set("supplier_id", String(filters.supplier_id));
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
const qs = params.toString();
@@ -103,4 +139,16 @@ export const issuedOrderDetailOptions = (id: string | undefined) =>
queryFn: () =>
jsonQuery<IssuedOrderDetail>(`/api/admin/issued-orders/${id}`),
enabled: !!id,
// 404s (deleted/missing orders) are not transient. Retrying just spams
// GETs and keeps the detail page on an infinite spinner.
retry: false,
});
export const issuedOrderNextNumberOptions = () =>
queryOptions({
queryKey: ["issued-orders", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
"/api/admin/issued-orders/next-number",
).then((d) => d?.next_number || d?.number || ""),
});

View File

@@ -9,6 +9,32 @@ export interface ApiError extends Error {
status?: number;
}
/**
* Full success envelope (`data` + server `message`) — the result shape of a
* mutation created with `envelope: true`. Used by the document detail pages
* (offers, issued orders) to pass the server's Czech success message through
* to the toast instead of a hardcoded client string.
*/
export interface ApiEnvelope<T> {
data?: T;
message?: string;
}
/**
* Czech-safe error message for toasts: returns the server-provided message
* from an `ApiError` (those carry `.status` and a Czech `error` string), and
* falls back when the message is a client-generated English string — network
* failures ("Failed to fetch" has no status), "Invalid JSON response",
* "Unauthorized" (401) or the generic "Request failed (NNN)".
*/
export function apiErrorMessage(err: unknown, fallback: string): string {
const status = (err as ApiError | null | undefined)?.status;
const message = err instanceof Error ? err.message : "";
if (!message || status === undefined || status === 401) return fallback;
if (/^Request failed \(\d+\)$/.test(message)) return fallback;
return message;
}
export type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE";
interface ApiResponseBody<T> {
@@ -22,6 +48,7 @@ async function performMutation<TIn, TOut>(opts: {
url: string | ((input: TIn) => string);
method: HttpMethod | ((input: TIn) => HttpMethod);
body?: TIn;
envelope?: boolean;
}): Promise<TOut> {
const url =
typeof opts.url === "function" ? opts.url(opts.body as TIn) : opts.url;
@@ -59,6 +86,9 @@ async function performMutation<TIn, TOut>(opts: {
throw err;
}
if (opts.envelope) {
return { data: result.data, message: result.message } as TOut;
}
return result.data as TOut;
}
@@ -67,6 +97,12 @@ export interface ApiMutationOptions<TIn, TOut> {
method: HttpMethod | ((input: TIn) => HttpMethod);
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
invalidate?: readonly string[];
/**
* When true, the mutation resolves with the full `ApiEnvelope` (`{ data,
* message }`) instead of the bare `data` — declare `TOut` as
* `ApiEnvelope<...>` accordingly.
*/
envelope?: boolean;
}
/**
@@ -89,12 +125,12 @@ export function useApiMutation<TIn, TOut>(
opts: ApiMutationOptions<TIn, TOut> &
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
) {
const { url, method, invalidate, onSuccess, ...rest } = opts;
const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;
const queryClient = useQueryClient();
return useMutation<TOut, ApiError, TIn, unknown>({
mutationFn: (input: TIn) =>
performMutation<TIn, TOut>({ url, method, body: input }),
performMutation<TIn, TOut>({ url, method, body: input, envelope }),
...rest,
onSuccess: (data, variables, onMutateResult, context) => {
if (invalidate) {

View File

@@ -52,7 +52,9 @@ export interface Customer {
export const offerCustomersOptions = () =>
queryOptions({
queryKey: ["offer-customers"],
// Under the broad ["offers"] domain so offer-domain invalidations
// (customer CRUD, offer mutations) refresh the picker automatically.
queryKey: ["offers", "customers"],
queryFn: () => jsonQuery<Customer[]>("/api/admin/customers"),
staleTime: 2 * 60_000,
});
@@ -70,6 +72,21 @@ export const scopeTemplatesOptions = () =>
queryFn: () => jsonQuery<ScopeTemplate[]>("/api/admin/offers-templates"),
});
/** Offer list row (the shape returned by GET /api/admin/offers). */
export interface Quotation {
id: number;
quotation_number: string;
project_code: string;
customer_name: string;
created_at: string;
valid_until: string;
currency: string;
total: number;
status: string;
order_id?: number;
order_status?: string;
}
export const offerListOptions = (filters: {
search?: string;
sort?: string;
@@ -92,7 +109,9 @@ export const offerListOptions = (filters: {
if (filters.customer_id)
params.set("customer_id", String(filters.customer_id));
const qs = params.toString();
return paginatedJsonQuery(`/api/admin/offers${qs ? `?${qs}` : ""}`);
return paginatedJsonQuery<Quotation>(
`/api/admin/offers${qs ? `?${qs}` : ""}`,
);
},
});
@@ -157,13 +176,13 @@ export interface OfferDetailData {
valid_until: string;
currency: string;
language: string;
vat_rate: number;
apply_vat: boolean;
items?: OfferItemData[];
sections?: OfferSectionData[];
status: string;
order: OfferOrderInfo | null;
locked_by: OfferLockInfo | null;
/** Legal next statuses, computed server-side (same contract as issued orders). */
valid_transitions: string[];
}
export const offerDetailOptions = (id: string | undefined) =>

View File

@@ -43,8 +43,6 @@ export interface OrderData {
status: string;
notes: string;
attachment_name?: string;
apply_vat: number | boolean;
vat_rate: number;
language?: string;
items: OrderItem[];
sections: OrderSection[];

View File

@@ -86,3 +86,13 @@ export const require2FAOptions = () =>
queryFn: () =>
jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"),
});
// Per-user 2FA enrollment status (users.totp_enabled) — NOT the company-wide
// require_2fa policy above. Mutations that enable/disable 2FA must invalidate
// the broad ["totp"] domain key.
export const totpStatusOptions = () =>
queryOptions({
queryKey: ["totp", "status"],
queryFn: () =>
jsonQuery<{ totp_enabled: boolean }>("/api/admin/totp/status"),
});

View File

@@ -1,5 +1,5 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export interface TripVehicle {
id: number;
@@ -59,7 +59,9 @@ export const tripListOptions = (filters: {
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
return paginatedJsonQuery<BackendTrip>(
`/api/admin/trips${qs ? `?${qs}` : ""}`,
);
},
});
@@ -81,6 +83,8 @@ export const tripHistoryOptions = (filters: {
month?: string;
vehicleId?: number;
userId?: number;
page?: number;
perPage?: number;
}) =>
queryOptions({
queryKey: [
@@ -90,6 +94,8 @@ export const tripHistoryOptions = (filters: {
month: filters.month,
vehicleId: filters.vehicleId,
userId: filters.userId,
page: filters.page,
perPage: filters.perPage,
},
],
queryFn: () => {
@@ -98,7 +104,53 @@ export const tripHistoryOptions = (filters: {
if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString();
return jsonQuery<BackendTrip[]>(`/api/admin/trips${qs ? `?${qs}` : ""}`);
return paginatedJsonQuery<BackendTrip>(
`/api/admin/trips${qs ? `?${qs}` : ""}`,
);
},
});
export interface TripStats {
count: number;
total_km: number;
business_km: number;
private_km: number;
}
// Count/km totals over the WHOLE filtered set (not one page) — hits
// /trips/stats with the same filters the lists use. `month` accepts either a
// numeric month (paired with `year`, TripsAdmin style) or a combined
// "YYYY-MM" string (TripsHistory style); the server supports both.
export const tripStatsOptions = (filters: {
month?: number | string;
year?: number;
vehicleId?: number;
userId?: number;
}) =>
queryOptions({
queryKey: [
"trips",
"stats",
{
month: filters.month,
year: filters.year,
vehicleId: filters.vehicleId,
userId: filters.userId,
},
],
queryFn: () => {
const params = new URLSearchParams();
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
const qs = params.toString();
return jsonQuery<TripStats>(
`/api/admin/trips/stats${qs ? `?${qs}` : ""}`,
);
},
});

View File

@@ -274,7 +274,8 @@ export default function Attendance() {
>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance"],
// dashboard included: the punch-button state + presence cards live there.
invalidate: ["attendance", "dashboard"],
});
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
@@ -300,7 +301,8 @@ export default function Attendance() {
>({
url: () => `${API_BASE}/leave-requests`,
method: () => "POST",
invalidate: ["attendance", "leave-requests", "leave", "users"],
// dashboard included: approvers see the pending-requests KPI there.
invalidate: ["attendance", "leave-requests", "leave", "users", "dashboard"],
});
const [submitting, setSubmitting] = useState(false);

View File

@@ -10,6 +10,7 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { useApiMutation } from "../lib/queries/mutations";
import { todayLocalStr } from "../utils/formatters";
import { combineDatetime } from "../utils/attendanceHelpers";
import {
Button,
Card,
@@ -41,6 +42,24 @@ interface CreateForm {
notes: string;
}
/**
* Wire payload for POST /attendance (CreateAttendanceSchema). The raw form
* above is NOT sent directly: its separate date+time inputs are combined into
* "YYYY-MM-DDTHH:MM:00" datetimes (same semantics as useAttendanceAdmin's
* create modal), and unset optional fields are null — never "".
*/
interface CreatePayload {
user_id: number;
shift_date: string;
leave_type: string;
notes: string | null;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
}
export default function AttendanceCreate() {
const alert = useAlert();
const { hasPermission } = useAuth();
@@ -52,10 +71,10 @@ export default function AttendanceCreate() {
}));
const [submitting, setSubmitting] = useState(false);
const createMutation = useApiMutation<CreateForm, { message?: string }>({
const createMutation = useApiMutation<CreatePayload, { message?: string }>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance", "users"],
invalidate: ["attendance", "users", "dashboard"],
});
const [form, setForm] = useState<CreateForm>(() => {
@@ -88,7 +107,36 @@ export default function AttendanceCreate() {
setSubmitting(true);
try {
const result = await createMutation.mutateAsync(form);
const isLeave = form.leave_type !== "work";
const payload: CreatePayload = {
user_id: Number(form.user_id),
shift_date: form.shift_date,
leave_type: form.leave_type,
notes: form.notes || null,
};
if (isLeave) {
payload.leave_hours = form.leave_hours || 8;
} else {
payload.arrival_time = combineDatetime(
form.arrival_date,
form.arrival_time,
);
payload.departure_time = combineDatetime(
form.departure_date,
form.departure_time,
);
payload.break_start = combineDatetime(
form.break_start_date,
form.break_start_time,
);
payload.break_end = combineDatetime(
form.break_end_date,
form.break_end_time,
);
}
const result = await createMutation.mutateAsync(payload);
alert.success(result?.message || "Uloženo");
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} catch (e) {

View File

@@ -8,7 +8,7 @@ import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import apiFetch from "../utils/api";
import { dashboardOptions } from "../lib/queries/dashboard";
import { require2FAOptions } from "../lib/queries/settings";
import { totpStatusOptions } from "../lib/queries/settings";
import { getCzechDate } from "../utils/dashboardHelpers";
import { useApiMutation } from "../lib/queries/mutations";
import { Card, Button, StatusChip, PageEnter } from "../ui";
@@ -86,9 +86,11 @@ export default function Dashboard() {
const { data: dashDataRaw, isPending: dashLoading } =
useQuery(dashboardOptions());
const dashData = dashDataRaw as DashData | undefined;
// Personal 2FA enrollment (users.totp_enabled) — NOT the company-wide
// require_2fa policy flag (the banner below uses user.require2FA for that).
const { data: totpData, isPending: totpLoading } =
useQuery(require2FAOptions());
const totpEnabled = totpData?.require_2fa ?? !!user?.totpEnabled;
useQuery(totpStatusOptions());
const totpEnabled = totpData?.totp_enabled ?? !!user?.totpEnabled;
const punchMutation = useApiMutation<
Record<string, unknown>,
@@ -176,6 +178,7 @@ export default function Dashboard() {
});
const data = await response.json();
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["totp"] });
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
@@ -206,6 +209,7 @@ export default function Dashboard() {
});
const data = await response.json();
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["totp"] });
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });

View File

@@ -51,7 +51,12 @@ import { invoiceDetailOptions } from "../lib/queries/invoices";
import { offerCustomersOptions } from "../lib/queries/offers";
import { bankAccountsOptions } from "../lib/queries/common";
import { jsonQuery } from "../lib/apiAdapter";
import { formatCurrency, formatDate, todayLocalStr } from "../utils/formatters";
import {
formatCurrency,
formatDate,
numberOr,
todayLocalStr,
} from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
import {
Button,
@@ -686,7 +691,7 @@ export default function InvoiceDetail() {
tax_date: normalizeDateStr(inv.tax_date),
currency: inv.currency || "CZK",
apply_vat: Number(inv.apply_vat) ? 1 : 0,
vat_rate: Number(inv.vat_rate) || 21,
vat_rate: numberOr(inv.vat_rate, 21),
payment_method: inv.payment_method || "Příkazem",
constant_symbol: inv.constant_symbol || "0308",
issued_by: inv.issued_by || "",
@@ -725,7 +730,11 @@ export default function InvoiceDetail() {
quantity: Number(item.quantity) || 1,
unit: item.unit || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(inv.vat_rate) || 21,
// 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
// from the invoice rate corrupted mixed-rate invoices on re-save.
vat_rate: numberOr(item.vat_rate, numberOr(inv.vat_rate, 21)),
}))
: [];
if (mappedItems.length > 0) {
@@ -778,11 +787,12 @@ export default function InvoiceDetail() {
}));
}
// Pre-fill from order
// Pre-fill from order. Orders no longer carry VAT (not tax documents) —
// the invoice decides its own VAT: default rate from company settings,
// apply_vat on.
if (fromOrderId && orderDataQuery.data) {
const order = orderDataQuery.data;
const vatRate =
Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21);
const vatRate = numberOr(companySettings?.default_vat_rate, 21);
setForm((prev) => ({
...prev,
customer_id: order.customer_id as number,
@@ -792,7 +802,7 @@ export default function InvoiceDetail() {
(order.currency as string) ||
companySettings?.default_currency ||
"CZK",
apply_vat: Number(order.apply_vat) || 0,
apply_vat: 1,
vat_rate: vatRate,
}));
const orderItems = order.items as Record<string, unknown>[] | undefined;

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,9 @@ import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -10,18 +12,21 @@ import {
formatCurrency,
formatDate,
formatMultiCurrency,
czechPlural,
} from "../utils/formatters";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
import { useDocumentListPdf } from "../hooks/useDocumentPdf";
import {
issuedOrderListOptions,
issuedOrderStatsOptions,
issuedOrderSuppliersOptions,
type IssuedOrder,
} from "../lib/queries/issued-orders";
import {
Button,
Card,
DataTable,
Pagination,
@@ -44,9 +49,15 @@ import {
const API_BASE = "/api/admin";
// DELIBERATE deviation from the Offers status-Tabs row: this page is embedded
// under the parent Orders page, which already renders the identical centered
// kit <Tabs> (Vydané/Přijaté) directly above this component — a second
// same-styled tab row would visually fight it (and the sibling "Přijaté" tab
// filters via a Select too). So the status filter stays a Select, with the
// "all" entry relabeled to match the offers tabs ("Všechny").
const STATUS_OPTIONS = statusOptions(ISSUED_ORDER_STATUS, {
value: "",
label: "Všechny stavy",
label: "Všechny",
});
const ViewIcon = (
@@ -62,6 +73,19 @@ const ViewIcon = (
<circle cx="12" cy="12" r="3" />
</svg>
);
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const PdfIcon = (
<svg
width="18"
@@ -107,11 +131,18 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [status, setStatus] = useState("");
const [supplierFilter, setSupplierFilter] = useState<number | "">("");
const [page, setPage] = useState(1);
// Track first successful load so later refetches (filter/status/page change)
// keep the table visible instead of flashing the full-page skeleton.
const hasLoadedOnce = useRef(false);
const { data: suppliers } = useQuery(issuedOrderSuppliersOptions());
// Shared hardened PDF flow (per-row spinner, double-click guard, 401 silent
// close, blob-URL revoke + unmount cleanup) — same hook as the Offers list.
const { openPdf, pdfLoadingId } = useDocumentListPdf();
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
order: IssuedOrder | null;
@@ -141,8 +172,8 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
sort,
order,
page,
perPage: 20,
status: status || undefined,
supplier_id: supplierFilter || undefined,
month,
year,
}),
@@ -154,6 +185,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
issuedOrderStatsOptions({
search: debouncedSearch,
status: status || undefined,
supplier_id: supplierFilter || undefined,
month,
year,
}),
@@ -176,30 +208,6 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
}
};
const handleExportPdf = async (o: IssuedOrder) => {
// Authenticated open: apiFetch attaches the access token, then we hand the
// resulting PDF blob to a window. A raw window.open(url) would be an
// unauthenticated top-level navigation and 401 on the token-guarded route.
const newWindow = window.open("", "_blank");
try {
const response = await apiFetch(
`${API_BASE}/issued-orders-pdf/${o.id}?lang=cs`,
);
if (!response.ok) {
newWindow?.close();
alert.error("Nepodařilo se vygenerovat PDF");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = url;
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch {
newWindow?.close();
alert.error("Chyba při generování PDF");
}
};
// Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/status/page change) keep the table visible (the Card
// dims via isFetching) so it doesn't flash.
@@ -229,10 +237,18 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
),
},
{
key: "customer_name",
key: "supplier_name",
header: "Dodavatel",
width: "24%",
render: (o) => o.customer_name || "—",
render: (o) => o.supplier_name || "—",
},
{
key: "order_date",
header: "Datum",
width: "13%",
sortKey: "order_date",
mono: true,
render: (o) => (o.order_date ? formatDate(o.order_date) : "—"),
},
{
key: "status",
@@ -246,14 +262,6 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
/>
),
},
{
key: "order_date",
header: "Datum",
width: "13%",
sortKey: "order_date",
mono: true,
render: (o) => (o.order_date ? formatDate(o.order_date) : "—"),
},
{
key: "total",
header: "Celkem",
@@ -268,39 +276,52 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
header: "Akce",
width: "14%",
align: "right",
render: (o) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => navigate(`/orders/issued/${o.id}`)}
aria-label={hasPermission("orders.edit") ? "Upravit" : "Detail"}
title={hasPermission("orders.edit") ? "Upravit" : "Detail"}
>
{ViewIcon}
</IconButton>
{hasPermission("orders.view") && (
render: (o) => {
// Offers' semantics: read-only rows (completed/cancelled) get the
// view icon, editable rows get the edit icon.
const readOnly = o.status === "completed" || o.status === "cancelled";
return (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => handleExportPdf(o)}
aria-label="PDF"
title="PDF"
onClick={() => navigate(`/orders/issued/${o.id}`)}
aria-label={readOnly ? "Zobrazit" : "Upravit"}
title={readOnly ? "Zobrazit" : "Upravit"}
>
{PdfIcon}
{readOnly ? ViewIcon : EditIcon}
</IconButton>
)}
{hasPermission("orders.delete") && (
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, order: o })}
aria-label="Smazat"
title="Smazat"
>
{DeleteIcon}
</IconButton>
)}
</Box>
),
{/* Drafts have no number yet — /file 404s for them. */}
{hasPermission("orders.view") && o.po_number && (
<IconButton
size="small"
onClick={() =>
openPdf(o.id, `${API_BASE}/issued-orders/${o.id}/file`)
}
aria-label="Zobrazit objednávku"
title="Zobrazit objednávku"
disabled={pdfLoadingId === o.id}
>
{pdfLoadingId === o.id ? (
<CircularProgress size={16} />
) : (
PdfIcon
)}
</IconButton>
)}
{hasPermission("orders.delete") && (
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, order: o })}
aria-label="Smazat"
title="Smazat"
>
{DeleteIcon}
</IconButton>
)}
</Box>
);
},
},
];
@@ -326,12 +347,25 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
return {};
};
// Search/status filter is active → an empty list means "nothing matches"
// (no create CTA); a genuinely empty list keeps the create-CTA empty state.
const isFiltered = !!debouncedSearch || !!status;
// Search/status/supplier filter is active → an empty list means "nothing
// matches" (no create CTA); a genuinely empty list keeps the create-CTA
// empty state.
const isFiltered = !!debouncedSearch || !!status || !!supplierFilter;
const total = pagination?.total ?? orders.length;
const countLine = `${total} ${czechPlural(
total,
"objednávka",
"objednávky",
"objednávek",
)}`;
return (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{countLine}
</Typography>
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
@@ -354,6 +388,22 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
options={STATUS_OPTIONS}
/>
</Box>
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={supplierFilter === "" ? "" : String(supplierFilter)}
onChange={(value) => {
setSupplierFilter(value ? Number(value) : "");
setPage(1);
}}
options={[
{ value: "", label: "Všichni dodavatelé" },
...(suppliers ?? []).map((s) => ({
value: String(s.id),
label: s.name,
})),
]}
/>
</Box>
</FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
@@ -367,14 +417,19 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
onSort={handleSort}
empty={
isFiltered ? (
<EmptyState title="Žádné objednávky neodpovídají filtru." />
<EmptyState
title="Žádné objednávky neodpovídají filtru"
description="Zkuste změnit filtr nebo hledaný výraz."
/>
) : (
<EmptyState
title="Zatím žádné vydané objednávky."
description={
hasPermission("orders.create")
? "Vytvořte první tlačítkem „Vytvořit objednávku."
: undefined
action={
hasPermission("orders.create") ? (
<Button component={RouterLink} to="/orders/issued/new">
Vytvořit objednávku
</Button>
) : undefined
}
/>
)
@@ -392,7 +447,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
fontSize: "0.9rem",
}}
>
<span>Celkem:</span>
<span>Celkem bez DPH:</span>
<Box
component="span"
sx={{ fontWeight: 700, color: "text.primary" }}
@@ -415,11 +470,10 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
title="Smazat objednávku"
message={
deleteConfirm.order
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.po_number || ""}"? Tato akce je nevratná.`
? `Opravdu chcete smazat objednávku „${documentNumberLabel(deleteConfirm.order.po_number)}“? Budou smazány i všechny položky. Tato akce je nevratná.`
: ""
}
confirmText="Smazat"
cancelText="Zrušit"
confirmVariant="danger"
loading={deleteMutation.isPending}
/>

View File

@@ -161,7 +161,7 @@ export default function LeaveApproval() {
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users"],
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
onSuccess: () => {
setApproveModal({ open: false, request: null });
alert.success("Žádost byla schválena");
@@ -174,7 +174,7 @@ export default function LeaveApproval() {
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users"],
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
onSuccess: () => {
setRejectModal({ open: false, request: null });
setRejectNote("");

File diff suppressed because it is too large Load Diff

View File

@@ -23,10 +23,12 @@ import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { useDocumentListPdf } from "../hooks/useDocumentPdf";
import {
offerListOptions,
offerStatsOptions,
offerCustomersOptions,
type Quotation,
} from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations";
import {
@@ -69,20 +71,6 @@ const STATUS_FILTERS = statusOptions(OFFER_STATUS, {
label: "Všechny",
}).filter((o) => o.value !== "expired");
interface Quotation {
id: number;
quotation_number: string;
project_code: string;
customer_name: string;
created_at: string;
valid_until: string;
currency: string;
total: number;
status: string;
order_id?: number;
order_status?: string;
}
const TemplatesIcon = (
<svg
width="20"
@@ -336,25 +324,17 @@ export default function Offers() {
show: boolean;
quotation: Quotation | null;
}>({ show: false, quotation: null });
const [deleting, setDeleting] = useState(false);
const [invalidateConfirm, setInvalidateConfirm] = useState<{
show: boolean;
quotation: Quotation | null;
}>({ show: false, quotation: null });
const [invalidating, setInvalidating] = useState(false);
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
const [duplicating, setDuplicating] = useState<number | null>(null);
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [creatingOrder, setCreatingOrder] = useState<number | null>(null);
// Shared hardened PDF flow (per-row spinner, double-click guard, 401 silent
// close, blob-URL revoke + unmount cleanup) — same hook as the issued list.
const { openPdf, pdfLoadingId } = useDocumentListPdf();
const [orderModal, setOrderModal] = useState<{
show: boolean;
quotation: Quotation | null;
@@ -490,13 +470,10 @@ export default function Offers() {
const handleDelete = async () => {
if (!deleteConfirm.quotation) return;
setDeleting(true);
try {
await deleteOfferMutation.mutateAsync(deleteConfirm.quotation.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
}
};
@@ -512,37 +489,6 @@ export default function Offers() {
}
};
const handlePdf = async (quotation: Quotation) => {
if (pdfLoading) return;
const newWindow = window.open("", "_blank");
setPdfLoading(quotation.id);
try {
const response = await apiFetch(
`${API_BASE}/offers/${quotation.id}/file`,
);
if (response.status === 401) {
newWindow?.close();
return;
}
if (!response.ok) {
newWindow?.close();
alert.error("PDF soubor nenalezen — otevřete nabídku a uložte ji");
return;
}
const blob = await response.blob();
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
}
blobUrlRef.current = URL.createObjectURL(blob);
if (newWindow) newWindow.location.href = blobUrlRef.current;
} catch {
newWindow?.close();
alert.error("Chyba připojení");
} finally {
setPdfLoading(null);
}
};
// Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/customer/tab/page change) keep the table visible (the
// Card dims via isFetching) so it doesn't flash.
@@ -573,6 +519,7 @@ export default function Offers() {
header: "Číslo",
width: "13%",
sortKey: "quotation_number",
mono: true,
render: (q) => (
<Box
component={RouterLink}
@@ -597,7 +544,7 @@ export default function Offers() {
{
key: "customer_name",
header: "Zákazník",
width: "13%",
width: "16%",
render: (q) => q.customer_name || "—",
},
{
@@ -637,17 +584,10 @@ export default function Offers() {
);
},
},
{
key: "currency",
header: "Měna",
width: "7%",
sortKey: "currency",
render: (q) => <StatusChip label={q.currency} color="default" />,
},
{
key: "total",
header: "Celkem",
width: "11%",
width: "12%",
align: "right",
mono: true,
bold: true,
@@ -693,7 +633,9 @@ export default function Offers() {
{OrderViewIcon}
</IconButton>
) : (
!readOnly &&
// Only active offers can spawn an order — the server transition
// guard rejects drafts/invalidated with a 400.
q.status === "active" &&
hasPermission("orders.create") && (
<IconButton
size="small"
@@ -714,15 +656,20 @@ export default function Offers() {
</IconButton>
)
)}
{hasPermission("offers.view") && (
{/* Drafts have no number yet — /file 404s for them. */}
{hasPermission("offers.view") && q.quotation_number && (
<IconButton
size="small"
onClick={() => handlePdf(q)}
onClick={() => openPdf(q.id, `${API_BASE}/offers/${q.id}/file`)}
aria-label="Zobrazit nabídku"
title="Zobrazit nabídku"
disabled={pdfLoading === q.id}
disabled={pdfLoadingId === q.id}
>
{pdfLoading === q.id ? <CircularProgress size={16} /> : PdfIcon}
{pdfLoadingId === q.id ? (
<CircularProgress size={16} />
) : (
PdfIcon
)}
</IconButton>
)}
{hasPermission("offers.delete") && (
@@ -906,7 +853,7 @@ export default function Offers() {
fontSize: "0.9rem",
}}
>
<span>Celkem:</span>
<span>Celkem bez DPH:</span>
<Box
component="span"
sx={{ fontWeight: 700, color: "text.primary" }}
@@ -927,10 +874,10 @@ export default function Offers() {
onClose={() => setDeleteConfirm({ show: false, quotation: null })}
onConfirm={handleDelete}
title="Smazat nabídku"
message={`Opravdu chcete smazat nabídku "${deleteConfirm.quotation?.quotation_number}"? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
message={`Opravdu chcete smazat nabídku ${documentNumberLabel(deleteConfirm.quotation?.quotation_number)}? Budou smazány i všechny položky a sekce. Tato akce je nevratná.`}
confirmText="Smazat"
confirmVariant="danger"
loading={deleting}
loading={deleteOfferMutation.isPending}
/>
<ConfirmDialog
@@ -938,7 +885,7 @@ export default function Offers() {
onClose={() => setInvalidateConfirm({ show: false, quotation: null })}
onConfirm={handleInvalidate}
title="Zneplatnit nabídku"
message={`Opravdu chcete zneplatnit nabídku "${invalidateConfirm.quotation?.quotation_number}"? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
message={`Opravdu chcete zneplatnit nabídku ${documentNumberLabel(invalidateConfirm.quotation?.quotation_number)}? Nabídka bude pouze pro čtení a nepůjde upravovat.`}
confirmText="Zneplatnit"
confirmVariant="danger"
loading={invalidating}

View File

@@ -198,7 +198,8 @@ export default function OffersCustomers() {
? `${API_BASE}/customers/${editingCustomer.id}`
: `${API_BASE}/customers`,
method: () => (editingCustomer ? "PUT" : "POST"),
invalidate: ["offer-customers", "offers"],
// Broad domain key — covers the ["offers","customers"] picker query too.
invalidate: ["offers"],
onSuccess: (data) => {
closeModal();
setTimeout(
@@ -214,7 +215,8 @@ export default function OffersCustomers() {
>({
url: (id) => `${API_BASE}/customers/${id}`,
method: () => "DELETE",
invalidate: ["offer-customers", "offers"],
// Broad domain key — covers the ["offers","customers"] picker query too.
invalidate: ["offers"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, customer: null });
alert.success(data?.message || "Zákazník byl smazán");

View File

@@ -144,9 +144,10 @@ export default function OrderDetail() {
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
// NET only — received orders are not tax documents (no VAT on them).
const totals = useMemo(() => {
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 };
const subtotal = order.items.reduce((sum, item) => {
if (!order?.items) return { total: 0 };
const total = order.items.reduce((sum, item) => {
if (Number(item.is_included_in_total)) {
return (
sum + (Number(item.quantity) || 0) * (Number(item.unit_price) || 0)
@@ -154,10 +155,7 @@ export default function OrderDetail() {
}
return sum;
}, 0);
const vatAmount = Number(order.apply_vat)
? subtotal * ((Number(order.vat_rate) || 0) / 100)
: 0;
return { subtotal, vatAmount, total: subtotal + vatAmount };
return { total };
}, [order]);
const statusMutation = useApiMutation<{ status: string }, unknown>({
@@ -236,14 +234,12 @@ export default function OrderDetail() {
const handleGenerateConfirmation = async (
lang: string,
applyVat: boolean,
customItems?: Array<{
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}>,
) => {
setConfirmationLoading(true);
@@ -253,7 +249,7 @@ export default function OrderDetail() {
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lang, applyVat, items: customItems }),
body: JSON.stringify({ lang, items: customItems }),
},
);
if (!response.ok) {
@@ -625,7 +621,7 @@ export default function OrderDetail() {
empty={<EmptyState title="Žádné položky." />}
/>
{/* Totals */}
{/* Totals (NET only — orders are not tax documents) */}
<Box
sx={{
mt: 2,
@@ -636,30 +632,6 @@ export default function OrderDetail() {
gap: 0.5,
}}
>
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="body2" color="text.secondary">
Mezisoučet:
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(totals.subtotal, order.currency)}
</Typography>
</Box>
{Number(order.apply_vat) > 0 && (
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant="body2" color="text.secondary">
DPH ({order.vat_rate}%):
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatCurrency(totals.vatAmount, order.currency)}
</Typography>
</Box>
)}
<Box
sx={{
display: "flex",
@@ -671,7 +643,7 @@ export default function OrderDetail() {
}}
>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
Celkem k úhradě:
Celkem bez DPH:
</Typography>
<Typography
variant="body2"
@@ -829,11 +801,8 @@ export default function OrderDetail() {
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
is_included_in_total: Number(it.is_included_in_total) !== 0,
vat_rate: Number(order.vat_rate) || 21,
}))}
orderNumber={order.order_number}
defaultVatRate={Number(order.vat_rate) || 21}
applyVat={!!order.apply_vat}
/>
)}
</PageEnter>

View File

@@ -172,7 +172,13 @@ export default function Projects() {
enabled: showCreate,
});
const createMutation = useApiMutation<typeof createForm, { id: number }>({
const createMutation = useApiMutation<
Omit<typeof createForm, "start_date" | "end_date"> & {
start_date: string | null;
end_date: string | null;
},
{ id: number }
>({
url: () => `${API_BASE}/projects`,
method: () => "POST",
invalidate: ["projects", "orders", "offers", "invoices", "attendance"],
@@ -204,7 +210,13 @@ export default function Projects() {
setCreating(true);
try {
await createMutation.mutateAsync(createForm);
// Server's isoDateString.nullish() accepts null but rejects "" —
// send empty date fields as null so the create doesn't 400.
await createMutation.mutateAsync({
...createForm,
start_date: createForm.start_date || null,
end_date: createForm.end_date || null,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {

View File

@@ -184,8 +184,6 @@ export default function OrdersReceived({
customer_id: "",
customer_order_number: "",
currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "",
scope_description: "",
notes: "",
@@ -219,8 +217,6 @@ export default function OrdersReceived({
customer_id: "",
customer_order_number: "",
currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "",
scope_description: "",
notes: "",
@@ -255,8 +251,6 @@ export default function OrdersReceived({
fd.append("customer_id", createForm.customer_id);
fd.append("customer_order_number", createForm.customer_order_number);
fd.append("currency", createForm.currency);
fd.append("vat_rate", createForm.vat_rate);
fd.append("apply_vat", createForm.apply_vat ? "1" : "0");
fd.append("scope_title", createForm.scope_title);
fd.append("scope_description", createForm.scope_description);
fd.append("notes", createForm.notes);
@@ -274,8 +268,6 @@ export default function OrdersReceived({
: null,
customer_order_number: createForm.customer_order_number,
currency: createForm.currency,
vat_rate: createForm.vat_rate,
apply_vat: createForm.apply_vat,
scope_title: createForm.scope_title,
scope_description: createForm.scope_description,
notes: createForm.notes,
@@ -571,7 +563,7 @@ export default function OrdersReceived({
fontSize: "0.9rem",
}}
>
<span>Celkem:</span>
<span>Celkem bez DPH:</span>
<Box
component="span"
sx={{ fontWeight: 700, color: "text.primary" }}
@@ -669,19 +661,6 @@ export default function OrdersReceived({
/>
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Sazba DPH (%)">
<TextField
type="number"
inputMode="numeric"
value={createForm.vat_rate}
onChange={(e) =>
setCreateForm({ ...createForm, vat_rate: e.target.value })
}
slotProps={{ htmlInput: { min: 0 } }}
/>
</Field>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
@@ -757,12 +736,6 @@ export default function OrdersReceived({
/>
</Field>
<CheckboxField
label="Účtovat DPH"
checked={createForm.apply_vat}
onChange={(v) => setCreateForm({ ...createForm, apply_vat: v })}
/>
<CheckboxField
label="Vytvořit propojený projekt"
checked={createForm.create_project}

View File

@@ -12,9 +12,11 @@ import { formatKm, todayLocalStr } from "../utils/formatters";
import apiFetch from "../utils/api";
import {
tripListOptions,
tripStatsOptions,
tripVehiclesOptions,
type BackendTrip,
} from "../lib/queries/trips";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
@@ -158,12 +160,20 @@ export default function Trips() {
const alert = useAlert();
const { hasPermission } = useAuth();
const { data: tripsData, isPending: tripsLoading } = useQuery(
tripListOptions({}),
);
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
// "Poslední jízdy" widget — only the 10 newest rows are shown, so fetch
// exactly those; the stat cards come from the server-side stats endpoint
// (computed over the whole filtered set, not one page).
const { items: trips, isPending: tripsLoading } =
usePaginatedQuery<BackendTrip>(tripListOptions({ perPage: 10 }));
const trips = tripsData ?? [];
// Stat cards are a personal "this month" summary — the header subtitle
// shows the current month, so the stats query is scoped to match it.
const now = new Date();
const { data: stats } = useQuery(
tripStatsOptions({ month: now.getMonth() + 1, year: now.getFullYear() }),
);
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData ?? [];
const [showModal, setShowModal] = useState(false);
@@ -321,19 +331,14 @@ export default function Trips() {
return <LoadingState />;
}
const totals = trips.reduce(
(acc, t) => {
const dist = t.distance ?? t.end_km - t.start_km;
acc.count++;
acc.total += dist;
if (t.is_business) acc.business += dist;
else acc.private += dist;
return acc;
},
{ total: 0, business: 0, private: 0, count: 0 },
);
const totals = {
count: stats?.count ?? 0,
total: stats?.total_km ?? 0,
business: stats?.business_km ?? 0,
private: stats?.private_km ?? 0,
};
const recentTrips = trips.slice(0, 10);
const recentTrips = trips;
const columns: DataColumn<BackendTrip>[] = [
{

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
@@ -9,11 +9,15 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import {
tripListOptions,
tripStatsOptions,
tripVehiclesOptions,
tripUsersOptions,
type BackendTrip,
type TripStats,
} from "../lib/queries/trips";
import { companySettingsOptions } from "../lib/queries/settings";
import { jsonQuery } from "../lib/apiAdapter";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import { useApiMutation } from "../lib/queries/mutations";
@@ -28,6 +32,7 @@ import {
Select,
DateField,
MonthField,
Pagination,
StatusChip,
FilterBar,
LoadingState,
@@ -83,6 +88,190 @@ function mapTrip(bt: BackendTrip): Trip {
};
}
/**
* Escape user-origin strings before interpolating them into the print HTML
* (the JSX hidden-div approach escaped automatically; the string template
* must do it explicitly).
*/
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
const PRINT_STYLES = `
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 10px;
line-height: 1.4;
color: #000;
background: #fff;
padding: 10mm;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #333;
}
.print-header-left { display: flex; align-items: center; gap: 12px; }
.print-logo { height: 40px; width: auto; }
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.summary {
display: flex;
justify-content: space-around;
margin-bottom: 15px;
padding: 10px;
background: #f5f5f5;
border: 1px solid #ddd;
}
.summary-item { text-align: center; }
.summary-value { font-size: 14px; font-weight: 700; }
.summary-label { font-size: 9px; color: #666; }
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; }
th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; }
td { font-size: 9px; }
tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
tfoot td { background: #eee; font-weight: 600; }
.badge {
display: inline-block;
padding: 1px 4px;
border-radius: 2px;
font-size: 8px;
font-weight: 500;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
@media print {
body { padding: 5mm; }
@page { size: A4 landscape; margin: 5mm; }
thead { display: table-header-group; }
}
`;
/**
* Build the full print document from the fetched /trips/print data set —
* no hidden-div/innerHTML round-trip, so there is nothing to race against
* React's commit.
*/
function buildPrintHtml(opts: {
trips: Trip[];
totals: TripStats;
periodName: string;
companyName: string;
vehicleName: string | null;
userName: string | null;
logoUrl: string;
}): string {
const { trips, totals } = opts;
const rows = trips
.map(
(trip) => `
<tr>
<td>${formatDate(trip.trip_date)}</td>
<td>${escapeHtml(trip.driver_name)}</td>
<td>${escapeHtml(trip.spz)}</td>
<td>${escapeHtml(trip.route_from)} &rarr; ${escapeHtml(trip.route_to)}</td>
<td class="text-right">${formatKm(trip.start_km)} - ${formatKm(trip.end_km)}</td>
<td class="text-right"><strong>${formatKm(trip.distance)} km</strong></td>
<td class="text-center">
<span class="badge ${trip.is_business ? "badge-success" : "badge-warning"}">
${trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>${escapeHtml(trip.notes || "")}</td>
</tr>`,
)
.join("");
return `
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kniha jízd - ${escapeHtml(opts.periodName)}</title>
<style>${PRINT_STYLES}</style>
</head>
<body>
<div class="print-header">
<div class="print-header-left">
<img src="${opts.logoUrl}" alt="" class="print-logo" />
<div class="print-header-text">
<h1>KNIHA JÍZD</h1>
<div class="company">${escapeHtml(opts.companyName)}</div>
</div>
</div>
<div class="print-header-right">
<div class="period">${escapeHtml(opts.periodName)}</div>
${opts.vehicleName ? `<div class="filters">Vozidlo: ${escapeHtml(opts.vehicleName)}</div>` : ""}
${opts.userName ? `<div class="filters">Řidič: ${escapeHtml(opts.userName)}</div>` : ""}
<div class="generated">Vygenerováno: ${new Date().toLocaleString("cs-CZ")}</div>
</div>
</div>
<div class="summary">
<div class="summary-item">
<div class="summary-value">${totals.count}</div>
<div class="summary-label">Počet jízd</div>
</div>
<div class="summary-item">
<div class="summary-value">${formatKm(totals.total_km)} km</div>
<div class="summary-label">Celkem</div>
</div>
<div class="summary-item">
<div class="summary-value">${formatKm(totals.business_km)} km</div>
<div class="summary-label">Služební</div>
</div>
<div class="summary-item">
<div class="summary-value">${formatKm(totals.private_km)} km</div>
<div class="summary-label">Soukromé</div>
</div>
</div>
<table>
<thead>
<tr>
<th style="width: 70px">Datum</th>
<th style="width: 80px">Řidič</th>
<th style="width: 70px">Vozidlo</th>
<th>Trasa</th>
<th style="width: 70px" class="text-right">Stav km</th>
<th style="width: 60px" class="text-right">Vzdálenost</th>
<th style="width: 55px" class="text-center">Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>${rows}</tbody>
<tfoot>
<tr>
<td colspan="5" class="text-right">Celkem:</td>
<td class="text-right"><strong>${formatKm(totals.total_km)} km</strong></td>
<td colspan="2"></td>
</tr>
</tfoot>
</table>
</body>
</html>
`;
}
const PrintIcon = (
<svg
width="18"
@@ -188,7 +377,7 @@ export default function TripsAdmin() {
});
const [filterVehicleId, setFilterVehicleId] = useState("");
const [filterUserId, setFilterUserId] = useState("");
const printRef = useRef<HTMLDivElement>(null);
const [page, setPage] = useState(1);
const [showEditModal, setShowEditModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
@@ -217,16 +406,27 @@ export default function TripsAdmin() {
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName = companySettings?.company_name ?? "";
const { data: tripsData, isPending } = useQuery(
tripListOptions({
month: Number(filterPeriod.slice(5, 7)) || undefined,
year: Number(filterPeriod.slice(0, 4)) || undefined,
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
userId: filterUserId ? Number(filterUserId) : undefined,
perPage: 100,
}),
);
const trips = (tripsData ?? []).map(mapTrip);
// ONE shared filter object for the list, stats AND print requests so the
// three can never drift apart. An empty MonthField value yields
// month/year = undefined (omitted from the request), mirroring the
// `Number(...) || undefined` guard the queries always used.
const filters = {
month: Number(filterPeriod.slice(5, 7)) || undefined,
year: Number(filterPeriod.slice(0, 4)) || undefined,
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
userId: filterUserId ? Number(filterUserId) : undefined,
};
const {
items: tripsItems,
pagination,
isPending,
} = usePaginatedQuery<BackendTrip>(tripListOptions({ ...filters, page }));
const trips = tripsItems.map(mapTrip);
// Stat cards: totals over the WHOLE filtered set (server-side aggregate),
// not just the visible page.
const { data: stats } = useQuery(tripStatsOptions(filters));
// useApiMutation JSON.stringifies the whole TIn as the request body, so
// TIn must match the backend schema (UpdateTripSchema) shape directly —
@@ -318,10 +518,12 @@ export default function TripsAdmin() {
};
const getPeriodName = () =>
new Date(
Number(filterPeriod.slice(0, 4)),
Number(filterPeriod.slice(5, 7)) - 1,
).toLocaleString("cs-CZ", { month: "long", year: "numeric" });
filterPeriod
? new Date(
Number(filterPeriod.slice(0, 4)),
Number(filterPeriod.slice(5, 7)) - 1,
).toLocaleString("cs-CZ", { month: "long", year: "numeric" })
: "Všechna období";
const getSelectedVehicleName = () => {
if (!filterVehicleId) return null;
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
@@ -333,94 +535,61 @@ export default function TripsAdmin() {
return u?.name || null;
};
const handlePrint = () => {
const periodName = getPeriodName();
const handlePrint = async () => {
// Open the print window SYNCHRONOUSLY, inside the click's transient
// activation — calling window.open after the network await can fall
// outside the activation window and get popup-blocked (which previously
// failed silently).
const printWindow = window.open("", "_blank");
if (!printWindow) {
alert.error("Tisk byl zablokován prohlížečem");
return;
}
setTimeout(() => {
if (printRef.current) {
const content = printRef.current.innerHTML;
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kniha jízd - ${periodName}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 10px;
line-height: 1.4;
color: #000;
background: #fff;
padding: 10mm;
}
.print-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #333;
}
.print-header-left { display: flex; align-items: center; gap: 12px; }
.print-logo { height: 40px; width: auto; }
.print-header-text { text-align: left; }
.print-header-right { text-align: right; }
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
.print-header .company { font-size: 11px; color: #666; }
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
.print-header .filters { font-size: 10px; color: #666; }
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
.summary {
display: flex;
justify-content: space-around;
margin-bottom: 15px;
padding: 10px;
background: #f5f5f5;
border: 1px solid #ddd;
}
.summary-item { text-align: center; }
.summary-value { font-size: 14px; font-weight: 700; }
.summary-label { font-size: 9px; color: #666; }
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
th, td { border: 1px solid #333; padding: 4px 6px; text-align: left; }
th { background: #333; color: #fff; font-weight: 600; font-size: 9px; text-transform: uppercase; }
td { font-size: 9px; }
tr:nth-child(even) { background: #f9f9f9; }
.text-center { text-align: center; }
.text-right { text-align: right; }
tfoot td { background: #eee; font-weight: 600; }
.badge {
display: inline-block;
padding: 1px 4px;
border-radius: 2px;
font-size: 8px;
font-weight: 500;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-warning { background: #fef3c7; color: #d97706; }
@media print {
body { padding: 5mm; }
@page { size: A4 landscape; margin: 5mm; }
thead { display: table-header-group; }
}
</style>
</head>
<body>
${content}
</body>
</html>
`);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
}
}, 100);
// Fetch the FULL filtered set for the printout — the list query is
// paginated, so printing from it would truncate to the visible page.
let printTrips: Trip[];
let printTotals: TripStats;
try {
const params = new URLSearchParams();
if (filters.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year));
if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId));
const data = await jsonQuery<{ trips: BackendTrip[]; totals: TripStats }>(
`${API_BASE}/trips/print?${params.toString()}`,
);
printTrips = data.trips.map(mapTrip);
printTotals = data.totals;
} catch (e) {
printWindow.close();
console.error("Trip print data fetch failed:", e);
alert.error(e instanceof Error ? e.message : "Chyba připojení");
return;
}
// The user closed the popup while the data was loading — a deliberate
// cancel, not an error.
if (printWindow.closed) return;
// The document is built directly from the fetched data (no hidden-div /
// setTimeout round-trip), so it can be neither unmounted nor stale.
printWindow.document.write(
buildPrintHtml({
trips: printTrips,
totals: printTotals,
periodName: getPeriodName(),
companyName,
vehicleName: getSelectedVehicleName(),
userName: getSelectedUserName(),
logoUrl: `${window.location.origin}/api/admin/company-settings/logo?variant=light`,
}),
);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
};
const calculateDistance = (): number => {
@@ -430,11 +599,9 @@ export default function TripsAdmin() {
};
const totals = {
count: trips.length,
total: trips.reduce((sum, t) => sum + t.distance, 0),
business: trips
.filter((t) => Number(t.is_business))
.reduce((sum, t) => sum + t.distance, 0),
count: stats?.count ?? 0,
total: stats?.total_km ?? 0,
business: stats?.business_km ?? 0,
};
const columns: DataColumn<Trip>[] = [
@@ -541,7 +708,7 @@ export default function TripsAdmin() {
>
<Typography variant="h4">Správa knihy jízd</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{trips.length > 0 && (
{(pagination?.total ?? 0) > 0 && (
<Button
variant="outlined"
color="inherit"
@@ -566,12 +733,21 @@ export default function TripsAdmin() {
{/* Filters */}
<FilterBar>
<Box sx={{ flex: "0 0 180px" }}>
<MonthField value={filterPeriod} onChange={setFilterPeriod} />
<MonthField
value={filterPeriod}
onChange={(val) => {
setFilterPeriod(val);
setPage(1);
}}
/>
</Box>
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={filterVehicleId}
onChange={setFilterVehicleId}
onChange={(value) => {
setFilterVehicleId(value);
setPage(1);
}}
options={[
{ value: "", label: "Všechna vozidla" },
...vehicles.map((v) => ({
@@ -584,7 +760,10 @@ export default function TripsAdmin() {
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={filterUserId}
onChange={setFilterUserId}
onChange={(value) => {
setFilterUserId(value);
setPage(1);
}}
options={[
{ value: "", label: "Všichni řidiči" },
...tripUsers.map((u) => ({
@@ -632,14 +811,21 @@ export default function TripsAdmin() {
{isPending ? (
<LoadingState />
) : (
<DataTable<Trip>
columns={columns}
rows={trips}
rowKey={(trip) => trip.id}
empty={
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
/>
<>
<DataTable<Trip>
columns={columns}
rows={trips}
rowKey={(trip) => trip.id}
empty={
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</>
)}
</Card>
@@ -796,120 +982,6 @@ export default function TripsAdmin() {
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
{/* Hidden Print Content */}
{trips.length > 0 && (
<div ref={printRef} style={{ display: "none" }}>
<div className="print-header">
<div className="print-header-left">
<img
src="/api/admin/company-settings/logo?variant=light"
alt=""
className="print-logo"
/>
<div className="print-header-text">
<h1>KNIHA JÍZD</h1>
<div className="company">{companyName}</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{getPeriodName()}</div>
{getSelectedVehicleName() && (
<div className="filters">
Vozidlo: {getSelectedVehicleName()}
</div>
)}
{getSelectedUserName() && (
<div className="filters">Řidič: {getSelectedUserName()}</div>
)}
<div className="generated">
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
</div>
</div>
</div>
<div className="summary">
<div className="summary-item">
<div className="summary-value">{totals.count}</div>
<div className="summary-label">Počet jízd</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.total)} km</div>
<div className="summary-label">Celkem</div>
</div>
<div className="summary-item">
<div className="summary-value">
{formatKm(totals.business)} km
</div>
<div className="summary-label">Služební</div>
</div>
<div className="summary-item">
<div className="summary-value">
{formatKm(totals.total - totals.business)} km
</div>
<div className="summary-label">Soukromé</div>
</div>
</div>
<table>
<thead>
<tr>
<th style={{ width: "70px" }}>Datum</th>
<th style={{ width: "80px" }}>Řidič</th>
<th style={{ width: "70px" }}>Vozidlo</th>
<th>Trasa</th>
<th style={{ width: "70px" }} className="text-right">
Stav km
</th>
<th style={{ width: "60px" }} className="text-right">
Vzdálenost
</th>
<th style={{ width: "55px" }} className="text-center">
Typ
</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td>{formatDate(trip.trip_date)}</td>
<td>{trip.driver_name}</td>
<td>{trip.spz}</td>
<td>
{trip.route_from} &rarr; {trip.route_to}
</td>
<td className="text-right">
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</td>
<td className="text-right">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td className="text-center">
<span
className={`badge ${trip.is_business ? "badge-success" : "badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>{trip.notes || ""}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={5} className="text-right">
Celkem:
</td>
<td className="text-right">
<strong>{formatKm(totals.total)} km</strong>
</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
</div>
)}
</PageEnter>
);
}

View File

@@ -5,12 +5,19 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips";
import {
tripHistoryOptions,
tripStatsOptions,
tripVehiclesOptions,
type BackendTrip,
} from "../lib/queries/trips";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
Card,
DataTable,
Select,
MonthField,
Pagination,
StatusChip,
PageHeader,
PageEnter,
@@ -91,18 +98,35 @@ export default function TripsHistory() {
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [vehicleId, setVehicleId] = useState("");
const [page, setPage] = useState(1);
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData;
const { data: tripsData, isPending } = useQuery(
const {
items: tripsData,
pagination,
isPending,
} = usePaginatedQuery<BackendTrip>(
tripHistoryOptions({
month,
vehicleId: vehicleId ? Number(vehicleId) : undefined,
userId: user?.id,
page,
}),
);
// Stat cards: month totals over the WHOLE filtered set (server-side
// aggregate over the same filters), not just the visible page.
const { data: stats } = useQuery(
tripStatsOptions({
month,
vehicleId: vehicleId ? Number(vehicleId) : undefined,
userId: user?.id,
}),
);
const trips: Trip[] = (tripsData ?? []).map((t) => ({
const trips: Trip[] = tripsData.map((t) => ({
id: t.id,
trip_date: t.trip_date,
spz: t.vehicles?.spz ?? "",
@@ -118,14 +142,11 @@ export default function TripsHistory() {
notes: t.notes ?? undefined,
}));
const totals = trips.reduce(
(acc, t) => ({
total: acc.total + (t.distance || 0),
business: acc.business + (t.is_business ? t.distance || 0 : 0),
count: acc.count + 1,
}),
{ total: 0, business: 0, count: 0 },
);
const totals = {
count: stats?.count ?? 0,
total: stats?.total_km ?? 0,
business: stats?.business_km ?? 0,
};
if (!hasPermission("trips.history")) return <Forbidden />;
@@ -221,12 +242,21 @@ export default function TripsHistory() {
{/* Filters */}
<FilterBar>
<Box sx={{ flex: "0 0 180px" }}>
<MonthField value={month} onChange={(val) => setMonth(val)} />
<MonthField
value={month}
onChange={(val) => {
setMonth(val);
setPage(1);
}}
/>
</Box>
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={vehicleId}
onChange={(value) => setVehicleId(value)}
onChange={(value) => {
setVehicleId(value);
setPage(1);
}}
options={[
{ value: "", label: "Všechna vozidla" },
...vehicles.map((v) => ({
@@ -275,14 +305,21 @@ export default function TripsHistory() {
{isPending ? (
<LoadingState />
) : (
<DataTable<Trip>
columns={columns}
rows={trips}
rowKey={(trip) => trip.id}
empty={
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
/>
<>
<DataTable<Trip>
columns={columns}
rows={trips}
rowKey={(trip) => trip.id}
empty={
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</>
)}
</Card>
</PageEnter>

View File

@@ -59,6 +59,11 @@ interface ItemDetail extends WarehouseItem {
const API_BASE = "/api/admin/warehouse/items";
// Must match UnitEnum in src/schemas/warehouse.schema.ts — the server rejects
// any other value (CreateItemSchema/UpdateItemSchema), so free text here would
// make the save 400.
const UNIT_OPTIONS = ["ks", "m", "kg", "bal", "sada", "m2", "m3", "l"] as const;
interface ItemForm {
item_number: string;
name: string;
@@ -449,15 +454,14 @@ export default function WarehouseItemDetail() {
</Select>
</Field>
<Field label="Jednotka" required error={errors.unit}>
<TextField
<Select
value={form.unit}
error={!!errors.unit}
onChange={(e) => {
updateForm("unit", e.target.value);
onChange={(v) => {
updateForm("unit", v);
setErrors((prev) => ({ ...prev, unit: "" }));
}}
placeholder="ks, m, kg..."
fullWidth
options={UNIT_OPTIONS.map((u) => ({ value: u, label: u }))}
/>
</Field>
<Field

View File

@@ -4,10 +4,12 @@ import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Link from "@mui/material/Link";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { apiFetch } from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
warehouseReceiptDetailOptions,
@@ -161,6 +163,33 @@ export default function WarehouseReceiptDetail() {
}
};
// A plain <a href> cannot send the Authorization header, so attachments are
// fetched via apiFetch and handed to the browser as a temporary blob URL.
const handleDownloadAttachment = async (att: WarehouseReceiptAttachment) => {
if (!receipt) return;
try {
const response = await apiFetch(
`/api/admin/warehouse/receipts/${receipt.id}/attachments/${att.id}`,
);
if (!response.ok) {
const result = await response.json().catch(() => ({}));
alert.error(result.error || "Nepodařilo se stáhnout přílohu");
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = att.file_name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 60000);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const confirming = confirmMutation.isPending;
const cancelling = cancelMutation.isPending;
@@ -249,13 +278,14 @@ export default function WarehouseReceiptDetail() {
width: "45%",
bold: true,
render: (att) => (
<a
href={`/api/admin/warehouse/receipts/${r.id}/attachments/${att.id}`}
target="_blank"
rel="noopener noreferrer"
<Link
component="button"
type="button"
onClick={() => handleDownloadAttachment(att)}
sx={{ textAlign: "left", font: "inherit" }}
>
{att.file_name}
</a>
</Link>
),
},
{

View File

@@ -137,7 +137,9 @@ export default function WarehouseSuppliers() {
url: () =>
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
method: () => (editingSupplier ? "PUT" : "POST"),
invalidate: ["warehouse"],
// issued-orders included: the PO form's supplier picker caches under
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
invalidate: ["warehouse", "issued-orders"],
onSuccess: (data) => {
setShowModal(false);
alert.success(data?.message || "Dodavatel byl uložen");
@@ -147,7 +149,9 @@ export default function WarehouseSuppliers() {
const deleteMutation = useApiMutation<number, { message?: string }>({
url: (id) => `${API_BASE}/${id}`,
method: () => "DELETE",
invalidate: ["warehouse"],
// issued-orders included: the PO form's supplier picker caches under
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
invalidate: ["warehouse", "issued-orders"],
onSuccess: (data) => {
setDeactivateConfirm({ show: false, supplier: null });
alert.success(data?.message || "Dodavatel byl smazán");
@@ -163,7 +167,9 @@ export default function WarehouseSuppliers() {
>({
url: ({ id }) => `${API_BASE}/${id}`,
method: () => "PUT",
invalidate: ["warehouse"],
// issued-orders included: the PO form's supplier picker caches under
// ["issued-orders","suppliers"] and must see supplier CRUD immediately.
invalidate: ["warehouse", "issued-orders"],
});
if (!hasPermission("warehouse.manage")) return <Forbidden />;

View File

@@ -0,0 +1,88 @@
import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete";
import MuiTextField from "@mui/material/TextField";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import type { Supplier } from "../lib/queries/issued-orders";
interface SupplierPickerProps {
suppliers: Supplier[];
/** Selected supplier id (FK), or null when none chosen. */
value: number | null;
onChange: (id: number | null) => void;
disabled?: boolean;
/**
* When set, draws the input in its error state (red border). The error TEXT
* is owned by the surrounding <Field> wrapper, so we deliberately do NOT
* render helperText here to avoid duplicating the message.
*/
error?: string;
placeholder?: string;
/** Optional id forwarded by <Field> for label association. */
id?: string;
}
// Search matches the supplier name AND the IČO, so typing either finds the
// record. Match on a stringified "name + ico" so MUI's default
// case-insensitive substring filtering covers both.
const filterSuppliers = createFilterOptions<Supplier>({
stringify: (s) => `${s.name} ${s.ico ?? ""}`,
});
/**
* Searchable supplier picker for issued orders (purchase orders) — a
* controlled MUI Autocomplete over the sklad_suppliers lookup list, bound to
* the supplier id. Modeled on CustomerPicker (offers/invoices keep that one);
* renders bare (no label/error text); wrap it in the page's
* <Field label error> for the label + error line.
*/
export default function SupplierPicker({
suppliers,
value,
onChange,
disabled,
error,
placeholder,
id,
}: SupplierPickerProps) {
const selected = suppliers.find((s) => s.id === value) ?? null;
return (
<Autocomplete<Supplier>
options={suppliers}
value={selected}
onChange={(_, opt) => onChange(opt ? opt.id : null)}
getOptionLabel={(s) => s?.name ?? ""}
isOptionEqualToValue={(o, v) => o.id === v.id}
filterOptions={filterSuppliers}
disabled={disabled}
size="small"
fullWidth
autoHighlight
renderOption={(props, s) => {
const { key, ...rest } = props as typeof props & { key: string };
return (
<Box component="li" key={key} {...rest}>
<Box>
{s.name}
{s.ico && (
<Typography
variant="caption"
sx={{ display: "block", color: "text.secondary" }}
>
: {s.ico}
</Typography>
)}
</Box>
</Box>
);
}}
renderInput={(params) => (
<MuiTextField
{...params}
id={id}
placeholder={placeholder ?? "Vyberte dodavatele…"}
error={!!error}
/>
)}
/>
);
}

View File

@@ -10,6 +10,7 @@ export { Field, SwitchField } from "./Field";
export { default as Select } from "./Select";
export type { SelectOption } from "./Select";
export { default as CustomerPicker } from "./CustomerPicker";
export { default as SupplierPicker } from "./SupplierPicker";
export { default as StatusChip } from "./StatusChip";
export { CheckboxField } from "./Checkbox";
export { default as Alert } from "./Alert";

View File

@@ -110,6 +110,16 @@ export const getTimePart = (datetime: string | null | undefined): string => {
return extractTime(datetime);
};
/**
* Join a date input ("YYYY-MM-DD") and a time input ("HH:MM") into the
* combined local-datetime wire format the attendance API expects
* ("YYYY-MM-DDTHH:MM:00", validated server-side by `nullableDateTimeString`
* and parsed with `new Date(...)` as local time). Returns null when either
* part is unset — "no value" for the optional datetime fields.
*/
export const combineDatetime = (date: string, time: string): string | null =>
date && time ? `${date}T${time}:00` : null;
export const calcProjectMinutesTotal = (
logs: Array<{
project_id?: number;

View File

@@ -50,6 +50,18 @@ export function todayLocalStr(): string {
return `${d.getFullYear()}-${m}-${day}`;
}
/**
* Coerce a server-returned numeric (number | decimal-string | null/undefined)
* to a number, falling back ONLY when the value is missing or not numeric —
* NEVER on a legitimate 0 (e.g. a 0% VAT rate / reverse charge). The
* `Number(x) || fallback` idiom silently turns stored zeros into the fallback.
*/
export function numberOr(raw: unknown, fallback: number): number {
if (raw == null || raw === "") return fallback;
const n = Number(raw);
return Number.isFinite(n) ? n : fallback;
}
export function formatKm(km: number | string): string {
return new Intl.NumberFormat("cs-CZ").format(Number(km) || 0);
}

View File

@@ -219,7 +219,14 @@ export default async function attendanceRoutes(
}
// --- Default: paginated records list ---
const { page, limit, skip, order } = parsePagination(query);
// maxLimit must allow a COMPLETE month: the admin month view fetches all
// records at once and computes the KPI cards / summary totals client-side
// (useAttendanceAdmin). With the default 100-row cap, months with more
// than 100 records were silently truncated (newest-first), so the screen
// totals dropped the earliest days while the print stayed complete.
const { page, limit, skip, order } = parsePagination(query, {
maxLimit: 2000,
});
const isAdmin = authData.permissions.includes("attendance.manage");
const parsedUserId = query.user_id ? Number(query.user_id) : undefined;
const userId =
@@ -418,6 +425,8 @@ export default async function attendanceRoutes(
departure_lng: body.departure_lng,
departure_accuracy: body.departure_accuracy,
departure_address: body.departure_address,
break_start: body.break_start,
break_end: body.break_end,
notes: body.notes,
project_id: body.project_id,
leave_type: body.leave_type,

View File

@@ -11,15 +11,16 @@ export default async function dashboardRoutes(
): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const now = new Date();
// shift_date is @db.Date: Prisma truncates a filter Date to its UTC date
// part, so these boundaries MUST be UTC-midnight instants of the LOCAL
// (Prague) calendar day. A local-midnight Date (new Date(y, m, d)) is
// 22:00/23:00 UTC of the PREVIOUS day and silently shifts the window to
// [yesterday, today) — the "Docházka dnes" card then matches zero rows.
const todayStart = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()),
);
const todayEnd = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate() + 1,
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() + 1),
);
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);

View File

@@ -2,12 +2,18 @@ import { FastifyInstance } from "fastify";
import QRCode from "qrcode";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { nasInvoicesManager } from "../../services/nas-financials-manager";
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 {
formatDate,
formatNum,
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
@@ -16,62 +22,6 @@ const DOMPurify = createDOMPurify(window);
/* ── Helpers ─────────────────────────────────────────────────────── */
function formatDate(date: Date | string | null | undefined): string {
if (!date) return "";
const d = new Date(date);
if (isNaN(d.getTime())) return String(date);
return localDateCzStr(d);
}
function formatNum(n: number, decimals = 2): string {
const abs = Math.abs(n);
const fixed = abs.toFixed(decimals);
const [intPart, decPart] = fixed.split(".");
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0");
const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result;
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return "";
let s = html;
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
"",
);
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
"",
);
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
s = s.replace(
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'href="#"',
);
s = s.replace(
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'src=""',
);
s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
let prev = "";
while (prev !== s) {
prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
}
return s;
}
interface AddressResult {
name: string;
lines: string[];
@@ -535,13 +485,7 @@ export default async function invoicesPdfRoutes(
: "";
// Quill indent CSS
let indentCSS = "";
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
}
const indentCSS = buildQuillIndentCss();
const html = `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}">

View File

@@ -2,65 +2,23 @@ import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { parseId, success } from "../../utils/response";
import { localDateCzStr } from "../../utils/date";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { nasOrdersManager } from "../../services/nas-financials-manager";
import {
formatDate,
formatNum,
formatCurrency,
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
/* ── Helpers (copied from orders-pdf.ts — the shared confirmation template) ── */
function formatDate(date: Date | string | null | undefined): string {
if (!date) return "";
const d = new Date(date);
if (isNaN(d.getTime())) return String(date);
return localDateCzStr(d);
}
function formatNum(n: number, decimals = 2): string {
const abs = Math.abs(n);
const fixed = abs.toFixed(decimals);
const [intPart, decPart] = fixed.split(".");
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result;
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return "";
let s = html;
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
"",
);
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
"",
);
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
let prev = "";
while (prev !== s) {
prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
}
return s;
}
/* ── Helpers — shared with the other PDF routes via utils/pdf-shared ── */
interface AddressResult {
name: string;
@@ -156,6 +114,31 @@ function buildAddressLines(
return { name, lines };
}
/**
* Address block for the sklad_suppliers counterparty. Unlike customers (which
* have structured street/city/postal columns), suppliers.address is a single
* Text blob — split it on newlines into one rendered line each (a blob without
* newlines renders as one line). IČO/DIČ come from the supplier's ico/dic
* columns, prefixed with the same translated labels the customer block used.
*/
function buildSupplierLines(
supplier: Record<string, unknown> | null,
tObj: Record<string, string>,
): AddressResult {
if (!supplier) return { name: "", lines: [] };
const name = String(supplier.name || "");
const lines: string[] = [];
if (supplier.address) {
for (const part of String(supplier.address).split(/\r?\n/)) {
const line = part.trim();
if (line) lines.push(line);
}
}
if (supplier.ico) lines.push(`${tObj.ico}${supplier.ico}`);
if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`);
return { name, lines };
}
/* ── Translations ────────────────────────────────────────────────── */
type Lang = "cs" | "en";
@@ -165,27 +148,18 @@ const translations: Record<Lang, Record<string, string>> = {
title: "OBJEDNÁVKA",
heading: "OBJEDNÁVKA č.",
// PO direction: WE (company) are the buyer (Odběratel), the
// selected customer record is the supplier (Dodavatel).
// selected sklad_suppliers record is the supplier (Dodavatel).
supplier: "Dodavatel",
buyer: "Odběratel",
issue_date: "Datum vystavení:",
delivery_date: "Požadované dodání:",
billing: "Objednáváme u Vás:",
billing: "Objednáváme si u Vás:",
col_no: "Č.",
col_desc: "Popis",
col_qty: "Množství",
col_unit_price: "Jedn. cena",
col_price: "Cena",
col_vat_pct: "%DPH",
col_vat: "DPH",
col_total: "Celkem",
subtotal: "Mezisoučet:",
vat_label: "DPH",
total: "Celkem",
amounts_in: "Částky jsou uvedeny v",
notes: "Poznámky",
delivery_terms: "Dodací podmínky:",
payment_terms: "Platební podmínky:",
total_no_vat: "Celkem bez DPH",
issued_by: "Vystavil:",
ico: "IČ: ",
dic: "DIČ: ",
@@ -202,17 +176,8 @@ const translations: Record<Lang, Record<string, string>> = {
col_desc: "Description",
col_qty: "Quantity",
col_unit_price: "Unit price",
col_price: "Price",
col_vat_pct: "VAT%",
col_vat: "VAT",
col_total: "Total",
subtotal: "Subtotal:",
vat_label: "VAT",
total: "Total",
amounts_in: "Amounts are in",
notes: "Notes",
delivery_terms: "Delivery terms:",
payment_terms: "Payment terms:",
total_no_vat: "Total excl. VAT",
issued_by: "Issued by:",
ico: "Reg. No.: ",
dic: "Tax ID: ",
@@ -224,12 +189,8 @@ interface IssuedOrderPdfData {
order_date: Date | null;
delivery_date: Date | null;
currency: string | null;
apply_vat: boolean | null;
vat_rate: unknown;
notes: string | null;
delivery_terms: string | null;
payment_terms: string | null;
issued_by: string | null;
// Editable heading above the items table; null → t.billing default.
order_text?: string | null;
}
interface IssuedOrderPdfItem {
@@ -238,21 +199,54 @@ interface IssuedOrderPdfItem {
quantity: unknown;
unit: string | null;
unit_price: unknown;
vat_rate: unknown;
}
export interface IssuedOrderPdfSection {
title: string | null;
title_cz: string | null;
content: string | null;
}
/** Tag-stripped emptiness check — "<p><br></p>" (empty Quill) counts as empty. */
function stripTags(html: string | null | undefined): string {
return (html || "").replace(/<[^>]*>/g, "").trim();
}
/**
* Repeating per-page footer for the issued-order PDF, rendered by Puppeteer
* in the bottom page margin (the only true repeating footer Chromium
* supports): "Vystavil: <name>" left, "Strana X z Y" / "Page X of Y"
* (by document language) centered. Styles must stay inline and the
* font-size explicit — Chromium defaults footer text to 0.
*/
export function buildIssuedOrderPdfFooter(
lang: Lang,
issuerName: string,
): string {
const t = translations[lang];
const pageWord = lang === "cs" ? "Strana" : "Page";
const ofWord = lang === "cs" ? "z" : "of";
return `<div style="width:100%; font-size:8pt; font-family:Tahoma, sans-serif; color:#646464; padding:0 10mm; box-sizing:border-box; display:flex; align-items:center;">
<div style="flex:1; text-align:left;"><span style="font-weight:600;">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuerName)}</div>
<div style="flex:1; text-align:center;">${pageWord} <span class="pageNumber"></span> ${ofWord} <span class="totalPages"></span></div>
<div style="flex:1;"></div>
</div>`;
}
export function renderIssuedOrderHtml(
order: IssuedOrderPdfData,
items: IssuedOrderPdfItem[],
customer: Record<string, unknown> | null,
supplier: Record<string, unknown> | null,
settings: Record<string, unknown> | null,
lang: Lang,
issuer: { name: string },
// Kept for signature stability across the many call sites: the issuer no
// longer renders in the BODY — it prints in the Puppeteer footer template
// (buildIssuedOrderPdfFooter) on every page.
_issuer: { name: string },
sections: IssuedOrderPdfSection[] = [],
): string {
const t = translations[lang];
const applyVat = order.apply_vat !== false;
const currency = order.currency || "CZK";
const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21;
const poNumber = escapeHtml(order.po_number || "");
// Logo embedding (same logic as the confirmation template).
@@ -268,42 +262,26 @@ export function renderIssuedOrderHtml(
}
// PO direction: our company (settings) = Odběratel (buyer);
// the customer record = Dodavatel (supplier).
// the sklad_suppliers record = Dodavatel (supplier).
const buyer = buildAddressLines(settings, true, t); // company → Odběratel
const supplier = buildAddressLines(customer, false, t); // customer → Dodavatel
const supplierAddr = buildSupplierLines(supplier, t); // supplier → Dodavatel
const buyerLinesHtml = buyer.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const supplierLinesHtml = supplier.lines
const supplierLinesHtml = supplierAddr.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
// Items — NET + per-line VAT-on-top, rounded per line (same math the
// service computeIssuedOrderTotals uses; mirrors the confirmation loop).
let subtotal = 0;
let totalVat = 0;
const vatSummary: Record<string, { base: number; vat: number }> = {};
// Items — NET only. A purchase order is not a tax document: no VAT columns,
// no VAT math (same as the service computeIssuedOrderTotals).
let total = 0;
const itemsHtml = items
.map((it, i) => {
const qty = Number(it.quantity) || 0;
const unitPrice = Number(it.unit_price) || 0;
const rate =
it.vat_rate != null && it.vat_rate !== ""
? Number(it.vat_rate)
: docRate;
const lineSubtotal = qty * unitPrice;
const lineVat = applyVat
? Math.round(lineSubtotal * (rate / 100) * 100) / 100
: 0;
const lineTotal = lineSubtotal + lineVat;
subtotal += lineSubtotal;
totalVat += lineVat;
const key = String(rate);
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineSubtotal;
vatSummary[key].vat += lineVat;
const lineTotal = qty * unitPrice;
total += lineTotal;
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const descHtml = `${escapeHtml(it.description)}${
@@ -315,63 +293,53 @@ export function renderIssuedOrderHtml(
<td class="row-num">${i + 1}</td>
<td class="desc">${descHtml}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
<td class="right">${formatNum(unitPrice)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>
<td class="center">${applyVat ? Math.floor(rate) : 0}%</td>
<td class="right">${formatNum(lineVat)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td>
<td class="right">${formatCurrency(unitPrice, currency)}</td>
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`;
})
.join("");
subtotal = Math.round(subtotal * 100) / 100;
totalVat = Math.round(totalVat * 100) / 100;
const totalToPay = Math.round((subtotal + totalVat) * 100) / 100;
let vatDetailHtml = "";
if (applyVat) {
for (const [rate, data] of Object.entries(vatSummary)) {
if (data.vat > 0) {
vatDetailHtml += `
<div class="row">
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
</div>`;
}
}
}
const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
<div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
</div>
`
: "";
total = Math.round(total * 100) / 100;
const issueDateStr = formatDate(order.order_date);
const deliveryDateStr = formatDate(order.delivery_date);
// Order-specific terms (purchase-order addition; not on invoices).
let termsHtml = "";
if (order.delivery_terms) {
termsHtml += `<div class="terms-row"><span class="lbl">${escapeHtml(t.delivery_terms)}</span> ${escapeHtml(order.delivery_terms)}</div>`;
}
if (order.payment_terms) {
termsHtml += `<div class="terms-row"><span class="lbl">${escapeHtml(t.payment_terms)}</span> ${escapeHtml(order.payment_terms)}</div>`;
}
const termsBlock = termsHtml ? `<div class="terms">${termsHtml}</div>` : "";
// Quill indent CSS
let indentCSS = "";
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
const indentCSS = buildQuillIndentCss();
// ── Sections ("Obsah") — flow INLINE after the items/totals block (user
// decision: no separate page, no repeated header). Each section keeps
// break-inside: avoid; long content paginates naturally and the Puppeteer
// footer template stamps every page. Suppressed entirely when every
// section is empty (tag-stripped check, so an empty-Quill "<p><br></p>"
// doesn't count). cs prefers the Czech title when present; en (or a
// missing Czech title) falls back to the EN title (same rule as offers).
const resolveSectionTitle = (s: IssuedOrderPdfSection): string =>
lang === "cs" && (s.title_cz || "").trim()
? s.title_cz || ""
: s.title || "";
// Visibility must use the SAME per-language title rule as the render loop —
// otherwise a section with only the other language's title would count as
// content and produce an empty sections block.
const visibleSections = sections.filter(
(s) => resolveSectionTitle(s).trim() || stripTags(s.content),
);
const hasSectionContent = visibleSections.length > 0;
let scopeHtml = "";
if (hasSectionContent) {
scopeHtml += '<div class="scope-sections">';
for (const section of visibleSections) {
const title = resolveSectionTitle(section);
const content = (section.content || "").trim();
scopeHtml += '<div class="scope-section">';
if (title.trim())
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content)
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
scopeHtml += "</div>";
}
scopeHtml += "</div>";
}
return `<!DOCTYPE html>
@@ -392,15 +360,9 @@ export function renderIssuedOrderHtml(
width: 186mm;
}
.invoice-page {
display: flex;
flex-direction: column;
min-height: calc(297mm - 27mm);
}
.invoice-content { flex: 1 1 auto; }
.invoice-footer {
flex-shrink: 0;
}
/* The repeating "Vystavil / Strana X z Y" footer lives in Puppeteer's
footerTemplate (bottom page margin), NOT in the body — the old flex
min-height pinning is gone with it. */
.accent { color: #de3a3a; }
@@ -450,7 +412,7 @@ export function renderIssuedOrderHtml(
vertical-align: top;
width: 50%;
}
.header-grid td.addr-customer {
.header-grid td.addr-supplier {
background: #f5f5f5;
}
.header-grid td.details-bank {
@@ -605,34 +567,6 @@ export function renderIssuedOrderHtml(
border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm;
}
.totals .currency-note {
text-align: right;
font-size: 8pt;
color: #1a1a1a;
margin-top: 2mm;
}
/* Dodaci / platebni podminky (PO-specificke) */
.terms {
margin-top: 4mm;
font-size: 9pt;
line-height: 1.5;
color: #1a1a1a;
}
.terms-row { margin-bottom: 1mm; }
.terms-row .lbl {
font-weight: 600;
color: #555;
}
/* Vystavil */
.issued-by {
font-size: 9pt;
margin: 2mm 0;
line-height: 1.4;
}
.issued-by .lbl { font-weight: 600; }
/* Upozorneni */
.notice {
font-size: 8pt;
@@ -641,32 +575,42 @@ export function renderIssuedOrderHtml(
line-height: 1.3;
}
/* Poznamky */
.invoice-notes {
margin-top: 4mm;
font-size: 10pt;
line-height: 1.5;
color: #1a1a1a;
/* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
stranu. */
.scope-sections {
margin-top: 6mm;
}
.invoice-notes-label {
font-weight: 600;
font-size: 9pt;
text-transform: uppercase;
color: #555;
.scope-section {
width: 100%;
max-width: 100%;
margin-bottom: 3mm;
break-inside: avoid;
}
.scope-section-title {
font-size: 11pt;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 1mm;
}
.invoice-notes-content p { margin: 0 0 0.4em 0; }
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; }
.invoice-notes-content li { margin-bottom: 0.2em; }
.invoice-notes-content,
.invoice-notes-content * {
.section-content {
color: #1a1a1a;
line-height: 1.5;
word-break: normal;
overflow-wrap: anywhere;
}
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important;
}
.invoice-notes-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; }
.invoice-notes-content h2 { font-size: 18px; }
.invoice-notes-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; }
.section-content { font-size: 14px; }
.section-content h1 { font-size: 20px; }
.section-content h2 { font-size: 18px; }
.section-content h3 { font-size: 16px; }
.section-content h4 { font-size: 15px; }
.section-content p { margin: 0 0 0.4em 0; }
.section-content ul, .section-content ol { margin: 0 0 0.4em 1.5em; }
.section-content li { margin-bottom: 0.2em; }
/* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
@@ -688,6 +632,7 @@ ${indentCSS}
display: flex;
flex-direction: column;
align-items: center;
gap: 30px;
min-height: 100vh;
overflow-x: hidden;
}
@@ -715,7 +660,7 @@ ${indentCSS}
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
</div>
<!-- Odberatel (nase firma) / Dodavatel (zakaznik) + Detaily -->
<!-- Odberatel (nase firma) / Dodavatel (sklad_suppliers) + Detaily -->
<table class="header-grid" cellspacing="0">
<tr>
<td>
@@ -723,9 +668,9 @@ ${indentCSS}
<div class="address-name">${escapeHtml(buyer.name)}</div>
${buyerLinesHtml}
</td>
<td class="addr-customer">
<td class="addr-supplier">
<div class="address-label">${escapeHtml(t.supplier)}</div>
<div class="address-name">${escapeHtml(supplier.name)}</div>
<div class="address-name">${escapeHtml(supplierAddr.name)}</div>
${supplierLinesHtml}
</td>
</tr>
@@ -739,18 +684,15 @@ ${indentCSS}
</table>
<!-- Polozky -->
<div class="billing-label">${escapeHtml(t.billing)}</div>
<div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
<table class="items">
<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:56%">${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>
<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>
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
@@ -758,34 +700,21 @@ ${indentCSS}
</tbody>
</table>
<!-- Soucty -->
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval;
mena je soucasti castky pres formatCurrency, takze zadna "Částky jsou
uvedeny v …" poznamka) -->
<div class="totals-wrapper">
<div class="totals">
<div class="detail-rows">
<div class="row">
<span class="label">${escapeHtml(t.subtotal)}</span>
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
</div>${vatDetailHtml}
</div>
<div class="grand">
<span class="label">${escapeHtml(t.total)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
<span class="label">${escapeHtml(t.total_no_vat)}</span>
<span class="value">${formatCurrency(total, currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
</div>
</div>
${notesHtml}
${termsBlock}
${scopeHtml}
</div><!-- /.invoice-content -->
<div class="invoice-footer">
<!-- Vystavil (jmeno prihlaseneho uzivatele) -->
<div class="issued-by"><span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(issuer.name)}</div>
</div><!-- /.invoice-footer -->
</div><!-- /.invoice-page -->
</body>
@@ -804,7 +733,6 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>;
const lang: Lang = query.lang === "en" ? "en" : "cs";
try {
const order = await prisma.issued_orders.findUnique({ where: { id } });
@@ -814,13 +742,26 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
.type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
}
// Language comes from the document's own column; ?lang= is an
// explicit override only (mirrors the /:id/file fallback semantics).
const lang: Lang =
query.lang === "en" || query.lang === "cs"
? query.lang
: order.language === "en"
? "en"
: "cs";
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: id },
orderBy: { position: "asc" },
});
const customer = order.customer_id
? ((await prisma.customers.findUnique({
where: { id: order.customer_id },
// id tiebreak: position can repeat, keep the order deterministic.
const sections = await prisma.issued_order_sections.findMany({
where: { issued_order_id: id },
orderBy: [{ position: "asc" }, { id: "asc" }],
});
const supplier = order.supplier_id
? ((await prisma.sklad_suppliers.findUnique({
where: { id: order.supplier_id },
})) as Record<string, unknown> | null)
: null;
const settings = (await prisma.company_settings.findFirst()) as Record<
@@ -838,10 +779,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
const html = renderIssuedOrderHtml(
order,
items,
customer,
supplier,
settings,
lang,
issuer,
sections,
);
// ?save=1 → archive the PDF to NAS instead of returning it (mirrors
@@ -852,7 +794,9 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
? new Date(order.order_date)
: new Date();
nasOrdersManager.cleanIssued(order.po_number);
const pdfBuffer = await htmlToPdf(html);
const pdfBuffer = await htmlToPdf(html, {
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
});
nasOrdersManager.saveIssuedPdf(
order.po_number,
baseDate.getFullYear(),
@@ -862,7 +806,9 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
return success(reply, null, 200, "PDF uloženo");
}
const pdf = await htmlToPdf(html);
const pdf = await htmlToPdf(html, {
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
});
const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
return reply
.type("application/pdf")

View File

@@ -1,5 +1,6 @@
import { FastifyInstance } from "fastify";
import { requirePermission } from "../../middleware/auth";
import prisma from "../../config/database";
import { requirePermission, requireAnyPermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
@@ -17,6 +18,18 @@ import {
deleteIssuedOrder,
getNextIssuedOrderNumberPreview,
} from "../../services/issued-orders.service";
import {
renderIssuedOrderHtml,
buildIssuedOrderPdfFooter,
} from "./issued-orders-pdf";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { nasOrdersManager } from "../../services/nas-financials-manager";
import { contentDisposition } from "../../utils/content-disposition";
// 3× the client's 10s heartbeat: a single missed beat (background-tab
// throttling, transient network) must not silently hand the lock to a second
// editor. Keep in sync with quotations.ts and useDocumentLock.
const LOCK_TIMEOUT_MS = 30 * 1000;
export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
// GET /api/admin/issued-orders
@@ -25,16 +38,16 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order, search } = parsePagination(query);
const { page, limit, skip, sort, order, search } = parsePagination(query);
const result = await listIssuedOrders({
page,
limit,
skip,
sort: String(query.sort || ""),
sort,
order,
search,
status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
month: query.month ? Number(query.month) : undefined,
year: query.year ? Number(query.year) : undefined,
});
@@ -55,9 +68,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
},
);
// GET /api/admin/issued-orders/stats — per-currency total (incl. VAT) summed
// over the WHOLE filtered set (not one page). Registered BEFORE "/:id" so the
// literal "stats" path isn't captured as an order id.
// GET /api/admin/issued-orders/stats — per-currency NET total summed over
// the WHOLE filtered set (not one page). Issued orders are not tax documents
// — no VAT anywhere. Registered BEFORE "/:id" so the literal "stats" path
// isn't captured as an order id.
fastify.get(
"/stats",
{ preHandler: requirePermission("orders.view") },
@@ -66,7 +80,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
const result = await getIssuedOrderTotals({
search: query.search ? String(query.search) : undefined,
status: query.status ? String(query.status) : undefined,
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
supplier_id: query.supplier_id ? Number(query.supplier_id) : undefined,
month: query.month ? Number(query.month) : undefined,
year: query.year ? Number(query.year) : undefined,
});
@@ -74,6 +88,40 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
},
);
// GET /api/admin/issued-orders/suppliers — lightweight supplier lookup for
// the PO supplier picker. Guarded by the ORDERS permissions (any of
// view/create/edit), NOT warehouse.manage: the warehouse suppliers CRUD is
// gated by warehouse.manage, which orders users typically lack — without
// this endpoint they couldn't populate the picker. Registered BEFORE "/:id"
// so the literal "suppliers" path isn't captured as an order id.
fastify.get(
"/suppliers",
{
preHandler: requireAnyPermission(
"orders.view",
"orders.create",
"orders.edit",
),
},
async (_request, reply) => {
const suppliers = await prisma.sklad_suppliers.findMany({
where: { is_active: true },
select: {
id: true,
name: true,
ico: true,
dic: true,
address: true,
email: true,
phone: true,
},
// id tiebreak so same-name suppliers sort deterministically.
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return success(reply, suppliers);
},
);
// GET /api/admin/issued-orders/:id
fastify.get<{ Params: { id: string } }>(
"/:id",
@@ -83,7 +131,217 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
if (id === null) return;
const order = await getIssuedOrder(id);
if (!order) return error(reply, "Objednávka nenalezena", 404);
return success(reply, order);
// `getIssuedOrder` already loaded locked_by/locked_at — enrich locked_by
// to {user_id, username, full_name} ONLY when the lock is fresh and held
// by ANOTHER user (same shape as offers so the frontend lock hook can be
// shared); otherwise null.
let lockedBy: {
user_id: number;
username: string;
full_name: string;
} | null = null;
if (order.locked_by && order.locked_at) {
const lockAge = Date.now() - new Date(order.locked_at).getTime();
if (
lockAge < LOCK_TIMEOUT_MS &&
order.locked_by !== request.authData!.userId
) {
const lockUser = await prisma.users.findUnique({
where: { id: order.locked_by },
select: {
id: true,
username: true,
first_name: true,
last_name: true,
},
});
if (lockUser) {
lockedBy = {
user_id: lockUser.id,
username: lockUser.username,
full_name: `${lockUser.first_name} ${lockUser.last_name}`.trim(),
};
}
}
}
return success(reply, { ...order, locked_by: lockedBy });
},
);
// POST /api/admin/issued-orders/:id/lock — acquire edit lock (mirrors offers)
fastify.post<{ Params: { id: string } }>(
"/:id/lock",
{ preHandler: requirePermission("orders.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const order = await prisma.issued_orders.findUnique({
where: { id },
select: { locked_by: true, locked_at: true },
});
if (!order) return error(reply, "Objednávka nenalezena", 404);
// Check if locked by someone else and lock is fresh
if (order.locked_by && order.locked_at) {
const lockAge = Date.now() - new Date(order.locked_at).getTime();
if (
lockAge < LOCK_TIMEOUT_MS &&
order.locked_by !== request.authData!.userId
) {
const lockUser = await prisma.users.findUnique({
where: { id: order.locked_by },
select: { first_name: true, last_name: true },
});
return error(
reply,
`Objednávku právě upravuje ${lockUser ? `${lockUser.first_name} ${lockUser.last_name}`.trim() : "jiný uživatel"}`,
423,
);
}
}
await prisma.issued_orders.update({
where: { id },
data: { locked_by: request.authData!.userId, locked_at: new Date() },
});
return success(reply, null, 200, "Zámek nastaven");
},
);
// POST /api/admin/issued-orders/:id/heartbeat — keep lock alive
fastify.post<{ Params: { id: string } }>(
"/:id/heartbeat",
{ preHandler: requirePermission("orders.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
await prisma.issued_orders.updateMany({
where: { id, locked_by: request.authData!.userId },
data: { locked_at: new Date() },
});
return success(reply, null);
},
);
// POST /api/admin/issued-orders/:id/unlock — release lock
fastify.post<{ Params: { id: string } }>(
"/:id/unlock",
{ preHandler: requirePermission("orders.edit") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
await prisma.issued_orders.updateMany({
where: { id, locked_by: request.authData!.userId },
data: { locked_by: null, locked_at: null },
});
return success(reply, null);
},
);
// GET /api/admin/issued-orders/:id/file — serve the archived PDF from the
// NAS; on an archive miss, fall back to a LIVE render from current data,
// re-archive the fresh PDF, and serve it (archived-file model with live
// fallback — same model as offers' /:id/file).
fastify.get<{ Params: { id: string } }>(
"/:id/file",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const order = await prisma.issued_orders.findUnique({ where: { id } });
// A draft has no po_number → nothing was ever archived; 404 like offers.
if (!order?.po_number) return error(reply, "Objednávka nenalezena", 404);
const fileName = `Objednavka-${order.po_number}.pdf`;
// Archive hit: the by-number sweep finds the PDF in whichever
// Vydané/YYYY/MM folder it was saved to (order_date can move after
// archiving, so a fixed expected path would miss).
const archived = nasOrdersManager.readIssuedByNumber(order.po_number);
if (archived) {
return reply
.type("application/pdf")
.header("Content-Disposition", contentDisposition("inline", fileName))
.send(archived.data);
}
// Archive miss → live render (same data path as issued-orders-pdf),
// then archive the fresh PDF so the next request is a plain hit.
try {
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: id },
orderBy: { position: "asc" },
});
const sections = await prisma.issued_order_sections.findMany({
where: { issued_order_id: id },
orderBy: [{ position: "asc" }, { id: "asc" }],
});
const supplier = order.supplier_id
? ((await prisma.sklad_suppliers.findUnique({
where: { id: order.supplier_id },
})) as Record<string, unknown> | null)
: null;
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
const lang =
order.language === "en" ? ("en" as const) : ("cs" as const);
const issuer = {
name: `${request.authData?.firstName ?? ""} ${request.authData?.lastName ?? ""}`.trim(),
};
const html = renderIssuedOrderHtml(
order,
items,
supplier,
settings,
lang,
issuer,
sections,
);
const pdf = await htmlToPdf(html, {
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
});
if (nasOrdersManager.isConfigured()) {
const baseDate = order.order_date
? new Date(order.order_date)
: new Date();
nasOrdersManager.cleanIssued(order.po_number);
const saved = nasOrdersManager.saveIssuedPdf(
order.po_number,
baseDate.getFullYear(),
baseDate.getMonth() + 1,
pdf,
);
if (!saved) {
request.log.error(
`Archivace PDF objednávky ${order.po_number} na NAS při /file fallbacku selhala`,
);
}
}
return reply
.type("application/pdf")
.header("Content-Disposition", contentDisposition("inline", fileName))
.send(pdf);
} catch (err) {
request.log.error(
err,
"Issued order /file live-render fallback failed",
);
return error(reply, "Chyba při generování PDF", 500);
}
},
);
@@ -95,6 +353,13 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
const parsed = parseBody(CreateIssuedOrderSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const order = await createIssuedOrder(parsed.data);
if ("error" in order) {
if (order.error === "supplier_not_found")
return error(reply, "Dodavatel nenalezen", 400);
if (order.error === "po_number_taken")
return error(reply, "Číslo objednávky je již použito", 409);
return error(reply, "Neznámá chyba", 500);
}
await logAudit({
request,
authData: request.authData,
@@ -125,6 +390,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
if ("error" in result) {
if (result.error === "not_found")
return error(reply, "Objednávka nenalezena", 404);
if (result.error === "not_editable")
return error(reply, "Objednávku v tomto stavu nelze upravovat", 400);
if (result.error === "supplier_not_found")
return error(reply, "Dodavatel nenalezen", 400);
if (result.error === "invalid_transition")
return error(
reply,
@@ -139,9 +408,18 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
action: "update",
entityType: "issued_order",
entityId: id,
description: `Upravena vydaná objednávka ${result.po_number}`,
description: `Upravena vydaná objednávka ${result.po_number ?? "koncept"}`,
oldValues: result.oldValues,
newValues: result.newValues,
});
return success(reply, { id }, 200, "Objednávka byla aktualizována");
// po_number in the response so a draft->sent finalize immediately shows
// the assigned number (POST already returns it).
return success(
reply,
{ id, po_number: result.po_number },
200,
"Objednávka byla aktualizována",
);
},
);
@@ -160,7 +438,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
action: "delete",
entityType: "issued_order",
entityId: id,
description: `Smazána vydaná objednávka ${existing.po_number}`,
description: `Smazána vydaná objednávka ${existing.po_number ?? "koncept"}`,
oldValues: existing as unknown as Record<string, unknown>,
});
return success(reply, null, 200, "Objednávka smazána");
},

View File

@@ -1,102 +1,24 @@
import { FastifyInstance } from "fastify";
import type { Prisma, company_settings } from "@prisma/client";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { nasOffersManager } from "../../services/nas-offers-manager";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId, success } from "../../utils/response";
import {
formatDate,
formatNum,
formatCurrency,
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom";
const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window);
function formatDate(date: Date | string | null | undefined): string {
if (!date) return "";
const d = new Date(date);
if (isNaN(d.getTime())) return String(date);
return localDateCzStr(d);
}
/** Format number with comma decimal separator and non-breaking space thousands separator */
function formatNum(n: number, decimals: number): string {
const abs = Math.abs(n);
const fixed = abs.toFixed(decimals);
const [intPart, decPart] = fixed.split(".");
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, "\u00A0");
const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result;
}
function formatCurrency(amount: number, currency: string): string {
const n = Number(amount) || 0;
switch (currency) {
case "EUR":
return `${formatNum(n, 2)} \u20AC`;
case "USD":
return `$${Math.abs(n)
.toFixed(2)
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
case "CZK":
return `${formatNum(n, 2)} K\u010D`;
case "GBP":
return `\u00A3${Math.abs(n)
.toFixed(2)
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
default:
return `${formatNum(n, 2)} ${currency}`;
}
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Sanitize Quill HTML: keep safe tags, remove event handlers, merge adjacent spans */
function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return "";
const allowedTags =
"<p><br><strong><em><u><s><ul><ol><li><span><sub><sup><a><h1><h2><h3><h4><blockquote><pre>";
let s = html;
// Remove dangerous tags with content
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
"",
);
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
"",
);
// Strip event handlers
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
// Strip javascript:/data:/vbscript: in href and src (defense-in-depth,
// matching the invoices/orders copies — DOMPurify runs first).
s = s.replace(
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'href="#"',
);
s = s.replace(
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'src=""',
);
// Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
// Merge adjacent spans with same attributes
let prev = "";
while (prev !== s) {
prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
}
return s;
}
interface AddressResult {
name: string;
lines: string[];
@@ -203,144 +125,124 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
included: { EN: "Included", CZ: "Zahrnuto" },
total: { EN: "Total", CZ: "Celkem" },
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" },
vat: { EN: "VAT", CZ: "DPH" },
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
ico: { EN: "ID", CZ: "I\u010CO" },
dic: { EN: "VAT ID", CZ: "DI\u010C" },
page: { EN: "Page", CZ: "Strana" },
of: { EN: "of", CZ: "z" },
};
export default async function offersPdfRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>;
/** Quotation row with the relations the PDF template renders. */
export type OfferForPdf = Prisma.quotationsGetPayload<{
include: {
customers: true;
quotation_items: true;
scope_sections: true;
};
}>;
try {
const quotation = await prisma.quotations.findUnique({
where: { id },
include: {
customers: true,
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
},
});
/**
* Build the full offer PDF/preview HTML. Extracted from the route handler so
* the offers /:id/file live-render fallback (quotations.ts) can reuse the
* exact same template without going through HTTP.
*/
export function renderOfferHtml(
quotation: OfferForPdf,
settings: company_settings | null,
): string {
const isCzech = (quotation.language ?? "EN") !== "EN";
const langKey = isCzech ? "CZ" : "EN";
const currency = quotation.currency || "EUR";
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key;
if (!quotation) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>");
}
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data);
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`;
}
const settings = await prisma.company_settings.findFirst();
const isCzech = (quotation.language ?? "EN") !== "EN";
const langKey = isCzech ? "CZ" : "EN";
const currency = quotation.currency || "EUR";
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key;
// Offers are NOT tax documents — the total is NET only (no VAT math).
const items = quotation.quotation_items;
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);
}
}
// 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
// content and produce a scope page holding nothing but the header. Mirrors
// issued-orders-pdf.
const stripSectionTags = (html: string | null | undefined): string =>
(html || "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
const resolveSectionTitle = (s: {
title: string | null;
title_cz: string | null;
}): string =>
isCzech && (s.title_cz || "").trim() ? s.title_cz || "" : s.title || "";
const visibleSections = quotation.scope_sections.filter(
(s) => resolveSectionTitle(s).trim() || stripSectionTags(s.content),
);
const hasScopeContent = visibleSections.length > 0;
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data);
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`;
}
const cust = buildAddressLines(
quotation.customers as unknown as Record<string, unknown>,
false,
t,
);
const supp = buildAddressLines(
settings as unknown as Record<string, unknown>,
true,
t,
);
const items = quotation.quotation_items;
let subtotal = 0;
for (const item of items) {
if (item.is_included_in_total !== false) {
subtotal +=
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
}
}
const applyVat = !!quotation.apply_vat;
const vatRate = Number(quotation.vat_rate) || 21;
const vatAmount = applyVat ? subtotal * (vatRate / 100) : 0;
const totalToPay = subtotal + vatAmount;
let hasScopeContent = false;
for (const s of quotation.scope_sections) {
if ((s.content || "").trim() || (s.title || "").trim()) {
hasScopeContent = true;
break;
}
}
const custLinesHtml = cust.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const suppLinesHtml = supp.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const cust = buildAddressLines(
quotation.customers as unknown as Record<string, unknown>,
false,
t,
);
const supp = buildAddressLines(
settings as unknown as Record<string, unknown>,
true,
t,
);
// Indentation CSS for Quill
const indentCSS = buildQuillIndentCss();
const custLinesHtml = cust.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const suppLinesHtml = supp.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
// Indentation CSS for Quill
let indentCSS = "";
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
}
let itemsHtml = "";
items.forEach((item, i) => {
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const subDesc = item.item_description || "";
const evenClass = i % 2 === 1 ? ' class="even"' : "";
itemsHtml += `<tr${evenClass}>
let itemsHtml = "";
items.forEach((item, i) => {
const qty = Number(item.quantity) || 0;
const lineTotal = qty * (Number(item.unit_price) || 0);
// 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 subDesc = item.item_description || "";
const evenClass = i % 2 === 1 ? ' class="even"' : "";
itemsHtml += `<tr${evenClass}>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
<td class="center">${formatNum(Number(item.quantity) || 1, 0)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${(item.unit || "").trim() ? ` / ${escapeHtml((item.unit || "").trim())}` : ""}</td>
<td class="right">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`;
});
});
let totalsHtml = "";
if (applyVat) {
totalsHtml += `<div class="detail-rows">
<div class="row">
<span class="label">${escapeHtml(t("subtotal"))}:</span>
<span class="value">${formatCurrency(subtotal, currency)}</span>
</div>
<div class="row">
<span class="label">${escapeHtml(t("vat"))} (${Math.round(vatRate)}%):</span>
<span class="value">${formatCurrency(vatAmount, currency)}</span>
</div>
</div>`;
}
totalsHtml += `<div class="grand">
<span class="label">${escapeHtml(t("total_to_pay"))}</span>
<span class="value">${formatCurrency(totalToPay, currency)}</span>
// No Mezisoučet/VAT rows — they would only duplicate the net total.
const totalsHtml = `<div class="grand">
<span class="label">${escapeHtml(t("total_no_vat"))}</span>
<span class="value">${formatCurrency(total, currency)}</span>
</div>`;
const quotationNumber = escapeHtml(quotation.quotation_number);
const quotationNumber = escapeHtml(quotation.quotation_number);
let scopeHtml = "";
if (hasScopeContent) {
scopeHtml += '<div class="scope-page">';
scopeHtml += `<div class="page-header">
let scopeHtml = "";
if (hasScopeContent) {
scopeHtml += '<div class="scope-page">';
scopeHtml += `<div class="page-header">
<div class="left">
<div class="page-title">${escapeHtml(t("title"))}</div>
<div class="quotation-number">${quotationNumber}</div>
@@ -351,27 +253,23 @@ export default async function offersPdfRoutes(
</div>
<hr class="separator" />`;
for (const section of quotation.scope_sections) {
const title =
isCzech && (section.title_cz || "").trim()
? section.title_cz
: section.title || "";
const content = (section.content || "").trim();
if (!title && !content) continue;
scopeHtml += '<div class="scope-section">';
if (title)
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content)
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
scopeHtml += "</div>";
}
scopeHtml += "</div>";
}
for (const section of visibleSections) {
const title = resolveSectionTitle(section);
const content = (section.content || "").trim();
scopeHtml += '<div class="scope-section">';
if (title.trim())
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content)
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
scopeHtml += "</div>";
}
scopeHtml += "</div>";
}
const pageLabel = escapeHtml(t("page"));
const ofLabel = escapeHtml(t("of"));
const pageLabel = escapeHtml(t("page"));
const ofLabel = escapeHtml(t("of"));
const html = `<!DOCTYPE html>
const html = `<!DOCTYPE html>
<html lang="${isCzech ? "cs" : "en"}">
<head>
<meta charset="utf-8" />
@@ -749,6 +647,40 @@ ${indentCSS}
</body>
</html>`;
return html;
}
export default async function offersPdfRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("offers.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const query = request.query as Record<string, string>;
try {
const quotation = await prisma.quotations.findUnique({
where: { id },
include: {
customers: true,
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
},
});
if (!quotation) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Nabídka nenalezena</h1></body></html>");
}
const settings = await prisma.company_settings.findFirst();
const html = renderOfferHtml(quotation, settings);
const saveMode = query.save === "1";
// Save PDF to NAS

View File

@@ -1,9 +1,15 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId, error } from "../../utils/response";
import {
formatDate,
formatNum,
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
} from "../../utils/pdf-shared";
import { parseBody } from "../../schemas/common";
import { z } from "zod";
import createDOMPurify from "dompurify";
@@ -20,69 +26,18 @@ const OrderPdfItemSchema = z.object({
unit: z.string().max(255),
unit_price: z.number().min(0).finite(),
is_included_in_total: z.boolean().optional(),
vat_rate: z.number().min(0).max(100).finite(),
});
// `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`.
// `items` is strictly validated; `lang`/`applyVat` are typed explicitly so the
// handler no longer reads them as untyped passthrough keys.
// `items` is strictly validated; `lang` is typed explicitly so the handler no
// longer reads it as an untyped passthrough key.
const OrderPdfBodySchema = z.looseObject({
items: z.array(OrderPdfItemSchema).optional(),
lang: z.string().max(10).optional(),
applyVat: z.boolean().optional(),
});
/* ── Helpers ─────────────────────────────────────────────────────── */
function formatDate(date: Date | string | null | undefined): string {
if (!date) return "";
const d = new Date(date);
if (isNaN(d.getTime())) return String(date);
return localDateCzStr(d);
}
function formatNum(n: number, decimals = 2): string {
const abs = Math.abs(n);
const fixed = abs.toFixed(decimals);
const [intPart, decPart] = fixed.split(".");
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result;
}
function escapeHtml(str: string | null | undefined): string {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return "";
let s = html;
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
"",
);
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
"",
);
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
s = s.replace(/href\s*=\s*["']?\s*javascript\s*:[^"'>\s]*/gi, 'href="#"');
s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
let prev = "";
while (prev !== s) {
prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
}
return s;
}
interface AddressResult {
name: string;
lines: string[];
@@ -193,13 +148,8 @@ const translations: Record<string, Record<string, string>> = {
col_desc: "Popis",
col_qty: "Množství",
col_unit_price: "Jedn. cena",
col_price: "Cena",
col_vat_pct: "%DPH",
col_vat: "DPH",
col_total: "Celkem",
subtotal: "Mezisoučet:",
vat_label: "DPH",
total: "Celkem",
total_no_vat: "Celkem bez DPH",
amounts_in: "Částky jsou uvedeny v",
notes: "Poznámky",
issued_by: "Vystavil:",
@@ -221,13 +171,8 @@ const translations: Record<string, Record<string, string>> = {
col_desc: "Description",
col_qty: "Quantity",
col_unit_price: "Unit price",
col_price: "Price",
col_vat_pct: "VAT%",
col_vat: "VAT",
col_total: "Total",
subtotal: "Subtotal:",
vat_label: "VAT",
total: "Total",
total_no_vat: "Total excl. VAT",
amounts_in: "Amounts are in",
notes: "Notes",
issued_by: "Issued by:",
@@ -238,204 +183,112 @@ const translations: Record<string, Record<string, string>> = {
},
};
/* ── Route ───────────────────────────────────────────────────────── */
/* ── Template ────────────────────────────────────────────────────── */
export default async function ordersPdfRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
"/:id/confirmation",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
export interface OrderConfirmationPdfItem {
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
}
try {
const lang = body.lang === "en" ? "en" : "cs";
const t = translations[lang];
interface OrderConfirmationPdfData {
order_number: string | null;
customer_order_number: string | null;
created_at: Date | string | null;
currency: string | null;
notes: string | null;
// Not a real orders column today — read defensively (PHP-era data carried it).
payment_method?: string | null;
customers?: Record<string, unknown> | null;
}
const order = await prisma.orders.findUnique({
where: { id },
include: {
customers: true,
order_items: { orderBy: { position: "asc" } },
},
});
/**
* Order-confirmation HTML. The confirmation is NOT a tax document — prices
* are NET only: no VAT columns or VAT summary; the grand total is labeled
* "Celkem bez DPH". Exported for tests.
*/
export function renderOrderConfirmationHtml(
order: OrderConfirmationPdfData,
items: OrderConfirmationPdfItem[],
settings: Record<string, unknown> | null,
lang: "cs" | "en",
userName: string,
): string {
const t = translations[lang];
if (!order) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
}
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
}
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
const currency = order.currency || "CZK";
let logoImg = "";
if (settings?.logo_data) {
const buf = Buffer.from(settings.logo_data as Buffer);
let mime = "image/png";
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg";
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif";
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp";
const b64 = buf.toString("base64");
logoImg = `<img src="data:${escapeHtml(mime)};base64,${b64}" class="logo" />`;
}
// NET-only total over the included lines — no VAT math anywhere.
let total = 0;
for (const item of items) {
if (item.is_included_in_total) total += item.quantity * item.unit_price;
}
total = Math.round(total * 100) / 100;
const currency = order.currency || "CZK";
const applyVat =
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat;
const orderVatRate = Number(order.vat_rate) || 21;
const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines(
(order.customers as Record<string, unknown>) || null,
false,
t,
);
// The confirmation PDF can be rendered from client-supplied items (e.g.
// a live preview of unsaved edits on the detail page) OR from the
// stored order. Fabricating descriptions/prices that don't reflect the
// stored order is an editing action, so the custom-items path requires
// `orders.edit` (admins bypass). A view-only caller is silently served
// the STORED order items instead — preventing a `orders.view` holder
// from producing an "official" confirmation with invented figures.
const authData = request.authData;
const canUseCustomItems =
authData?.roleName === "admin" ||
!!authData?.permissions.includes("orders.edit");
const customItemsRaw =
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
const suppLinesHtml = supp.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const custLinesHtml = cust.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
let items: Array<{
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}> = [];
const orderNumber = escapeHtml(order.order_number || "");
const poNumber = escapeHtml(order.customer_order_number || "");
const orderDateStr = formatDate(order.created_at);
if (customItemsRaw && customItemsRaw.length > 0) {
items = customItemsRaw.map((it) => ({
description: it.description,
quantity: it.quantity,
unit: it.unit,
unit_price: it.unit_price,
is_included_in_total: it.is_included_in_total !== false,
vat_rate: it.vat_rate,
}));
} else {
items = order.order_items.map((it) => ({
description: it.description || "",
quantity: Number(it.quantity) || 0,
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
is_included_in_total: !!it.is_included_in_total,
vat_rate: orderVatRate,
}));
}
let subtotal = 0;
let totalVat = 0;
const vatSummary: Record<string, { base: number; vat: number }> = {};
for (const item of items) {
if (item.is_included_in_total) {
const lineTotal = item.quantity * item.unit_price;
subtotal += lineTotal;
const rate = item.vat_rate;
const key = String(rate);
if (!vatSummary[key]) vatSummary[key] = { base: 0, vat: 0 };
vatSummary[key].base += lineTotal;
if (applyVat) {
const lineVat = (lineTotal * rate) / 100;
vatSummary[key].vat += lineVat;
totalVat += lineVat;
}
}
}
const totalToPay = subtotal + totalVat;
const userName = request.authData
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
: "";
const supp = buildAddressLines(settings, true, t);
const cust = buildAddressLines(
(order.customers as Record<string, unknown>) || null,
false,
t,
);
const suppLinesHtml = supp.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const custLinesHtml = cust.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join("");
const orderNumber = escapeHtml(order.order_number || "");
const poNumber = escapeHtml(order.customer_order_number || "");
const orderDateStr = formatDate(order.created_at);
const itemsHtml = items
.map((item, i) => {
const lineSubtotal = item.quantity * item.unit_price;
const lineVat = applyVat ? (lineSubtotal * item.vat_rate) / 100 : 0;
const lineTotal = lineSubtotal + lineVat;
const qtyDecimals =
Math.floor(item.quantity) === item.quantity ? 0 : 2;
return `<tr>
const itemsHtml = items
.map((item, i) => {
const lineTotal = item.quantity * item.unit_price;
const qtyDecimals = Math.floor(item.quantity) === item.quantity ? 0 : 2;
return `<tr>
<td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td>
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</td>
<td class="right">${formatNum(item.unit_price)}</td>
<td class="right">${formatNum(lineSubtotal)}</td>
<td class="center">${applyVat ? Math.floor(item.vat_rate) : 0}%</td>
<td class="right">${formatNum(lineVat)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`;
})
.join("");
})
.join("");
const paymentMethod =
String((order as Record<string, unknown>).payment_method || "") ||
(lang === "cs" ? "převodem" : "Bank transfer");
const paymentMethod =
String(order.payment_method || "") ||
(lang === "cs" ? "převodem" : "Bank transfer");
let vatDetailHtml = "";
if (applyVat) {
for (const [rate, data] of Object.entries(vatSummary)) {
if (data.vat > 0) {
vatDetailHtml += `
<div class="row">
<span class="label">${escapeHtml(t.vat_label)} ${Math.floor(Number(rate))}%:</span>
<span class="value">${formatNum(data.vat)} ${escapeHtml(currency)}</span>
</div>`;
}
}
}
const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
const notesRaw = order.notes ?? "";
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
const notesHtml = notesStripped
? `
<div class="invoice-notes">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
</div>
`
: "";
: "";
// Quill indent CSS
let indentCSS = "";
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
}
// Quill indent CSS
const indentCSS = buildQuillIndentCss();
const html = `<!DOCTYPE html>
return `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}">
<head>
<meta charset="utf-8" />
@@ -683,47 +536,6 @@ export default async function ordersPdfRoutes(
line-height: 1.3;
}
/* DPH rekapitulace + QR */
.recap-section {
display: flex;
gap: 5mm;
align-items: flex-start;
margin-top: 1mm;
}
.recap-section .qr {
flex-shrink: 0;
width: 28mm;
}
.recap-section .qr img,
.recap-section .qr svg { width: 28mm; height: 28mm; }
.recap-section table {
border-collapse: collapse;
font-size: 9pt;
flex: 1;
}
.recap-section table th {
font-size: 8pt;
font-weight: 600;
color: #555;
padding: 3px 6px;
text-align: right;
border-bottom: 0.5pt solid #ccc;
}
.recap-section table td {
padding: 3px 6px;
text-align: right;
border-bottom: 0.5pt solid #eee;
}
.recap-section table td.center { text-align: center; }
.recap-section table td.cnb-rate {
font-size: 8pt;
color: #888;
text-align: right;
border-bottom: none;
padding-top: 4px;
}
/* Prevzal / razitko */
.footer-row {
display: flex;
@@ -845,13 +657,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:56%">${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>
<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>
<th class="right" style="width:16%">${escapeHtml(t.col_total)}</th>
<th class="right" style="width:21%">${escapeHtml(t.col_total)}</th>
</tr>
</thead>
<tbody>
@@ -859,18 +668,12 @@ ${indentCSS}
</tbody>
</table>
<!-- Soucty -->
<!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
<div class="totals-wrapper">
<div class="totals">
<div class="detail-rows">
<div class="row">
<span class="label">${escapeHtml(t.subtotal)}</span>
<span class="value">${formatNum(subtotal)} ${escapeHtml(currency)}</span>
</div>${vatDetailHtml}
</div>
<div class="grand">
<span class="label">${escapeHtml(t.total)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span>
<span class="label">${escapeHtml(t.total_no_vat)}</span>
<span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
</div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
</div>
@@ -897,9 +700,96 @@ ${indentCSS}
</body>
</html>`;
}
/* ── Route ───────────────────────────────────────────────────────── */
export default async function ordersPdfRoutes(
fastify: FastifyInstance,
): Promise<void> {
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
"/:id/confirmation",
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const parsed = parseBody(OrderPdfBodySchema, request.body || {});
if ("error" in parsed) return error(reply, parsed.error, 400);
const body = parsed.data;
try {
const lang = body.lang === "en" ? "en" : "cs";
const order = await prisma.orders.findUnique({
where: { id },
// The confirmation PDF never renders the PO attachment — don't pull
// the blob just to read the order header/items.
omit: { attachment_data: true },
include: {
customers: true,
order_items: { orderBy: { position: "asc" } },
},
});
if (!order) {
return reply
.status(404)
.type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>");
}
const settings = (await prisma.company_settings.findFirst()) as Record<
string,
unknown
> | null;
// The confirmation PDF can be rendered from client-supplied items (e.g.
// a live preview of unsaved edits on the detail page) OR from the
// stored order. Fabricating descriptions/prices that don't reflect the
// stored order is an editing action, so the custom-items path requires
// `orders.edit` (admins bypass). A view-only caller is silently served
// the STORED order items instead — preventing a `orders.view` holder
// from producing an "official" confirmation with invented figures.
const authData = request.authData;
const canUseCustomItems =
authData?.roleName === "admin" ||
!!authData?.permissions.includes("orders.edit");
const customItemsRaw =
canUseCustomItems && Array.isArray(body.items) ? body.items : null;
let items: OrderConfirmationPdfItem[];
if (customItemsRaw && customItemsRaw.length > 0) {
items = customItemsRaw.map((it) => ({
description: it.description,
quantity: it.quantity,
unit: it.unit,
unit_price: it.unit_price,
is_included_in_total: it.is_included_in_total !== false,
}));
} else {
items = order.order_items.map((it) => ({
description: it.description || "",
quantity: Number(it.quantity) || 0,
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
is_included_in_total: !!it.is_included_in_total,
}));
}
const userName = request.authData
? `${request.authData.firstName || ""} ${request.authData.lastName || ""}`.trim()
: "";
const html = renderOrderConfirmationHtml(
order,
items,
settings,
lang,
userName,
);
const pdfBuffer = await htmlToPdf(html);
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`;
const filename = `Potvrzeni-${order.order_number || String(id)}.pdf`;
return reply
.type("application/pdf")

View File

@@ -3,6 +3,7 @@ import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { contentDisposition } from "../../utils/content-disposition";
import { parseBody } from "../../schemas/common";
import { config } from "../../config/env";
import {
@@ -110,10 +111,12 @@ export default async function ordersRoutes(
const attachment = await getOrderAttachment(id);
if (!attachment) return error(reply, "Příloha nenalezena", 404);
const safeFilename = attachment.filename.replace(/[\r\n"\\/]/g, "");
return reply
.type("application/pdf")
.header("Content-Disposition", `inline; filename="${safeFilename}"`)
.header(
"Content-Disposition",
contentDisposition("inline", attachment.filename),
)
.send(attachment.data);
},
);

View File

@@ -3,6 +3,7 @@ import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { contentDisposition } from "../../utils/content-disposition";
import { parseBody } from "../../schemas/common";
import {
CreateQuotationSchema,
@@ -21,8 +22,13 @@ import {
getNextOfferNumber,
} from "../../services/offers.service";
import { nasOffersManager } from "../../services/nas-offers-manager";
import { renderOfferHtml } from "./offers-pdf";
import { htmlToPdf } from "../../utils/html-to-pdf";
const LOCK_TIMEOUT_MS = 10 * 1000; // 10 seconds — lock expires if no heartbeat
// 3× the client's 10s heartbeat: a single missed beat (background-tab
// throttling, transient network) must not silently hand the lock to a second
// editor. Keep in sync with issued-orders.ts and useDocumentLock.
const LOCK_TIMEOUT_MS = 30 * 1000;
export default async function quotationsRoutes(
fastify: FastifyInstance,
@@ -53,9 +59,10 @@ export default async function quotationsRoutes(
},
);
// GET /api/admin/offers/stats — per-currency total (incl. VAT) summed over the
// WHOLE filtered set (not one page). Offers have NO month/year filter; the
// list filters by search + status + customer_id, so the totals use the same.
// GET /api/admin/offers/stats — per-currency NET total summed over the WHOLE
// filtered set (not one page). Offers are not tax documents — no VAT
// anywhere. Offers have NO month/year filter; the list filters by
// search + status + customer_id, so the totals use the same.
// Registered BEFORE "/:id" so the literal "stats" path isn't captured as an id.
fastify.get(
"/stats",
@@ -117,8 +124,19 @@ export default async function quotationsRoutes(
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await invalidateOffer(id);
if (!existing) return error(reply, "Nabídka nenalezena", 404);
const result = await invalidateOffer(id);
if ("error" in result) {
if (result.error === "not_found")
return error(reply, "Nabídka nenalezena", 404);
if (result.error === "invalid_transition")
return error(
reply,
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
400,
);
return error(reply, "Neznámá chyba", 500);
}
const existing = result.data;
await logAudit({
request,
@@ -126,7 +144,9 @@ export default async function quotationsRoutes(
action: "update",
entityType: "quotation",
entityId: id,
description: `Zneplatněna nabídka ${existing.quotation_number}`,
description: `Zneplatněna nabídka ${existing.quotation_number ?? "koncept"}`,
oldValues: { status: existing.status },
newValues: { status: "invalidated" },
});
return success(reply, null, 200, "Nabídka zneplatněna");
},
@@ -262,8 +282,13 @@ export default async function quotationsRoutes(
if ("error" in parsed) return error(reply, parsed.error, 400);
const quotation = await createOffer(parsed.data);
if ("error" in quotation)
return error(reply, quotation.error!, quotation.status!);
if ("error" in quotation) {
if (quotation.error === "customer_not_found")
return error(reply, "Zákazník nenalezen", 400);
if (quotation.error === "number_taken")
return error(reply, "Číslo nabídky je již použito", 409);
return error(reply, "Neznámá chyba", 500);
}
await logAudit({
request,
@@ -295,13 +320,17 @@ export default async function quotationsRoutes(
if ("error" in result) {
if (result.error === "not_found")
return error(reply, "Nabídka nenalezena", 404);
if (result.error === "invalidated")
return error(reply, "Nelze upravit zneplatněnou nabídku", 400);
return error(
reply,
result.error || "Neznámá chyba",
"status" in result ? result.status : 400,
);
if (result.error === "not_editable")
return error(reply, "Nabídku v tomto stavu nelze upravovat", 400);
if (result.error === "invalid_transition")
return error(
reply,
`Neplatný přechod stavu z "${result.currentStatus}" na "${result.newStatus}"`,
400,
);
if (result.error === "customer_not_found")
return error(reply, "Zákazník nenalezen", 400);
return error(reply, "Neznámá chyba", 500);
}
// Keep lock — user stays on the page after save
@@ -312,7 +341,9 @@ export default async function quotationsRoutes(
action: "update",
entityType: "quotation",
entityId: id,
description: `Upravena nabídka ${result.quotation_number}`,
description: `Upravena nabídka ${result.quotation_number ?? "koncept"}`,
oldValues: result.oldValues,
newValues: result.newValues,
});
return success(reply, { id }, 200, "Nabídka byla uložena");
},
@@ -325,25 +356,23 @@ export default async function quotationsRoutes(
const id = parseId(request.params.id, reply);
if (id === null) return;
const existing = await deleteOffer(id);
if (!existing) return error(reply, "Nabídka nenalezena", 404);
// Delete PDF from NAS
if (existing.quotation_number && existing.created_at) {
const yr = new Date(existing.created_at).getFullYear();
try {
await nasOffersManager.deleteOfferPdf(
nasOffersManager.buildRelativePath(existing.quotation_number, yr),
const result = await deleteOffer(id);
if ("error" in result) {
if (result.error === "not_found")
return error(reply, "Nabídka nenalezena", 404);
if (result.error === "linked")
return error(
reply,
"Nabídku nelze smazat, je navázána na objednávku nebo projekt",
409,
);
} catch (e) {
// Non-fatal: NAS delete may fail if the file does not exist — but
// never swallow silently (project never-swallow rule).
request.log.warn(
e,
`Smazání PDF nabídky ${existing.quotation_number} z NAS selhalo`,
);
}
return error(reply, "Neznámá chyba", 500);
}
const existing = result.data;
// NAS PDF cleanup is owned by deleteOffer (single owner) — the route
// used to repeat it here, which always failed the second time and logged
// a spurious error.
await logAudit({
request,
@@ -351,7 +380,8 @@ export default async function quotationsRoutes(
action: "delete",
entityType: "quotation",
entityId: id,
description: `Smazána nabídka ${existing.quotation_number}`,
description: `Smazána nabídka ${existing.quotation_number ?? "koncept"}`,
oldValues: existing as unknown as Record<string, unknown>,
});
return success(reply, null, 200, "Nabídka smazána");
},
@@ -379,15 +409,71 @@ export default async function quotationsRoutes(
year,
);
const file = nasOffersManager.readOfferPdf(relPath);
if (!file) return error(reply, "PDF soubor nenalezen", 404);
if (file) {
return reply
.type("application/pdf")
.header(
"Content-Disposition",
// Quotation numbers contain "/" segments — not header- or
// filename-safe raw; the helper handles non-ASCII per RFC 5987.
contentDisposition(
"inline",
`Nabidka-${(offer.quotation_number || "").replace(/\//g, "-")}.pdf`,
),
)
.send(file.data);
}
return reply
.type("application/pdf")
.header(
"Content-Disposition",
`inline; filename="${offer.quotation_number}.pdf"`,
)
.send(file.data);
// Archive miss → live-render fallback (same model as issued orders'
// /:id/file): rebuild the PDF from current data with the exact PDF-route
// template, re-archive it so the next request is a plain hit, and serve
// the fresh bytes with identical response semantics.
try {
const quotation = await prisma.quotations.findUnique({
where: { id },
include: {
customers: true,
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
},
});
if (!quotation) return error(reply, "Nabídka nenalezena", 404);
const settings = await prisma.company_settings.findFirst();
const html = renderOfferHtml(quotation, settings);
const pdfBuffer = await htmlToPdf(html);
if (nasOffersManager.isConfigured()) {
// saveOfferPdf logs internally and returns null on failure —
// archiving is best-effort here, serving the PDF must not fail.
const saved = nasOffersManager.saveOfferPdf(
offer.quotation_number,
year,
pdfBuffer,
);
if (!saved) {
request.log.error(
`Archivace PDF nabídky ${offer.quotation_number} na NAS při /file fallbacku selhala`,
);
}
}
return reply
.type("application/pdf")
.header(
"Content-Disposition",
// Quotation numbers contain "/" segments — not header- or
// filename-safe raw; the helper handles non-ASCII per RFC 5987.
contentDisposition(
"inline",
`Nabidka-${(offer.quotation_number || "").replace(/\//g, "-")}.pdf`,
),
)
.send(pdfBuffer);
} catch (err) {
request.log.error(err, "Offer /file live-render fallback failed");
return error(reply, "Chyba při generování PDF", 500);
}
},
);
}

View File

@@ -6,6 +6,7 @@ import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { contentDisposition } from "../../utils/content-disposition";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
@@ -14,6 +15,7 @@ import {
} from "../../schemas/received-invoices.schema";
import { nasInvoicesManager } from "../../services/nas-financials-manager";
import { toCzk } from "../../services/exchange-rates";
import { utcMidnightOfLocalDay } from "../../utils/date";
import path from "path";
const VALID_STATUSES = ["unpaid", "paid"] as const;
@@ -294,10 +296,14 @@ export default async function receivedInvoicesRoutes(
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
const mime = invoice.file_mime || "application/pdf";
const safeFileName = invoice.file_name.replace(/[\r\n"]/g, "");
// "inline": the FE (ReceivedInvoices.openFile) opens this in a new
// window for viewing, not as a forced download.
return reply
.type(mime)
.header("Content-Disposition", `inline; filename="${safeFileName}"`)
.header(
"Content-Disposition",
contentDisposition("inline", invoice.file_name),
)
.send(nasFile.data);
},
);
@@ -556,14 +562,17 @@ export default async function receivedInvoicesRoutes(
// `amount` is the GROSS total (VAT included); VAT is the portion within it.
const computedVat = vatFromGross(finalAmount, finalVatRate);
// Auto-set paid_date when status transitions to paid (matching PHP)
// Auto-set paid_date when status transitions to paid (matching PHP).
// paid_date is @db.Date (truncated to the UTC date part) —
// utcMidnightOfLocalDay keeps the LOCAL calendar day even during the
// 00:0002:00 Prague window (a bare new Date() stored yesterday there).
const newStatus =
body.status !== undefined
? String(body.status)
: String(existing.status);
const paidDate =
newStatus === "paid" && String(existing.status) !== "paid"
? new Date()
? utcMidnightOfLocalDay()
: body.paid_date !== undefined
? body.paid_date
? new Date(String(body.paid_date))

View File

@@ -264,7 +264,8 @@ export default async function totpRoutes(
async (request, reply) => {
const parsed = parseBody(TotpBackupSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400);
const { login_token, backup_code: code } = parsed.data;
const { login_token, backup_code: code, remember_me } = parsed.data;
const rememberMe = remember_me ?? false;
const tokenHash = crypto
.createHash("sha256")
@@ -393,14 +394,18 @@ export default async function totpRoutes(
.update(refreshTokenRaw)
.digest("hex");
// Mirror /login/totp: honor remember_me so a backup-code login keeps
// the same session longevity the user asked for at the password step.
const refreshExpiresIn = rememberMe
? config.jwt.refreshTokenRememberExpiry
: config.jwt.refreshTokenSessionExpiry;
await prisma.refresh_tokens.create({
data: {
user_id: user.id,
token_hash: refreshTokenHash,
expires_at: new Date(
Date.now() + config.jwt.refreshTokenSessionExpiry * 1000,
),
remember_me: false,
expires_at: new Date(Date.now() + refreshExpiresIn * 1000),
remember_me: rememberMe,
ip_address: request.ip,
user_agent: request.headers["user-agent"] ?? null,
},
@@ -411,7 +416,7 @@ export default async function totpRoutes(
secure: config.isProduction,
sameSite: "strict",
path: "/api/admin",
maxAge: config.jwt.refreshTokenSessionExpiry,
maxAge: refreshExpiresIn,
});
await logAudit({

View File

@@ -6,6 +6,99 @@ import { success, paginated, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import { CreateTripSchema, UpdateTripSchema } from "../../schemas/trips.schema";
import { AuthData } from "../../types";
/**
* Shared filter/scoping logic for the trip list AND the stats endpoint — the
* stats MUST honor exactly the same filters as the list so the cards always
* describe the rows the list is paging through.
*
* Scoping: admins / trips.manage holders see all trips (optionally filtered
* by user_id); everyone else is hard-scoped to their own trips.
*/
function buildTripsWhere(
query: Record<string, unknown>,
authData: AuthData,
): Record<string, unknown> {
// Admin role bypasses permission checks entirely (requireAnyPermission
// lets it through with an empty permissions list), so treat the admin
// role as a manager for scoping purposes too.
const isManager =
authData.roleName === "admin" ||
authData.permissions.includes("trips.manage");
const where: Record<string, unknown> = {};
if (!isManager) where.user_id = authData.userId;
else if (query.user_id) {
// Non-numeric filter values are ignored (like an invalid month below) —
// NaN in a Prisma where clause would throw and surface as a 500.
const uid = Number(query.user_id);
if (Number.isFinite(uid)) where.user_id = uid;
}
if (query.vehicle_id) {
const vid = Number(query.vehicle_id);
if (Number.isFinite(vid)) where.vehicle_id = vid;
}
// Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats
if (query.month) {
const monthStr = String(query.month);
let yr: number, mo: number;
if (monthStr.includes("-")) {
// Combined YYYY-MM format
const [yStr, mStr] = monthStr.split("-");
yr = Number(yStr);
mo = Number(mStr);
} else if (query.year) {
yr = Number(query.year);
mo = Number(query.month);
} else {
yr = NaN;
mo = NaN;
}
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
// Use explicit date strings to avoid toJSON timezone shift
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
const nextMonth =
mo === 12
? `${yr + 1}-01-01`
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
where.trip_date = {
gte: new Date(monthStart),
lt: new Date(nextMonth),
};
}
}
return where;
}
/**
* Recompute a vehicle's actual_km as MAX(end_km) over its trips, falling
* back to initial_km when the vehicle has no trips. Shared by the POST,
* PUT and DELETE handlers so the odometer logic can't drift.
*/
async function recomputeVehicleActualKm(vehicleId: number): Promise<void> {
const [maxTrip, vehicle] = await Promise.all([
prisma.trips.findFirst({
where: { vehicle_id: vehicleId },
orderBy: { end_km: "desc" },
select: { end_km: true },
}),
prisma.vehicles.findUnique({
where: { id: vehicleId },
select: { initial_km: true },
}),
]);
await prisma.vehicles.update({
where: { id: vehicleId },
data: {
actual_km: maxTrip
? Number(maxTrip.end_km)
: Number(vehicle?.initial_km ?? 0),
},
});
}
export default async function tripsRoutes(
fastify: FastifyInstance,
@@ -22,55 +115,16 @@ export default async function tripsRoutes(
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!;
// Admin role bypasses permission checks entirely (requireAnyPermission
// lets it through with an empty permissions list), so treat the admin
// role as a manager for scoping purposes too.
const isAdmin =
authData.roleName === "admin" ||
authData.permissions.includes("trips.manage");
const where: Record<string, unknown> = {};
if (!isAdmin) where.user_id = authData.userId;
else if (query.user_id) where.user_id = Number(query.user_id);
if (query.vehicle_id) where.vehicle_id = Number(query.vehicle_id);
// Support both "month=3&year=2026" (TripsAdmin) and "month=2026-03" (TripsHistory) formats
if (query.month) {
const monthStr = String(query.month);
let yr: number, mo: number;
if (monthStr.includes("-")) {
// Combined YYYY-MM format
const [yStr, mStr] = monthStr.split("-");
yr = Number(yStr);
mo = Number(mStr);
} else if (query.year) {
yr = Number(query.year);
mo = Number(query.month);
} else {
yr = NaN;
mo = NaN;
}
if (!isNaN(yr) && !isNaN(mo) && mo >= 1 && mo <= 12) {
// Use explicit date strings to avoid toJSON timezone shift
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
const nextMonth =
mo === 12
? `${yr + 1}-01-01`
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
where.trip_date = {
gte: new Date(monthStart),
lt: new Date(nextMonth),
};
}
}
const where = buildTripsWhere(query, request.authData!);
const [trips, total] = await Promise.all([
prisma.trips.findMany({
where,
skip,
take: limit,
orderBy: { trip_date: order },
// trip_date is date-only — same-day rows need the id tiebreak for
// deterministic pagination.
orderBy: [{ trip_date: order }, { id: order }],
include: {
users: { select: { id: true, first_name: true, last_name: true } },
vehicles: { select: { id: true, name: true, spz: true } },
@@ -83,6 +137,60 @@ export default async function tripsRoutes(
},
);
// GET /api/admin/trips/stats — count/km totals over the WHOLE filtered set
// (not one page). Honors exactly the same filters + user scoping as the
// list (month/year, vehicle_id, user_id for managers) so the stat cards
// can't drift from the rows the list is paging through.
fastify.get(
"/stats",
{
preHandler: requireAnyPermission(
"trips.manage",
"trips.record",
"trips.history",
),
},
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const where = buildTripsWhere(query, request.authData!);
// Legacy PHP-era rows can carry a stored `distance` that differs from
// end_km - start_km, and every row renderer prefers it
// (`distance ?? end_km - start_km`) — the totals must apply the same
// coalesce or the stat cards would disagree with the visible rows.
// That rules out a plain groupBy aggregate.
const rows = await prisma.trips.findMany({
where,
select: {
distance: true,
start_km: true,
end_km: true,
is_business: true,
},
});
let count = 0;
let businessKm = 0;
let privateKm = 0;
for (const t of rows) {
const km =
t.distance != null
? Number(t.distance)
: Number(t.end_km) - Number(t.start_km);
count += 1;
if (t.is_business) businessKm += km;
else privateKm += km;
}
return success(reply, {
count,
total_km: businessKm + privateKm,
business_km: businessKm,
private_km: privateKm,
});
},
);
// GET /api/admin/trips/users — users with trips.record permission
fastify.get(
"/users",
@@ -121,28 +229,11 @@ export default async function tripsRoutes(
{ preHandler: requirePermission("trips.manage") },
async (request, reply) => {
const query = request.query as Record<string, unknown>;
const filterUserId = query.user_id ? Number(query.user_id) : null;
const filterVehicleId = query.vehicle_id
? Number(query.vehicle_id)
: null;
const where: Record<string, unknown> = {};
if (filterUserId) where.user_id = filterUserId;
if (filterVehicleId) where.vehicle_id = filterVehicleId;
if (query.month && query.year) {
// Use explicit date strings to avoid toJSON timezone shift
const yr = Number(query.year);
const mo = Number(query.month);
const monthStart = `${yr}-${String(mo).padStart(2, "0")}-01`;
const nextMonth =
mo === 12
? `${yr + 1}-01-01`
: `${yr}-${String(mo + 1).padStart(2, "0")}-01`;
where.trip_date = {
gte: new Date(monthStart),
lt: new Date(nextMonth),
};
}
// Same shared filter source as the list + stats endpoints (incl. the
// month 1-12 validation). /print is manager-only (trips.manage guard),
// so buildTripsWhere always takes its manager branch here — user_id /
// vehicle_id / month filters behave exactly as before.
const where = buildTripsWhere(query, request.authData!);
const trips = await prisma.trips.findMany({
where,
@@ -150,7 +241,8 @@ export default async function tripsRoutes(
users: { select: { id: true, first_name: true, last_name: true } },
vehicles: { select: { id: true, name: true, spz: true } },
},
orderBy: { trip_date: "asc" },
// trip_date is date-only — id tiebreak keeps same-day rows stable
orderBy: [{ trip_date: "asc" }, { id: "asc" }],
});
const vehicles = await prisma.vehicles.findMany({
@@ -166,7 +258,13 @@ export default async function tripsRoutes(
let businessKm = 0;
let privateKm = 0;
for (const t of trips) {
const dist = Number(t.end_km) - Number(t.start_km);
// Same coalesce as the row renderers and /stats: legacy rows may
// store a `distance` differing from end_km - start_km, and the print
// footer must match the sum of the printed rows.
const dist =
t.distance != null
? Number(t.distance)
: Number(t.end_km) - Number(t.start_km);
totalKm += dist;
if (t.is_business) businessKm += dist;
else privateKm += dist;
@@ -251,21 +349,13 @@ export default async function tripsRoutes(
},
});
// Recompute actual_km from MAX(end_km) across all trips (same as
// PUT/DELETE). Setting it unconditionally to this trip's end_km would
// regress the odometer when a back-dated trip with a lower end_km is
// entered after a later trip with a higher reading.
const maxTrip = await prisma.trips.findFirst({
where: { vehicle_id: vehicleId },
orderBy: { end_km: "desc" },
select: { end_km: true },
});
if (maxTrip) {
await prisma.vehicles.update({
where: { id: vehicleId },
data: { actual_km: Number(maxTrip.end_km) },
});
}
// Recompute actual_km from MAX(end_km) across all trips (same helper
// as PUT/DELETE). Setting it unconditionally to this trip's end_km
// would regress the odometer when a back-dated trip with a lower
// end_km is entered after a later trip with a higher reading. (The
// helper's initial_km fallback is unreachable here — the just-created
// trip guarantees at least one row.)
await recomputeVehicleActualKm(vehicleId);
await logAudit({
request,
@@ -312,6 +402,19 @@ export default async function tripsRoutes(
}
const data: Record<string, unknown> = {};
if (body.vehicle_id !== undefined) {
const targetVehicleId = Number(body.vehicle_id);
if (targetVehicleId !== existing.vehicle_id) {
// Schema only guarantees a positive integer — without this check a
// nonexistent id would hit the FK as P2003 and surface as a 500.
const vehicleExists = await prisma.vehicles.findUnique({
where: { id: targetVehicleId },
select: { id: true },
});
if (!vehicleExists) return error(reply, "Vozidlo nenalezeno", 400);
}
data.vehicle_id = targetVehicleId;
}
if (body.trip_date !== undefined)
data.trip_date = new Date(String(body.trip_date));
if (body.start_km !== undefined) data.start_km = Number(body.start_km);
@@ -325,20 +428,19 @@ export default async function tripsRoutes(
await prisma.trips.update({ where: { id }, data });
// Update vehicle actual_km if end_km changed
if (body.end_km !== undefined) {
const vehicleId = existing.vehicle_id;
const maxTrip = await prisma.trips.findFirst({
where: { vehicle_id: vehicleId },
orderBy: { end_km: "desc" },
select: { end_km: true },
});
if (maxTrip) {
await prisma.vehicles.update({
where: { id: vehicleId },
data: { actual_km: Number(maxTrip.end_km) },
});
}
// Recompute odometers when the km reading or the vehicle changed: the
// trip's (possibly new) vehicle, and — when the trip moved between
// vehicles — the old one too, which may have lost its MAX(end_km) row.
const newVehicleId =
body.vehicle_id !== undefined
? Number(body.vehicle_id)
: existing.vehicle_id;
const vehicleChanged = newVehicleId !== existing.vehicle_id;
if (body.end_km !== undefined || vehicleChanged) {
await recomputeVehicleActualKm(newVehicleId);
}
if (vehicleChanged) {
await recomputeVehicleActualKm(existing.vehicle_id);
}
await logAudit({
@@ -348,6 +450,8 @@ export default async function tripsRoutes(
entityType: "trip",
entityId: id,
description: `Upravena jízda`,
oldValues: existing,
newValues: data,
});
return success(reply, { id }, 200, "Záznam byl aktualizován");
},
@@ -372,24 +476,9 @@ export default async function tripsRoutes(
const vehicleId = existing.vehicle_id;
await prisma.trips.delete({ where: { id } });
// Recalculate vehicle actual_km after deletion
const maxTrip = await prisma.trips.findFirst({
where: { vehicle_id: vehicleId },
orderBy: { end_km: "desc" },
select: { end_km: true },
});
const vehicle = await prisma.vehicles.findUnique({
where: { id: vehicleId },
select: { initial_km: true },
});
await prisma.vehicles.update({
where: { id: vehicleId },
data: {
actual_km: maxTrip
? Number(maxTrip.end_km)
: (vehicle?.initial_km ?? 0),
},
});
// Recalculate vehicle actual_km after deletion (falls back to
// initial_km when this was the vehicle's last trip).
await recomputeVehicleActualKm(vehicleId);
await logAudit({
request,

View File

@@ -5,6 +5,7 @@ import { config } from "../../config/env";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response";
import { contentDisposition } from "../../utils/content-disposition";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import { nasInvoicesManager } from "../../services/nas-financials-manager";
@@ -1294,6 +1295,41 @@ export default async function warehouseRoutes(
},
);
// GET /receipts/:id/attachments/:attachmentId — download attachment from NAS
fastify.get<{ Params: { id: string; attachmentId: string } }>(
"/receipts/:id/attachments/:attachmentId",
{ preHandler: requirePermission("warehouse.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;
const attachmentId = parseId(request.params.attachmentId, reply);
if (attachmentId === null) return;
const attachment = await prisma.sklad_receipt_attachments.findUnique({
where: { id: attachmentId },
});
// 404 (not 400) when the attachment belongs to a different receipt:
// a read endpoint should not confirm that the id exists elsewhere.
if (!attachment || attachment.receipt_id !== id) {
return error(reply, "Příloha nenalezena", 404);
}
const nasFile = nasInvoicesManager.readReceived(attachment.file_path);
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
const mime = attachment.file_mime || "application/octet-stream";
// "attachment": the UI always downloads via blob + a.download, never
// renders inline — and attachment is safer with client-controlled mime.
return reply
.type(mime)
.header(
"Content-Disposition",
contentDisposition("attachment", attachment.file_name),
)
.send(nasFile.data);
},
);
// POST /receipts/:id/attachments — upload attachment
fastify.post<{ Params: { id: string } }>(
"/receipts/:id/attachments",

View File

@@ -1,6 +1,8 @@
import { z } from "zod";
import {
intIdFromForm,
isoDateString,
nullableDateTimeString,
nullableIntIdFromForm,
numberFromForm,
numberInRange,
@@ -66,19 +68,28 @@ export const AttendancePunchSchema = z.object({
address: z.string().max(500).nullish(),
});
// arrival/departure/break fields travel as COMBINED local datetimes
// ("YYYY-MM-DDTHH:MM:00", built by the admin forms' combineDatetime from a
// date + time input) — the service parses them with `new Date(...)`. They are
// NOT bare HH:MM times.
export const CreateAttendanceSchema = z.object({
user_id: intIdFromForm.optional(),
shift_date: z.string(),
arrival_time: timeString.nullish(),
// Must be a bare "YYYY-MM-DD" (parses to UTC midnight) — the service's
// duplicate-validation window and the stored @db.Date both depend on it.
// isoDateString leniently strips a trailing time component.
shift_date: isoDateString,
arrival_time: nullableDateTimeString.optional(),
arrival_lat: numberFromForm.nullish(),
arrival_lng: numberFromForm.nullish(),
arrival_accuracy: numberFromForm.nullish(),
arrival_address: z.string().nullish(),
departure_time: timeString.nullish(),
departure_time: nullableDateTimeString.optional(),
departure_lat: numberFromForm.nullish(),
departure_lng: numberFromForm.nullish(),
departure_accuracy: numberFromForm.nullish(),
departure_address: z.string().nullish(),
break_start: nullableDateTimeString.optional(),
break_end: nullableDateTimeString.optional(),
notes: z.string().nullish(),
project_id: nullableIntIdFromForm.nullish(),
leave_type: z
@@ -90,10 +101,10 @@ export const CreateAttendanceSchema = z.object({
});
export const UpdateAttendanceSchema = z.object({
arrival_time: timeString.nullish(),
departure_time: timeString.nullish(),
break_start: timeString.nullish(),
break_end: timeString.nullish(),
arrival_time: nullableDateTimeString.optional(),
departure_time: nullableDateTimeString.optional(),
break_start: nullableDateTimeString.optional(),
break_end: nullableDateTimeString.optional(),
notes: z.string().nullish(),
project_id: nullableIntIdFromForm.optional(),
leave_type: z.enum(["work", "vacation", "sick", "unpaid"]).optional(),

View File

@@ -23,6 +23,7 @@ export const TotpBackupSchema = z.object({
// Cap length so a request with bodyLimit=10KB can't trigger N×bcrypt on
// a multi-KB string. Real backup codes are 8-16 chars; 64 is generous.
.max(64, "Záložní kód je příliš dlouhý"),
remember_me: z.boolean().optional().default(false),
});
export const TotpEnableSchema = z.object({

View File

@@ -61,6 +61,14 @@ export const positiveNumberFromForm = z
message: "Číslo musí být kladné",
});
/** number|string → non-negative integer (e.g. list positions). */
export const nonNegativeIntFromForm = z
.union([z.number(), z.string()])
.transform(coerceNum)
.refine((n) => Number.isInteger(n) && n >= 0, {
message: "Číslo musí být nezáporné celé číslo",
});
/** number|string → positive integer id; rejects NaN/≤0/non-integer. */
export const intIdFromForm = z
.union([z.number(), z.string()])
@@ -111,6 +119,40 @@ export const timeString = z.preprocess(
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Čas musí být ve formátu HH:MM"),
);
/**
* Combined local datetime string "YYYY-MM-DDTHH:MM" — the wire format the
* attendance forms produce by joining a date input and a time input
* (`${date}T${time}:00`). Tolerant of a trailing seconds component (":ss" —
* stripped before validating), because the client appends ":00" and an edit
* form round-trips a value the `Date.toJSON` override serialised as
* "YYYY-MM-DDTHH:MM:SS". The service parses the value with `new Date(...)`
* as LOCAL time (Europe/Prague) — no timezone suffix is part of the format.
*/
export const dateTimeString = z.preprocess(
(v) =>
typeof v === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)
? v.slice(0, 16)
: v,
z
.string()
.regex(
/^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d$/,
"Datum a čas musí být ve formátu YYYY-MM-DD HH:MM",
),
);
/**
* Optional-datetime variant: ""/null mean "not set" → null (the attendance
* forms historically submitted empty strings for unfilled time fields, and a
* hard reject would 400 the whole form — including leave records that have no
* times at all). Pair with `.optional()` at the call site for fields that may
* be absent entirely (absent stays `undefined`, e.g. "don't change" on update).
*/
export const nullableDateTimeString = z.preprocess(
(v) => (v === "" || v === null ? null : v),
z.union([z.null(), dateTimeString]),
);
/**
* An email field that also accepts an empty string (meaning "not set"). Many
* settings/supplier forms submit "" for un-filled optional emails; a bare
@@ -120,3 +162,17 @@ export const emailOrEmpty = z.union([
z.literal(""),
z.string().max(255).email("Neplatný email"),
]);
/**
* Shared rich-text document section — offers' scope_sections and issued
* orders' issued_order_sections are 1:1 mirror tables, so both schemas use
* this. Limits are DB-aligned: title/title_cz are VarChar(500) (the old
* offers-local copy under-shot at 255), content is Text with the app-level
* 8000 cap used by the other rich-text fields.
*/
export const DocumentSectionSchema = z.object({
title: z.string().max(500).nullish(),
title_cz: z.string().max(500).nullish(),
content: z.string().max(8000).nullish(),
position: nonNegativeIntFromForm.optional(),
});

View File

@@ -54,6 +54,7 @@ export const UpdateInvoiceSchema = z.object({
bank_iban: z.string().max(255).nullish(),
bank_account: z.string().max(255).nullish(),
issued_by: z.string().max(255).nullish(),
billing_text: z.string().max(8000).nullish(),
customer_id: nullableIntIdFromForm.optional(),
vat_rate: numberInRange(0, 100).optional(),
apply_vat: booleanFromForm.optional(),

View File

@@ -1,11 +1,10 @@
import { z } from "zod";
import {
numberInRange,
nonNegativeNumberFromForm,
positiveNumberFromForm,
nullableIntIdFromForm,
booleanFromForm,
isoDateString,
DocumentSectionSchema,
} from "./common";
export const IssuedOrderItemSchema = z.object({
@@ -14,7 +13,6 @@ export const IssuedOrderItemSchema = z.object({
quantity: positiveNumberFromForm.optional(),
unit: z.string().max(20).nullish(),
unit_price: nonNegativeNumberFromForm.optional(),
vat_rate: numberInRange(0, 100).optional(),
position: z.number().int().nonnegative().optional(),
});
@@ -28,21 +26,26 @@ const ISSUED_ORDER_STATUSES = [
export const CreateIssuedOrderSchema = z.object({
po_number: z.string().max(50).nullish(),
customer_id: nullableIntIdFromForm.nullish(),
supplier_id: nullableIntIdFromForm.nullish(),
status: z.enum(ISSUED_ORDER_STATUSES).optional(),
currency: z.string().max(10).optional(),
vat_rate: numberInRange(0, 100).optional(),
apply_vat: booleanFromForm.optional(),
exchange_rate: nonNegativeNumberFromForm.optional(),
order_date: isoDateString.nullish(),
delivery_date: isoDateString.nullish(),
language: z.string().max(5).optional(),
delivery_terms: z.string().max(500).nullish(),
payment_terms: z.string().max(500).nullish(),
issued_by: z.string().max(255).nullish(),
notes: z.string().nullish(),
// Editable heading above the PDF items table; empty/null falls back to the
// default "Objednáváme si u Vás:" (issued-orders-pdf t.billing).
order_text: z.string().max(500).nullish(),
internal_notes: z.string().nullish(),
items: z.array(IssuedOrderItemSchema).optional(),
// 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(),
});
export const UpdateIssuedOrderSchema = CreateIssuedOrderSchema.partial();
// Update = partial create MINUS the number: PO numbers are immutable
// (assigned by the sequence on finalize), so the field is stripped before it
// can ever reach the service (which never reads it on update anyway).
export const UpdateIssuedOrderSchema = CreateIssuedOrderSchema.partial().omit({
po_number: true,
});

View File

@@ -1,59 +1,45 @@
import { z } from "zod";
import {
numberFromForm,
numberInRange,
nonNegativeIntFromForm,
nonNegativeNumberFromForm,
positiveNumberFromForm,
nullableIntIdFromForm,
booleanFromForm,
isoDateString,
DocumentSectionSchema,
} from "./common";
// Limits are DB-aligned: description VarChar(500), unit VarChar(20);
// item_description is Text with the app-level 8000 cap used elsewhere.
const QuotationItemSchema = z.object({
description: z.string().max(8000).nullish(),
description: z.string().max(500).nullish(),
item_description: z.string().max(8000).nullish(),
quantity: positiveNumberFromForm.default(1),
unit: z.string().max(255).nullish(),
unit_price: numberFromForm.default(0),
unit: z.string().max(20).nullish(),
unit_price: nonNegativeNumberFromForm.default(0),
is_included_in_total: booleanFromForm.optional().default(true),
position: nonNegativeNumberFromForm.optional(),
// Int column — a float position would 500 at Prisma.
position: nonNegativeIntFromForm.optional(),
});
const ScopeSectionSchema = z.object({
title: z.string().max(255).nullish(),
title_cz: z.string().max(255).nullish(),
content: z.string().max(8000).nullish(),
position: nonNegativeNumberFromForm.optional(),
});
// Shared with issued orders (scope_sections ≡ issued_order_sections).
const ScopeSectionSchema = DocumentSectionSchema;
// NOTE: no top-level `.default()`s here — `UpdateQuotationSchema` derives from
// this schema via `.partial()`, and Zod 4 still applies a field default when
// the key is absent, which would silently inject status/currency/language
// resets into every update payload. Create-time defaults live in
// `createOffer` (service) instead.
export const CreateQuotationSchema = z.object({
quotation_number: z.string().max(255).nullish(),
project_code: z.string().max(255).nullish(),
// DB: quotations.quotation_number VarChar(50).
quotation_number: z.string().max(50).nullish(),
// DB column is VarChar(100) — a longer value would 500 at Prisma.
project_code: z.string().max(100).nullish(),
customer_id: nullableIntIdFromForm.nullish(),
created_at: z.string().max(255).nullish(),
valid_until: z.string().max(255).nullish(),
currency: z.string().max(20).optional().default("CZK"),
language: z.string().max(20).optional().default("cs"),
vat_rate: numberInRange(0, 100).optional().default(21.0),
apply_vat: booleanFromForm.optional().default(true),
status: z
.enum(["draft", "active", "ordered", "invalidated"])
.optional()
.default("draft"),
scope_title: z.string().max(255).nullish(),
scope_description: z.string().max(8000).nullish(),
items: z.array(QuotationItemSchema).optional(),
sections: z.array(ScopeSectionSchema).optional(),
});
export const UpdateQuotationSchema = z.object({
project_code: z.string().max(255).nullish(),
customer_id: nullableIntIdFromForm.optional(),
created_at: z.union([z.string().max(255), z.null()]).optional(),
valid_until: z.union([z.string().max(255), z.null()]).optional(),
created_at: isoDateString.nullish(),
valid_until: isoDateString.nullish(),
currency: z.string().max(20).optional(),
language: z.string().max(20).optional(),
vat_rate: numberInRange(0, 100).optional(),
apply_vat: booleanFromForm.optional(),
status: z.enum(["draft", "active", "ordered", "invalidated"]).optional(),
scope_title: z.string().max(255).nullish(),
scope_description: z.string().max(8000).nullish(),
@@ -61,5 +47,12 @@ export const UpdateQuotationSchema = z.object({
sections: z.array(ScopeSectionSchema).optional(),
});
// Update = partial create MINUS the number: quotation numbers are immutable
// (assigned by the sequence on finalize), so the field is stripped before it
// can ever reach the service.
export const UpdateQuotationSchema = CreateQuotationSchema.partial().omit({
quotation_number: true,
});
export type CreateQuotationInput = z.infer<typeof CreateQuotationSchema>;
export type UpdateQuotationInput = z.infer<typeof UpdateQuotationSchema>;

View File

@@ -1,7 +1,6 @@
import { z } from "zod";
import {
numberFromForm,
numberInRange,
nonNegativeNumberFromForm,
positiveNumberFromForm,
intIdFromForm,
@@ -42,8 +41,6 @@ export const CreateOrderSchema = z.object({
.default("prijata"),
currency: z.string().max(20).optional().default("CZK"),
language: z.string().max(20).optional().default("cs"),
vat_rate: numberInRange(0, 100).optional().default(21.0),
apply_vat: booleanFromForm.optional().default(true),
exchange_rate: positiveNumberFromForm.optional().default(1.0),
scope_title: z.string().max(255).nullish(),
scope_description: z.string().max(8000).nullish(),
@@ -62,8 +59,6 @@ export const UpdateOrderSchema = z.object({
scope_description: z.string().max(8000).nullish(),
notes: z.string().max(8000).nullish(),
customer_id: nullableIntIdFromForm.optional(),
vat_rate: numberInRange(0, 100).optional(),
apply_vat: booleanFromForm.optional(),
items: z.array(OrderItemSchema).optional(),
sections: z.array(OrderSectionSchema).optional(),
});

View File

@@ -21,6 +21,8 @@ export const CreateTripSchema = z.object({
});
export const UpdateTripSchema = z.object({
// trips.vehicle_id is a non-nullable FK — when present it must be a valid id
vehicle_id: intIdFromForm.optional(),
trip_date: isoDateString.optional(),
start_km: nonNegativeNumberFromForm.optional(),
end_km: nonNegativeNumberFromForm.optional(),

View File

@@ -126,6 +126,8 @@ export interface CreateAttendanceData {
departure_lng?: number | null;
departure_accuracy?: number | null;
departure_address?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string | null;
project_id?: number | null;
leave_type: string;
@@ -187,12 +189,16 @@ export async function getStatus(userId: number) {
const y = now.getFullYear(),
m = now.getMonth(),
d = now.getDate();
const todayStart = new Date(y, m, d, 0, 0, 0);
const todayEnd = new Date(y, m, d, 23, 59, 59);
// shift_date is @db.Date: Prisma compares it by its UTC date part, so the
// range boundaries must be UTC midnights of the LOCAL calendar day. A local
// midnight (= 22:00/23:00 UTC of the previous day) shifts the whole window
// one day back. Half-open [gte, lt) ranges.
const todayStart = new Date(Date.UTC(y, m, d));
const todayEnd = new Date(Date.UTC(y, m, d + 1));
// Monthly fund range (used by query #4)
const monthStart = new Date(y, m, 1);
const monthEnd = new Date(y, m + 1, 0, 23, 59, 59);
const monthStart = new Date(Date.UTC(y, m, 1));
const monthEnd = new Date(Date.UTC(y, m + 1, 1));
// Queries 1-4 are independent of one another → run them in parallel.
const [ongoingShift, todayShiftsRaw, balance, monthRecords] =
@@ -210,7 +216,7 @@ export async function getStatus(userId: number) {
prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: todayStart, lte: todayEnd },
shift_date: { gte: todayStart, lt: todayEnd },
departure_time: { not: null },
},
include: {
@@ -226,7 +232,7 @@ export async function getStatus(userId: number) {
prisma.attendance.findMany({
where: {
user_id: userId,
shift_date: { gte: monthStart, lte: monthEnd },
shift_date: { gte: monthStart, lt: monthEnd },
},
}),
]);
@@ -583,10 +589,13 @@ export async function getWorkfund(year: number) {
: bizDays;
const fund = bizDays * 8;
const fundToDate = bizDaysToDate * 8;
const monthStart = new Date(year, m, 1);
const monthEnd = new Date(year, m + 1, 0, 23, 59, 59);
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight
// month boundaries, half-open range. Local midnights would double-count
// each previous month's last day.
const monthStart = new Date(Date.UTC(year, m, 1));
const monthEnd = new Date(Date.UTC(year, m + 1, 1));
const monthRecords = await prisma.attendance.findMany({
where: { shift_date: { gte: monthStart, lte: monthEnd } },
where: { shift_date: { gte: monthStart, lt: monthEnd } },
select: {
user_id: true,
shift_date: true,
@@ -844,13 +853,16 @@ export async function getPrintData(
const yr = Number(yearStr);
const mo = Number(monthNumStr);
const monthStart = new Date(yr, mo - 1, 1);
const monthEnd = new Date(yr, mo, 0, 23, 59, 59);
// shift_date is @db.Date (compared by UTC date part) → UTC-midnight month
// boundaries, half-open range (local midnights pulled in the previous
// month's last day).
const monthStart = new Date(Date.UTC(yr, mo - 1, 1));
const monthEnd = new Date(Date.UTC(yr, mo, 1));
const users = await getAttendanceUsers();
const where: Record<string, unknown> = {
shift_date: { gte: monthStart, lte: monthEnd },
shift_date: { gte: monthStart, lt: monthEnd },
};
if (filterUserId) where.user_id = filterUserId;
@@ -1041,9 +1053,12 @@ export async function listAttendance(params: ListAttendanceParams) {
where.user_id = params.userId;
}
if (params.month && params.year) {
// shift_date is @db.Date: Prisma compares by UTC date part, so the month
// boundaries must be UTC midnights. Local midnights shifted the window a
// day back (included prev-month's last day, dropped this month's last day).
where.shift_date = {
gte: new Date(params.year, params.month - 1, 1),
lt: new Date(params.year, params.month, 1),
gte: new Date(Date.UTC(params.year, params.month - 1, 1)),
lt: new Date(Date.UTC(params.year, params.month, 1)),
};
}
@@ -1525,15 +1540,25 @@ export async function createAttendance(
) {
const userId = data.user_id ?? authUserId;
const shiftDate = new Date(data.shift_date);
// shift_date arrives as "YYYY-MM-DD" → parsed as UTC midnight, and the
// @db.Date column is compared by UTC date part. The duplicate/overlap
// window must therefore be the UTC day [shiftDate, shiftDate+1) — building
// it from LOCAL getters made both bounds 22:00/23:00 UTC of the previous
// day, so the validation queried ONLY yesterday's records (same-day
// overlaps undetected, false duplicates when yesterday had records).
const startOfDay = new Date(
shiftDate.getFullYear(),
shiftDate.getMonth(),
shiftDate.getDate(),
Date.UTC(
shiftDate.getUTCFullYear(),
shiftDate.getUTCMonth(),
shiftDate.getUTCDate(),
),
);
const endOfDay = new Date(
shiftDate.getFullYear(),
shiftDate.getMonth(),
shiftDate.getDate() + 1,
Date.UTC(
shiftDate.getUTCFullYear(),
shiftDate.getUTCMonth(),
shiftDate.getUTCDate() + 1,
),
);
// Multiple work shifts per day are allowed — only block when the new
@@ -1608,6 +1633,8 @@ export async function createAttendance(
departure_lng: data.departure_lng ?? null,
departure_accuracy: data.departure_accuracy ?? null,
departure_address: data.departure_address ?? null,
break_start: data.break_start ? new Date(data.break_start) : null,
break_end: data.break_end ? new Date(data.break_end) : null,
notes: data.notes ?? null,
project_id: data.project_id ?? null,
leave_type: data.leave_type as attendance_leave_type,

View File

@@ -1,7 +1,11 @@
import prisma from "../config/database";
import { config } from "../config/env";
import { sendMail } from "./mailer";
import { localDateCzStr, localDateStr } from "../utils/date";
import {
localDateCzStr,
localDateStr,
utcMidnightOfLocalDay,
} from "../utils/date";
import { getSystemSettings } from "./system-settings";
interface AlertInvoice {
@@ -33,17 +37,35 @@ function formatAmount(n: number | { toNumber?: () => number }): string {
});
}
/**
* Alert window [today, today+3] for the due-date queries + the local date
* strings the classifier matches against.
*
* due_date is @db.Date: Prisma truncates the filter Dates to their UTC date
* part, so the window boundaries must be UTC midnights of the LOCAL calendar
* day. A local midnight (22:00/23:00 UTC of the previous day) shifted the
* whole window a day early — the "splatnost za 3 dny" advance alert
* (due == today+3) could then never fire.
*
* Exported (with an injectable `now`) so the window math is unit-testable.
*/
export function computeAlertWindow(now: Date = new Date()) {
const today = utcMidnightOfLocalDay(now);
const todayStr = localDateStr(now);
const in3days = new Date(today);
in3days.setUTCDate(in3days.getUTCDate() + 3);
const in3daysStr = localDateStr(
new Date(now.getFullYear(), now.getMonth(), now.getDate() + 3),
);
return { today, todayStr, in3days, in3daysStr };
}
export async function checkInvoiceAlerts(): Promise<void> {
const settings = await getSystemSettings();
const alertEmail = settings.invoice_alert_email || config.email.invoiceAlert;
if (!alertEmail) return;
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayStr = localDateStr(today);
const in3days = new Date(today);
in3days.setDate(in3days.getDate() + 3);
const in3daysStr = localDateStr(in3days);
const { today, todayStr, in3days, in3daysStr } = computeAlertWindow();
// Classify a due date into an alert type/label, or null if it doesn't match.
const classify = (

View File

@@ -1,4 +1,5 @@
import prisma from "../config/database";
import { utcMidnightOfLocalDay } from "../utils/date";
import { toCzk } from "./exchange-rates";
import {
generateInvoiceNumber,
@@ -139,8 +140,12 @@ function buildInvoiceWhere(
if (status) where.status = status;
if (customer_id) where.customer_id = customer_id;
if (month && year) {
const from = new Date(year, month - 1, 1);
const to = new Date(year, month, 1);
// issue_date is @db.Date: Prisma compares by UTC date part, so the month
// boundaries must be UTC midnights. Local midnights shifted the window a
// day back — the filter included the previous month's last day and
// DROPPED invoices issued on the selected month's last day.
const from = new Date(Date.UTC(year, month - 1, 1));
const to = new Date(Date.UTC(year, month, 1));
where.issue_date = { gte: from, lt: to };
}
if (search) {
@@ -180,13 +185,18 @@ function computeInvoiceTotals(
export async function markOverdueInvoices() {
try {
// due_date is @db.Date (UTC midnight of the calendar day). Compare
// against UTC midnight of the LOCAL today — a bare new Date() has
// yesterday's UTC date during the 00:0002:00 Prague window, so the
// overdue flip lagged a day there. Due TODAY = not yet overdue.
const today = utcMidnightOfLocalDay();
await prisma.invoices.updateMany({
where: { status: "issued", due_date: { lt: new Date() } },
where: { status: "issued", due_date: { lt: today } },
data: { status: "overdue" },
});
// Reverse: if due_date was changed to future, set back to issued
// Reverse: if due_date was changed to today/future, set back to issued
await prisma.invoices.updateMany({
where: { status: "overdue", due_date: { gte: new Date() } },
where: { status: "overdue", due_date: { gte: today } },
data: { status: "issued" },
});
} catch (err) {
@@ -312,10 +322,13 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
const year = queryYear || now.getFullYear();
const month = queryMonth || now.getMonth() + 1;
const monthStart = new Date(year, month - 1, 1);
const monthEnd = new Date(year, month, 0, 23, 59, 59);
const startOfYear = new Date(year, 0, 1);
const endOfYear = new Date(year, 11, 31, 23, 59, 59);
// issue_date is @db.Date (compared by UTC date part) → UTC-midnight period
// boundaries, half-open [gte, lt) ranges. Local-midnight bounds included
// the previous period's boundary day in the stats.
const monthStart = new Date(Date.UTC(year, month - 1, 1));
const nextMonthStart = new Date(Date.UTC(year, month, 1));
const startOfYear = new Date(Date.UTC(year, 0, 1));
const startOfNextYear = new Date(Date.UTC(year + 1, 0, 1));
const [monthInvoices, awaitingInvoices, overdueInvoices] = await Promise.all([
prisma.invoices.findMany({
@@ -323,21 +336,21 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
// Drafts are not real financial documents — exclude them so their
// VAT/amounts never inflate the monthly stats (vat_month etc.).
status: { not: "draft" },
issue_date: { gte: monthStart, lte: monthEnd },
issue_date: { gte: monthStart, lt: nextMonthStart },
},
include: { invoice_items: true },
}),
prisma.invoices.findMany({
where: {
status: "issued",
issue_date: { gte: startOfYear, lte: endOfYear },
issue_date: { gte: startOfYear, lt: startOfNextYear },
},
include: { invoice_items: true },
}),
prisma.invoices.findMany({
where: {
status: "overdue",
issue_date: { gte: startOfYear, lte: endOfYear },
issue_date: { gte: startOfYear, lt: startOfNextYear },
},
include: { invoice_items: true },
}),
@@ -430,6 +443,9 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
export async function getOrderDataForInvoice(orderId: number) {
const order = await prisma.orders.findUnique({
where: { id: orderId },
// This result is spread into the order-data API response — never include
// the PO attachment blob (served only by GET /orders/:id/attachment).
omit: { attachment_data: true },
include: {
customers: true,
order_items: { orderBy: { position: "asc" } },
@@ -598,9 +614,11 @@ export async function updateInvoice(id: number, body: InvoiceInput) {
// Status change
if (body.status !== undefined) {
data.status = String(body.status);
// Auto-set paid_date when transitioning to paid
// Auto-set paid_date when transitioning to paid. paid_date is @db.Date
// (truncated to the UTC date part) — utcMidnightOfLocalDay keeps the
// LOCAL calendar day even during the 00:0002:00 Prague window.
if (String(body.status) === "paid" && !existing.paid_date) {
data.paid_date = new Date();
data.paid_date = utcMidnightOfLocalDay();
}
}

View File

@@ -1,11 +1,14 @@
import type { $Enums } from "@prisma/client";
import { Prisma, type $Enums } from "@prisma/client";
import prisma from "../config/database";
import { utcMidnightOfLocalDay } from "../utils/date";
import {
generateIssuedOrderNumber,
previewIssuedOrderNumber,
releaseIssuedOrderNumber,
assignIssuedOrderNumber,
isIssuedOrderNumberTaken,
} from "./numbering.service";
import { nasOrdersManager } from "./nas-financials-manager";
export interface IssuedOrderItemInput {
description?: string | null;
@@ -13,34 +16,36 @@ export interface IssuedOrderItemInput {
quantity?: number | string | null;
unit?: string | null;
unit_price?: number | string | null;
vat_rate?: number | string | null;
position?: number | null;
}
export interface IssuedOrderSectionInput {
title?: string | null;
title_cz?: string | null;
content?: string | null;
position?: number | null;
}
export interface IssuedOrderInput {
po_number?: string | number | null;
customer_id?: number | string | null;
supplier_id?: number | string | null;
status?: string;
currency?: string;
vat_rate?: number | string | null;
apply_vat?: boolean | number | string;
exchange_rate?: number | string | null;
order_date?: string | null;
delivery_date?: string | null;
language?: string;
delivery_terms?: string | null;
payment_terms?: string | null;
issued_by?: string | null;
notes?: string | null;
order_text?: string | null;
internal_notes?: string | null;
items?: IssuedOrderItemInput[];
sections?: IssuedOrderSectionInput[];
[key: string]: unknown;
}
interface IssuedOrderFilterParams {
search?: string;
status?: string;
customer_id?: number;
supplier_id?: number;
month?: number;
year?: number;
}
@@ -66,20 +71,23 @@ export interface CurrencyAmount {
function buildIssuedOrderWhere(
params: IssuedOrderFilterParams,
): Record<string, unknown> {
const { search, status, customer_id, month, year } = params;
const { search, status, supplier_id, month, year } = params;
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (customer_id) where.customer_id = customer_id;
if (supplier_id) where.supplier_id = supplier_id;
if (search) {
where.OR = [
{ po_number: { contains: search } },
{ customers: { name: { contains: search } } },
{ customers: { company_id: { contains: search } } },
{ suppliers: { name: { contains: search } } },
{ suppliers: { ico: { contains: search } } },
];
}
if (month && year) {
const from = new Date(year, month - 1, 1);
const to = new Date(year, month, 1);
// order_date is @db.Date: Prisma compares by UTC date part, so the month
// boundaries must be UTC midnights. Local midnights shifted the window a
// day back (included prev-month's last day, dropped this month's last day).
const from = new Date(Date.UTC(year, month - 1, 1));
const to = new Date(Date.UTC(year, month, 1));
where.order_date = { gte: from, lt: to };
}
return where;
@@ -98,35 +106,41 @@ const ALLOWED_SORT_FIELDS = [
"po_number",
"status",
"order_date",
"created_at",
"currency",
];
/** NET base + VAT-on-top, rounded per line before accumulation (mirrors invoices). */
/**
* Non-status update fields — used by the editable-state guard so a field edit
* on a confirmed/completed/cancelled order is rejected EXPLICITLY (it used to
* be silently dropped). `po_number` is stripped by the schema and the service
* never reads it on update.
*/
const NON_STATUS_UPDATE_FIELDS = [
"supplier_id",
"currency",
"exchange_rate",
"order_date",
"delivery_date",
"language",
"order_text",
"internal_notes",
"items",
"sections",
] as const;
/**
* NET-only total: issued orders (PO) are not tax documents — VAT never appears
* on them. The total is the plain sum of qty × unit_price, rounded to 2dp.
*/
export function computeIssuedOrderTotals(
items: Array<{ quantity: unknown; unit_price: unknown; vat_rate: unknown }>,
applyVat: boolean | null,
defaultVatRate: unknown,
items: Array<{ quantity: unknown; unit_price: unknown }>,
) {
let subtotal = 0;
let vat = 0;
let total = 0;
for (const it of items) {
const base = (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
subtotal += base;
if (applyVat) {
const rate =
it.vat_rate != null && it.vat_rate !== ""
? Number(it.vat_rate)
: defaultVatRate != null
? Number(defaultVatRate)
: 21;
vat += Math.round(base * (rate / 100) * 100) / 100;
}
total += (Number(it.quantity) || 0) * (Number(it.unit_price) || 0);
}
return {
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vat * 100) / 100,
total: Math.round((subtotal + vat) * 100) / 100,
};
return { total: Math.round(total * 100) / 100 };
}
export async function listIssuedOrders(params: ListIssuedOrdersParams) {
@@ -150,7 +164,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
take: limit,
orderBy,
include: {
customers: { select: { id: true, name: true } },
suppliers: { select: { id: true, name: true } },
issued_order_items: true,
},
}),
@@ -158,16 +172,12 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
]);
const enriched = rows.map((o) => {
const totals = computeIssuedOrderTotals(
o.issued_order_items,
o.apply_vat,
o.vat_rate,
);
const totals = computeIssuedOrderTotals(o.issued_order_items);
const { issued_order_items, ...rest } = o;
return {
...rest,
items: issued_order_items,
customer_name: o.customers?.name || null,
supplier_name: o.suppliers?.name || null,
...totals,
};
});
@@ -176,7 +186,7 @@ export async function listIssuedOrders(params: ListIssuedOrdersParams) {
}
/**
* Sum issued-order TOTAL (incl. VAT) per currency across the WHOLE filtered set
* Sum issued-order NET total per currency across the WHOLE filtered set
* (not a single page). Reuses `buildIssuedOrderWhere` so filters track the list,
* and `computeIssuedOrderTotals` so per-order math matches the list/detail.
* Currency defaults to "CZK" when the column is null. Returns one entry per
@@ -194,11 +204,7 @@ export async function getIssuedOrderTotals(
const byCurrency: Record<string, number> = {};
for (const o of rows) {
const { total } = computeIssuedOrderTotals(
o.issued_order_items,
o.apply_vat,
o.vat_rate,
);
const { total } = computeIssuedOrderTotals(o.issued_order_items);
const cur = o.currency || "CZK";
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(total) || 0);
}
@@ -217,87 +223,144 @@ export async function getIssuedOrder(id: number) {
const order = await prisma.issued_orders.findUnique({
where: { id },
include: {
customers: true,
// Same minimal field set as the picker lookup endpoint — the full row
// would expose internal notes/contact to any orders.view holder.
suppliers: {
select: {
id: true,
name: true,
ico: true,
dic: true,
address: true,
email: true,
phone: true,
},
},
issued_order_items: { orderBy: { position: "asc" } },
// id tiebreak: position can repeat, keep the order deterministic.
issued_order_sections: {
orderBy: [{ position: "asc" }, { id: "asc" }],
},
},
});
if (!order) return null;
const { issued_order_items, ...rest } = order;
const { issued_order_items, issued_order_sections, ...rest } = order;
return {
...rest,
items: issued_order_items,
customer: order.customers,
customer_name: order.customers?.name || null,
sections: issued_order_sections,
supplier: order.suppliers,
supplier_name: order.suppliers?.name || null,
valid_transitions: VALID_TRANSITIONS[order.status as string] || [],
};
}
export async function createIssuedOrder(body: IssuedOrderInput) {
return prisma.$transaction(async (tx) => {
const status = body.status ? String(body.status) : "draft";
try {
return await prisma.$transaction(async (tx) => {
const status = body.status ? String(body.status) : "draft";
// Deferred numbering: a draft carries NO po_number (the column is
// nullable-unique so many drafts coexist). The official number is consumed
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
// A caller-supplied number, or a doc created already-finalized, numbers now.
const explicit =
body.po_number !== undefined &&
body.po_number !== null &&
body.po_number !== ""
? String(body.po_number)
: null;
const poNumber =
explicit ??
(status === "draft"
? null
: (await generateIssuedOrderNumber(undefined, tx)).number);
// 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;
if (supplierId) {
const supplier = await tx.sklad_suppliers.findUnique({
where: { id: supplierId },
select: { id: true },
});
if (!supplier) return { error: "supplier_not_found" as const };
}
const order = await tx.issued_orders.create({
data: {
po_number: poNumber,
customer_id: body.customer_id ? Number(body.customer_id) : null,
status: status as $Enums.issued_orders_status,
currency: body.currency ? String(body.currency) : "CZK",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
exchange_rate:
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
order_date: body.order_date
? new Date(String(body.order_date))
: new Date(),
delivery_date: body.delivery_date
? new Date(String(body.delivery_date))
: null,
language: body.language ? String(body.language) : "cs",
delivery_terms: body.delivery_terms
? String(body.delivery_terms)
: null,
payment_terms: body.payment_terms ? String(body.payment_terms) : null,
issued_by: body.issued_by ? String(body.issued_by) : null,
notes: body.notes ? String(body.notes) : null,
internal_notes: body.internal_notes
? String(body.internal_notes)
: null,
},
});
// Deferred numbering: a draft carries NO po_number (the column is
// nullable-unique so many drafts coexist). The official number is consumed
// only when the draft is finalized (assignIssuedOrderNumber on draft->sent).
// A caller-supplied number, or a doc created already-finalized, numbers now.
const explicit =
body.po_number !== undefined &&
body.po_number !== null &&
body.po_number !== ""
? String(body.po_number)
: null;
if (Array.isArray(body.items)) {
await tx.issued_order_items.createMany({
data: body.items.map((item, i) => ({
issued_order_id: order.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
// Re-check caller-supplied uniqueness INSIDE the transaction so the
// check-then-insert window can't race a concurrent create (mirrors
// createOffer). generateIssuedOrderNumber verifies its own numbers,
// so this guards only the explicit path.
if (explicit !== null && (await isIssuedOrderNumberTaken(explicit, tx))) {
return { error: "po_number_taken" as const };
}
const poNumber =
explicit ??
(status === "draft"
? null
: (await generateIssuedOrderNumber(undefined, tx)).number);
const order = await tx.issued_orders.create({
data: {
po_number: poNumber,
supplier_id: supplierId,
status: status as $Enums.issued_orders_status,
currency: body.currency ? String(body.currency) : "CZK",
exchange_rate:
body.exchange_rate != null ? Number(body.exchange_rate) : 1.0,
// order_date is @db.Date (truncated to the UTC date part) — the
// "today" default must be utcMidnightOfLocalDay, or it stores
// yesterday during the 00:0002:00 Prague window.
order_date: body.order_date
? new Date(String(body.order_date))
: utcMidnightOfLocalDay(),
delivery_date: body.delivery_date
? new Date(String(body.delivery_date))
: null,
language: body.language ? String(body.language) : "cs",
order_text: body.order_text ? String(body.order_text) : null,
internal_notes: body.internal_notes
? String(body.internal_notes)
: null,
},
});
}
return order;
});
if (Array.isArray(body.items)) {
await tx.issued_order_items.createMany({
data: body.items.map((item, i) => ({
issued_order_id: order.id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
position: item.position ?? i,
})),
});
}
if (Array.isArray(body.sections)) {
await tx.issued_order_sections.createMany({
data: body.sections.map((s, i) => ({
issued_order_id: order.id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
return order;
});
} catch (err) {
// P2002 on the po_number unique index = a concurrent insert won the race
// between the in-tx check and our insert — surface the same clean
// conflict token instead of a 500 (mirrors offers).
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return { error: "po_number_taken" as const };
}
throw err;
}
}
export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
@@ -314,28 +377,37 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
}
}
// Editable-state guard: non-status fields may change only in draft/sent.
// In confirmed/completed/cancelled a field edit is REJECTED explicitly
// (offers parity — it used to be silently ignored). Status-only transition
// payloads (e.g. confirmed -> completed) still work.
const editable = currentStatus === "draft" || currentStatus === "sent";
if (
!editable &&
NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined)
) {
return { error: "not_editable" as const, currentStatus };
}
const data: Record<string, unknown> = { modified_at: new Date() };
if (editable) {
const strFields = [
"currency",
"language",
"delivery_terms",
"payment_terms",
"issued_by",
];
const strFields = ["currency", "language", "order_text"];
for (const f of strFields) {
if (body[f] !== undefined) data[f] = body[f] ? String(body[f]) : null;
}
if (body.customer_id !== undefined)
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
if (body.apply_vat !== undefined)
data.apply_vat =
body.apply_vat === true ||
body.apply_vat === 1 ||
body.apply_vat === "1";
if (body.supplier_id !== undefined) {
const supplierId = body.supplier_id ? Number(body.supplier_id) : null;
if (supplierId) {
// Same dangling-FK guard as create: 400, not a P2003 500.
const supplier = await prisma.sklad_suppliers.findUnique({
where: { id: supplierId },
select: { id: true },
});
if (!supplier) return { error: "supplier_not_found" as const };
}
data.supplier_id = supplierId;
}
if (body.exchange_rate !== undefined)
data.exchange_rate =
body.exchange_rate != null ? Number(body.exchange_rate) : null;
@@ -347,8 +419,6 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
data.delivery_date = body.delivery_date
? new Date(String(body.delivery_date))
: null;
if (body.notes !== undefined)
data.notes = body.notes ? String(body.notes) : null;
if (body.internal_notes !== undefined)
data.internal_notes = body.internal_notes
? String(body.internal_notes)
@@ -366,47 +436,67 @@ export async function updateIssuedOrder(id: number, body: IssuedOrderInput) {
body.status !== undefined &&
String(body.status) === "sent";
if (finalizing) {
// 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
// write (new header, old items). Now either everything lands or nothing.
const replaceItems = editable && Array.isArray(body.items);
const replaceSections = editable && Array.isArray(body.sections);
let assignedNumber: string | null = null;
if (finalizing || replaceItems || replaceSections) {
await prisma.$transaction(async (tx) => {
await tx.issued_orders.update({ where: { id }, data });
await assignIssuedOrderNumber(id, tx);
if (finalizing) assignedNumber = await assignIssuedOrderNumber(id, tx);
if (replaceItems) {
await tx.issued_order_items.deleteMany({
where: { issued_order_id: id },
});
await tx.issued_order_items.createMany({
data: body.items!.map((item, i) => ({
issued_order_id: id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
position: item.position ?? i,
})),
});
}
if (replaceSections) {
await tx.issued_order_sections.deleteMany({
where: { issued_order_id: id },
});
await tx.issued_order_sections.createMany({
data: body.sections!.map((s, i) => ({
issued_order_id: id,
title: s.title ?? null,
title_cz: s.title_cz ?? null,
content: s.content ?? null,
position: s.position ?? i,
})),
});
}
});
} else {
await prisma.issued_orders.update({ where: { id }, data });
}
if (editable && Array.isArray(body.items)) {
await prisma.$transaction(async (tx) => {
await tx.issued_order_items.deleteMany({
where: { issued_order_id: id },
});
await tx.issued_order_items.createMany({
data: body.items!.map((item, i) => ({
issued_order_id: id,
description: item.description ?? null,
item_description: item.item_description ?? null,
quantity: item.quantity ?? 1,
unit: item.unit ?? null,
unit_price: item.unit_price ?? 0,
vat_rate: item.vat_rate ?? 21.0,
position: item.position ?? i,
})),
});
});
}
// newValues for the audit diff: the header fields actually written plus the
// replaced collections (JSON.stringify drops the undefined = untouched keys).
const newValues: Record<string, unknown> = { ...data };
if (replaceItems) newValues.items = body.items;
if (replaceSections) newValues.sections = body.sections;
// Reflect the number the finalize transition just assigned (existing was read
// before the assign, so its po_number is still the pre-finalize null).
const poNumber = finalizing
? ((
await prisma.issued_orders.findUnique({
where: { id },
select: { po_number: true },
})
)?.po_number ?? existing.po_number)
: existing.po_number;
return { id, po_number: poNumber };
return {
id,
po_number: assignedNumber ?? existing.po_number,
oldValues: existing as unknown as Record<string, unknown>,
newValues,
};
}
export async function deleteIssuedOrder(id: number) {
@@ -420,6 +510,23 @@ export async function deleteIssuedOrder(id: number) {
? new Date(existing.created_at).getFullYear()
: new Date().getFullYear();
await releaseIssuedOrderNumber(year, existing.po_number ?? undefined);
// Remove the archived PDF from the NAS so the DB delete doesn't orphan it
// (mirrors deleteOffer). cleanIssued sweeps every Vydané/YYYY/MM folder, so
// an order whose order_date moved after archiving is still found.
// Best-effort: log, never throw.
if (existing.po_number && nasOrdersManager.isConfigured()) {
try {
nasOrdersManager.cleanIssued(existing.po_number);
} catch (e) {
console.error(
"[issued-orders.service] NAS issued-order PDF delete failed for",
existing.po_number,
e,
);
}
}
return existing;
}

View File

@@ -52,10 +52,20 @@ export class NasDocumentManager {
// ── Created (issued) documents ───────────────────────────────────
/** Remove any existing PDF for this document number across all year/month folders */
/**
* Remove any existing PDF for this document number across all year/month
* folders — including stale `<number>_N.pdf` duplicates that the old
* uniquePath-based save left behind when two archive calls raced (the
* save-after-edit fire-and-forget vs. the /file fallback). Document
* numbers are digit-only and never contain "_", so the suffix match
* cannot hit a different document.
*/
cleanIssued(documentNumber: string): void {
if (!this.basePath) return;
const safeName = this.sanitizeFilename(documentNumber) + ".pdf";
const safeBase = this.sanitizeFilename(documentNumber);
const stalePattern = new RegExp(
`^${safeBase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(_\\d+)?\\.pdf$`,
);
const issuedDir = path.join(this.basePath, DIR_ISSUED);
try {
if (!fs.existsSync(issuedDir)) return;
@@ -74,9 +84,10 @@ export class NasDocumentManager {
);
continue;
}
const filePath = path.join(monthPath, safeName);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
for (const file of fs.readdirSync(monthPath)) {
if (stalePattern.test(file)) {
fs.unlinkSync(path.join(monthPath, file));
}
}
}
}
@@ -103,12 +114,17 @@ export class NasDocumentManager {
const dir = this.ensureDir(DIR_ISSUED, year, month);
if (!dir) return null;
// Deterministic path, OVERWRITE in place (same model as the offers
// manager): the archive of a numbered document has exactly one canonical
// file. uniquePath here used to spawn `<number>_1.pdf` duplicates
// whenever two archive calls raced — those are user-visible junk on the
// NAS and invisible to the reader/cleaner, which match the exact name.
const safeName = this.sanitizeFilename(documentNumber) + ".pdf";
const fullPath = this.uniquePath(dir, safeName);
const fullPath = path.join(dir, safeName);
try {
fs.writeFileSync(fullPath, pdfBuffer);
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${path.basename(fullPath)}`;
return `${DIR_ISSUED}/${year}/${this.pad(month)}/${safeName}`;
} catch (err) {
console.error(
"[nas-financials-manager] write failed for issued document:",
@@ -119,6 +135,54 @@ export class NasDocumentManager {
}
}
/**
* Locate and read the archived PDF for an issued document by its number
* alone, sweeping every Vydané/YYYY/MM folder (the same walk cleanIssued
* uses). Needed because the archive folder is derived from the document
* date at save time and the date can be edited afterwards — a fixed
* expected-path read would miss a file that does exist in another month
* folder. Returns null when not found (callers fall back to a live render).
*/
readIssuedByNumber(
documentNumber: string,
): { data: Buffer; fileName: string } | null {
if (!this.basePath) return null;
const safeName = this.sanitizeFilename(documentNumber) + ".pdf";
const issuedDir = path.join(this.basePath, DIR_ISSUED);
try {
if (!fs.existsSync(issuedDir)) return null;
for (const yearDir of fs.readdirSync(issuedDir)) {
const yearPath = path.join(issuedDir, yearDir);
if (!fs.statSync(yearPath).isDirectory()) continue;
for (const monthDir of fs.readdirSync(yearPath)) {
const monthPath = path.join(yearPath, monthDir);
try {
if (!fs.statSync(monthPath).isDirectory()) continue;
} catch (err) {
console.error(
"[nas-financials-manager] stat failed for monthPath:",
monthPath,
err,
);
continue;
}
const filePath = path.join(monthPath, safeName);
if (fs.existsSync(filePath)) {
return { data: fs.readFileSync(filePath), fileName: safeName };
}
}
}
return null;
} catch (err) {
console.error(
"[nas-financials-manager] readIssuedByNumber failed for:",
documentNumber,
err,
);
return null;
}
}
readIssued(relativePath: string): { data: Buffer; fileName: string } | null {
const fullPath = this.resolveSafePath(relativePath);
if (!fullPath) return null;

View File

@@ -206,8 +206,16 @@ async function releaseSequence(
/** Verify a shared number is not already used by an order or project. */
export async function isSharedNumberTaken(number: string): Promise<boolean> {
const [existingOrder, existingProject] = await Promise.all([
prisma.orders.findFirst({ where: { order_number: number } }),
prisma.projects.findFirst({ where: { project_number: number } }),
// select id only — the orders row carries the PO attachment blob, which an
// existence check must never pull.
prisma.orders.findFirst({
where: { order_number: number },
select: { id: true },
}),
prisma.projects.findFirst({
where: { project_number: number },
select: { id: true },
}),
]);
return !!(existingOrder || existingProject);
}
@@ -220,26 +228,41 @@ async function isInvoiceNumberTaken(number: string): Promise<boolean> {
return !!existing;
}
/** Verify an issued-order (PO) number is not already used. */
async function isIssuedOrderNumberTaken(number: string): Promise<boolean> {
const existing = await prisma.issued_orders.findFirst({
/** Verify an issued-order (PO) number is not already used. Pass the tx client
* when the check must be part of a transaction (otherwise it runs on a
* separate connection and is only a best-effort pre-check). */
export async function isIssuedOrderNumberTaken(
number: string,
client: Prisma.TransactionClient | PrismaClient = prisma,
): Promise<boolean> {
const existing = await client.issued_orders.findFirst({
where: { po_number: number },
select: { id: true },
});
return !!existing;
}
/** Verify an offer/quotation number is not already used. */
export async function isOfferNumberTaken(number: string): Promise<boolean> {
const existing = await prisma.quotations.findFirst({
/** Verify an offer/quotation number is not already used. Pass the tx client
* when the check must be part of a transaction (otherwise it runs on a
* separate connection and is only a best-effort pre-check). */
export async function isOfferNumberTaken(
number: string,
client: Prisma.TransactionClient | PrismaClient = prisma,
): Promise<boolean> {
const existing = await client.quotations.findFirst({
where: { quotation_number: number },
select: { id: true },
});
return !!existing;
}
/** Verify an order number is not already used. */
export async function isOrderNumberTaken(number: string): Promise<boolean> {
// select id only — the orders row carries the PO attachment blob, which an
// existence check must never pull.
const existing = await prisma.orders.findFirst({
where: { order_number: number },
select: { id: true },
});
return !!existing;
}
@@ -429,7 +452,18 @@ export async function previewInvoiceNumber(
const code = settings?.invoice_type_code || "81";
const year = _year || new Date().getFullYear();
const seq = await previewNextSequence("invoice", year);
// Mirror generateInvoiceNumber's collision check (without consuming the
// sequence). The number_sequences counter can lag behind real data, so the
// raw next sequence may already be taken — advance past any number already
// used by an invoice so the preview matches what generateInvoiceNumber
// would actually assign instead of showing a stale, already-used number.
let seq = await previewNextSequence("invoice", year);
for (let attempt = 0; attempt < 1000; attempt++) {
const number = applyPattern(pattern, { year, prefix: "", code, seq });
if (!(await isInvoiceNumberTaken(number)))
return { number, next_number: number };
seq++;
}
const number = applyPattern(pattern, { year, prefix: "", code, seq });
return { number, next_number: number };
}
@@ -471,7 +505,18 @@ export async function previewIssuedOrderNumber(
const code = settings?.issued_order_type_code || "72";
const year = _year || new Date().getFullYear();
const seq = await previewNextSequence("issued_order", year);
// Mirror generateIssuedOrderNumber's collision check (without consuming the
// sequence). The number_sequences counter can lag behind real data, so the
// raw next sequence may already be taken — advance past any number already
// used by an issued order so the preview matches what
// generateIssuedOrderNumber would actually assign.
let seq = await previewNextSequence("issued_order", year);
for (let attempt = 0; attempt < 1000; attempt++) {
const number = applyPattern(pattern, { year, prefix: "", code, seq });
if (!(await isIssuedOrderNumberTaken(number)))
return { number, next_number: number };
seq++;
}
const number = applyPattern(pattern, { year, prefix: "", code, seq });
return { number, next_number: number };
}

View File

@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import prisma from "../config/database";
import {
generateOfferNumber,
previewOfferNumber,
releaseOfferNumber,
isOfferNumberTaken,
assignOfferNumber,
@@ -27,6 +27,44 @@ interface ScopeSectionInput {
// Re-export for convenience
export { previewOfferNumber as getNextOfferNumber } from "./numbering.service";
/**
* Offer status lifecycle (mirrors issued orders' table): a draft must be
* finalized to `active` first (that's when the number is assigned), an active
* offer can be ordered or invalidated, an ordered one only invalidated, and
* `invalidated` is terminal. The create-order flow (orders.service
* createOrderFromQuotation) consults this table too — an offer can only become
* `ordered` from a status that allows the transition.
*/
export const VALID_TRANSITIONS: Record<string, string[]> = {
draft: ["active"],
active: ["ordered", "invalidated"],
ordered: ["invalidated"],
invalidated: [],
};
/** Fields (non-status) are editable only while the offer is draft/active. */
function isEditableStatus(status: string): boolean {
return status === "draft" || status === "active";
}
/**
* Non-status update fields — used by the editable-state guard so a field edit
* on an ordered/invalidated offer is rejected EXPLICITLY (not silently
* dropped). `quotation_number` is stripped by the schema and is not listed.
*/
const NON_STATUS_UPDATE_FIELDS = [
"project_code",
"customer_id",
"created_at",
"valid_until",
"currency",
"language",
"scope_title",
"scope_description",
"items",
"sections",
] as const;
const ALLOWED_SORT_FIELDS = [
"id",
"quotation_number",
@@ -77,28 +115,22 @@ function buildOfferWhere(params: OfferFilterParams): Record<string, unknown> {
return where;
}
// Offers are NOT tax documents — totals are NET only (no VAT anywhere).
function enrichQuotation(q: any) {
const subtotal = q.quotation_items
const total = q.quotation_items
.filter((i: any) => i.is_included_in_total !== false)
.reduce(
(s: number, i: any) =>
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
0,
);
const vatAmount = q.apply_vat
? subtotal *
((q.vat_rate != null && q.vat_rate !== "" ? Number(q.vat_rate) : 21) /
100)
: 0;
const { quotation_items, scope_sections, ...rest } = q;
return {
...rest,
items: quotation_items,
sections: scope_sections,
customer_name: q.customers?.name || null,
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round((subtotal + vatAmount) * 100) / 100,
total: Math.round(total * 100) / 100,
};
}
@@ -108,16 +140,24 @@ export async function listOffers(params: ListOffersParams) {
const where = buildOfferWhere(params);
// Tiebreak on id: created_at is second-precision, so same-second rows would
// otherwise paginate non-deterministically (project ordering rule).
const orderBy: Record<string, "asc" | "desc">[] = [
{ [sortField]: order },
{ id: order },
];
const [quotations, total] = await Promise.all([
prisma.quotations.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: order },
orderBy,
include: {
customers: { select: { id: true, name: true } },
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
// id tiebreak: position can repeat, keep the order deterministic.
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
},
}),
prisma.quotations.count({ where }),
@@ -148,7 +188,7 @@ export async function listOffers(params: ListOffersParams) {
}
/**
* Sum offer TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a
* Sum offer NET total per currency across the WHOLE filtered set (not a
* single page). Reuses `buildOfferWhere` so the filters track the list, and
* `enrichQuotation` so the per-offer math matches the list/detail exactly.
* Returns one entry per currency, rounded to 2dp, zero/empty totals dropped.
@@ -158,12 +198,12 @@ export async function getOfferTotals(
): Promise<{ totals: CurrencyAmount[] }> {
const where = buildOfferWhere(params);
// Only quotation_items — the per-currency NET math needs nothing else
// (customers/scope_sections would just inflate the full-set scan).
const quotations = await prisma.quotations.findMany({
where,
include: {
customers: { select: { id: true, name: true } },
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
quotation_items: true,
},
});
@@ -190,7 +230,8 @@ export async function getOffer(id: number) {
include: {
customers: true,
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
// id tiebreak: position can repeat, keep the order deterministic.
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
},
});
if (!quotation) return null;
@@ -213,6 +254,9 @@ export async function getOffer(id: number) {
customer: quotation.customers,
customer_name: quotation.customers?.name || null,
order: orderInfo,
// Computed server-side so the frontend renders only legal status buttons
// (same contract as getIssuedOrder).
valid_transitions: VALID_TRANSITIONS[quotation.status] || [],
};
}
@@ -225,6 +269,18 @@ export async function createOffer(body: Record<string, any>) {
? String(body.quotation_number)
: null;
// Validate the referenced customer exists BEFORE the insert — a dangling
// FK would otherwise surface as a P2003 500 instead of a clean 400
// (mirrors createIssuedOrder's supplier check).
const customerId = body.customer_id ? Number(body.customer_id) : null;
if (customerId) {
const customer = await tx.customers.findUnique({
where: { id: customerId },
select: { id: true },
});
if (!customer) return { error: "customer_not_found" as const };
}
// Deferred numbering: a draft carries NO quotation_number (the column is
// nullable-unique so many drafts coexist). The official number is consumed
// only when the draft is finalized (assignOfferNumber on draft->active).
@@ -238,19 +294,15 @@ export async function createOffer(body: Record<string, any>) {
// generateOfferNumber already verifies its own generated numbers, so this
// guards the caller-supplied path.
if (explicitNumber !== null) {
const taken = await isOfferNumberTaken(explicitNumber);
if (taken) {
throw Object.assign(new Error("Číslo nabídky je již použito"), {
status: 400,
});
}
const taken = await isOfferNumberTaken(explicitNumber, tx);
if (taken) return { error: "number_taken" as const };
}
const quotation = await tx.quotations.create({
data: {
quotation_number: quotationNumber,
project_code: body.project_code ? String(body.project_code) : null,
customer_id: body.customer_id ? Number(body.customer_id) : null,
customer_id: customerId,
created_at: body.created_at
? new Date(String(body.created_at))
: undefined,
@@ -259,8 +311,6 @@ export async function createOffer(body: Record<string, any>) {
: null,
currency: body.currency ? String(body.currency) : "CZK",
language: body.language ? String(body.language) : "cs",
vat_rate: body.vat_rate != null ? Number(body.vat_rate) : 21.0,
apply_vat: body.apply_vat !== false,
status,
scope_title: body.scope_title ? String(body.scope_title) : null,
scope_description: body.scope_description
@@ -299,11 +349,14 @@ export async function createOffer(body: Record<string, any>) {
return quotation;
});
} catch (err) {
if (err instanceof Error && "status" in err) {
return {
error: err.message,
status: (err as Error & { status: number }).status,
} as const;
// P2002 on the quotation_number unique index = a concurrent insert won the
// race between the in-tx check and our insert — surface the same clean
// conflict token instead of a 500 (mirrors createIssuedOrder).
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2002"
) {
return { error: "number_taken" as const };
}
throw err;
}
@@ -312,31 +365,53 @@ export async function createOffer(body: Record<string, any>) {
export async function updateOffer(id: number, body: Record<string, any>) {
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return { error: "not_found" as const };
if (existing.status === "invalidated")
return { error: "invalidated" as const };
// A finalized offer's number is immutable. A draft has no number yet
// (quotation_number === null) — it is assigned on finalize, not editable here,
// so an empty/absent submitted value on a draft is not a "change" and must not
// block the draft->active finalize.
const currentStatus = existing.status;
// Status changes must follow the transition table (issued-orders parity).
if (body.status !== undefined && String(body.status) !== currentStatus) {
const newStatus = String(body.status);
const allowed = VALID_TRANSITIONS[currentStatus] || [];
if (!allowed.includes(newStatus)) {
return { error: "invalid_transition" as const, currentStatus, newStatus };
}
}
// Editable-state guard: non-status fields may change only in draft/active.
// In ordered/invalidated a field edit is REJECTED explicitly (it used to be
// possible to rewrite everything in any status except invalidated).
// Status-only transition payloads (e.g. ordered -> invalidated) still work.
const editable = isEditableStatus(currentStatus);
if (
existing.quotation_number !== null &&
body.quotation_number !== undefined &&
String(body.quotation_number) !== existing.quotation_number
!editable &&
NON_STATUS_UPDATE_FIELDS.some((f) => body[f] !== undefined)
) {
return { error: "Číslo nabídky nelze změnit", status: 400 } as const;
return { error: "not_editable" as const, currentStatus };
}
// Explicit null CLEARS the customer; a set id must exist (dangling FK would
// surface as a P2003 500). The old `Number(null)` coerced null to 0.
let customerId: number | null | undefined = undefined;
if (body.customer_id !== undefined) {
customerId = body.customer_id ? Number(body.customer_id) : null;
if (customerId) {
const customer = await prisma.customers.findUnique({
where: { id: customerId },
select: { id: true },
});
if (!customer) return { error: "customer_not_found" as const };
}
}
// Finalize transition draft -> active: assign the official number ATOMICALLY
// with the status change. assignOfferNumber is idempotent.
const finalizing =
existing.status === "draft" &&
currentStatus === "draft" &&
body.status !== undefined &&
String(body.status) === "active";
const data = {
customer_id:
body.customer_id !== undefined ? Number(body.customer_id) : undefined,
customer_id: customerId,
created_at:
body.created_at !== undefined
? body.created_at
@@ -351,13 +426,6 @@ export async function updateOffer(id: number, body: Record<string, any>) {
: undefined,
currency: body.currency !== undefined ? String(body.currency) : undefined,
language: body.language !== undefined ? String(body.language) : undefined,
vat_rate: body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
apply_vat:
body.apply_vat !== undefined
? body.apply_vat === true ||
body.apply_vat === 1 ||
body.apply_vat === "1"
: undefined,
status: body.status !== undefined ? String(body.status) : undefined,
project_code:
body.project_code !== undefined
@@ -417,19 +485,39 @@ export async function updateOffer(id: number, body: Record<string, any>) {
await prisma.quotations.update({ where: { id }, data });
}
// newValues for the audit diff: the header fields actually written plus the
// replaced collections (JSON.stringify drops the undefined = untouched keys).
const newValues: Record<string, unknown> = { ...data };
if (Array.isArray(body.items)) newValues.items = body.items;
if (Array.isArray(body.sections)) newValues.sections = body.sections;
// Reflect the number the finalize transition just assigned (existing was read
// before the assign, so its quotation_number is still the pre-finalize null).
return {
id,
quotation_number: assignedNumber ?? existing.quotation_number,
oldValues: existing as unknown as Record<string, unknown>,
newValues,
};
}
export async function deleteOffer(id: number) {
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return null;
if (!existing) return { error: "not_found" as const };
await prisma.quotations.delete({ where: { id } });
try {
await prisma.quotations.delete({ where: { id } });
} catch (err) {
// P2003 = the offer is referenced by an order/project via a Restrict FK —
// surface a clean conflict token instead of a 500.
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2003"
) {
return { error: "linked" as const };
}
throw err;
}
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
@@ -437,6 +525,8 @@ export async function deleteOffer(id: number) {
await releaseOfferNumber(year, existing.quotation_number ?? undefined);
// Remove the offer PDF from the NAS so the DB delete doesn't orphan it.
// The service is the SINGLE owner of this cleanup (the route used to repeat
// it and always logged a spurious second-delete failure).
// Best-effort and idempotent: deleteOfferPdf returns false (and logs) if the
// file is already gone, so a NAS outage / missing file never throws here.
if (existing.quotation_number && nasOffersManager.isConfigured()) {
@@ -455,7 +545,7 @@ export async function deleteOffer(id: number) {
}
}
return existing;
return { data: existing };
}
export async function duplicateOffer(id: number) {
@@ -463,7 +553,8 @@ export async function duplicateOffer(id: number) {
where: { id },
include: {
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
// id tiebreak: position can repeat, keep the order deterministic.
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
},
});
if (!original) return null;
@@ -479,8 +570,6 @@ export async function duplicateOffer(id: number) {
valid_until: null,
currency: original.currency,
language: original.language,
vat_rate: original.vat_rate,
apply_vat: original.apply_vat,
status: "active",
scope_title: original.scope_title,
scope_description: original.scope_description,
@@ -520,11 +609,23 @@ export async function duplicateOffer(id: number) {
export async function invalidateOffer(id: number) {
const existing = await prisma.quotations.findUnique({ where: { id } });
if (!existing) return null;
if (!existing) return { error: "not_found" as const };
// The dedicated endpoint must respect the same transition table as PUT:
// only active/ordered offers can be invalidated (a draft is deleted instead,
// invalidated is terminal).
const allowed = VALID_TRANSITIONS[existing.status] || [];
if (!allowed.includes("invalidated")) {
return {
error: "invalid_transition" as const,
currentStatus: existing.status,
newStatus: "invalidated",
};
}
await prisma.quotations.update({
where: { id },
data: { status: "invalidated", modified_at: new Date() },
});
return existing;
return { data: existing };
}

View File

@@ -9,6 +9,7 @@ import {
releaseProjectNumber,
} from "./numbering.service";
import { NasFileManager } from "./nas-file-manager";
import { VALID_TRANSITIONS as OFFER_VALID_TRANSITIONS } from "./offers.service";
// Best-effort NAS folder creation for order-spawned projects, mirroring
// projects.service. Stateless singleton; reads NAS_PATH at construction.
@@ -72,19 +73,20 @@ async function syncProjectStatus(
});
}
// ⚠ Also called by getOrderTotals with a MINIMAL select (currency, item
// quantity/unit_price/is_included_in_total). If you read a NEW order/item
// field here, add it to that select — a field missing from the select is
// silently 0 in the per-currency totals.
//
// Orders are NOT tax documents — totals are NET only (no VAT anywhere).
function enrichOrder(o: any) {
const subtotal = o.order_items
const total = o.order_items
.filter((i: any) => i.is_included_in_total !== false)
.reduce(
(s: number, i: any) =>
s + (Number(i.quantity) || 0) * (Number(i.unit_price) || 0),
0,
);
const vatAmount = o.apply_vat
? subtotal *
((o.vat_rate != null && o.vat_rate !== "" ? Number(o.vat_rate) : 21) /
100)
: 0;
const { order_items, order_sections, ...rest } = o;
const invoice = o.invoices?.[0] || null;
return {
@@ -96,9 +98,7 @@ function enrichOrder(o: any) {
project_code: o.quotations?.project_code || null,
invoice_id: invoice?.id || null,
invoice_number: invoice?.invoice_number || null,
subtotal: Math.round(subtotal * 100) / 100,
vat_amount: Math.round(vatAmount * 100) / 100,
total: Math.round((subtotal + vatAmount) * 100) / 100,
total: Math.round(total * 100) / 100,
};
}
@@ -158,6 +158,11 @@ export async function listOrders(params: ListOrdersParams) {
take: limit,
// Tiebreak on id so same-second created_at rows sort deterministically.
orderBy: [{ [sortField]: order }, { id: order }],
// The PO attachment blob is served ONLY by GET /:id/attachment. Never
// pull it into list payloads — a single 1MB PDF would serialize into
// megabytes of JSON per row. attachment_name stays as the existence
// signal the frontend uses.
omit: { attachment_data: true },
include: {
customers: { select: { id: true, name: true } },
order_items: { orderBy: { position: "asc" } },
@@ -183,7 +188,7 @@ export interface CurrencyAmount {
}
/**
* Sum order TOTAL (incl. VAT) per currency across the WHOLE filtered set (not a
* Sum order NET total per currency across the WHOLE filtered set (not a
* single page). Reuses `buildOrderWhere` so the filters track the list, and
* `enrichOrder` so the per-order math matches the list/detail exactly. Returns
* one entry per currency, rounded to 2dp, zero/empty totals dropped.
@@ -193,17 +198,19 @@ export async function getOrderTotals(
): Promise<{ totals: CurrencyAmount[] }> {
const where = buildOrderWhere(params);
// Select ONLY what the math needs (currency + item numbers).
// This runs over the WHOLE filtered set, so pulling attachment blobs,
// sections HTML or relations here would multiply the query size for nothing.
const orders = await prisma.orders.findMany({
where,
include: {
customers: { select: { id: true, name: true } },
order_items: { orderBy: { position: "asc" } },
order_sections: { orderBy: { position: "asc" } },
quotations: { select: { quotation_number: true, project_code: true } },
invoices: {
select: { id: true, invoice_number: true },
take: 1,
orderBy: { id: "desc" },
select: {
currency: true,
order_items: {
select: {
quantity: true,
unit_price: true,
is_included_in_total: true,
},
},
},
});
@@ -228,6 +235,9 @@ export async function getOrderTotals(
export async function getOrder(id: number) {
const order = await prisma.orders.findUnique({
where: { id },
// The blob belongs only to GET /:id/attachment — the detail payload
// carries attachment_name as the existence signal.
omit: { attachment_data: true },
include: {
customers: true,
order_items: { orderBy: { position: "asc" } },
@@ -292,7 +302,7 @@ export async function createOrderFromQuotation(
where: { id: quotationId },
include: {
quotation_items: { orderBy: { position: "asc" } },
scope_sections: { orderBy: { position: "asc" } },
scope_sections: { orderBy: [{ position: "asc" }, { id: "asc" }] },
},
});
@@ -303,6 +313,19 @@ export async function createOrderFromQuotation(
status: 400,
} as const;
// The flow sets the offer's status to `ordered`, so it must respect the
// offers transition table: only an offer whose current status allows the
// `ordered` transition qualifies. A draft must be finalized first (it has no
// number yet — an ordered offer without a number must never exist) and an
// invalidated offer is terminal.
const allowedFromStatus = OFFER_VALID_TRANSITIONS[quotation.status] || [];
if (!allowedFromStatus.includes("ordered")) {
return {
error: `Objednávku nelze vytvořit z nabídky ve stavu "${quotation.status}"`,
status: 400,
} as const;
}
const result = await prisma.$transaction(async (tx) => {
const orderNumber = await generateSharedNumber(tx);
// The order keeps the shared (order) sequence; the auto-created project
@@ -319,8 +342,6 @@ export async function createOrderFromQuotation(
status: "prijata",
currency: quotation.currency || "CZK",
language: quotation.language || "cs",
vat_rate: quotation.vat_rate ?? 21.0,
apply_vat: quotation.apply_vat ?? true,
scope_title: quotation.scope_title,
scope_description: quotation.scope_description,
attachment_data: attachmentBuffer
@@ -412,8 +433,6 @@ interface CreateOrderData {
status: string;
currency: string;
language: string;
vat_rate: number;
apply_vat?: boolean;
exchange_rate?: number;
scope_title?: string | null;
scope_description?: string | null;
@@ -453,8 +472,6 @@ export async function createOrder(
status: body.status,
currency: body.currency,
language: body.language,
vat_rate: body.vat_rate,
apply_vat: body.apply_vat !== false,
exchange_rate: body.exchange_rate,
scope_title: body.scope_title ?? null,
scope_description: body.scope_description ?? null,
@@ -567,7 +584,11 @@ interface UpdateOrderData {
}
export async function updateOrder(id: number, body: UpdateOrderData) {
const existing = await prisma.orders.findUnique({ where: { id } });
// Pre-read only the fields the guards below check — never the PDF blob.
const existing = await prisma.orders.findUnique({
where: { id },
select: { status: true, order_number: true },
});
if (!existing)
return { error: "Objednávka nenalezena", status: 404 } as const;
@@ -609,10 +630,6 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
}
if (body.customer_id !== undefined)
data.customer_id = body.customer_id ? Number(body.customer_id) : null;
if (body.vat_rate !== undefined) data.vat_rate = Number(body.vat_rate);
if (body.apply_vat !== undefined)
data.apply_vat =
body.apply_vat === true || body.apply_vat === 1 || body.apply_vat === "1";
if (Array.isArray(body.items) || Array.isArray(body.sections)) {
if (currentStatus !== "prijata" && currentStatus !== "v_realizaci") {
@@ -680,7 +697,11 @@ export async function updateOrder(id: number, body: UpdateOrderData) {
}
export async function deleteOrder(id: number, deleteFiles = false) {
const existing = await prisma.orders.findUnique({ where: { id } });
// Pre-read only what the delete needs (number release + year) — not the blob.
const existing = await prisma.orders.findUnique({
where: { id },
select: { order_number: true, created_at: true },
});
if (!existing)
return { error: "Objednávka nenalezena", status: 404 } as const;

View File

@@ -0,0 +1,28 @@
/**
* Build a Content-Disposition header value that is safe for non-ASCII
* filenames (Czech diacritics are the norm here: "příloha č. 1.pdf").
*
* Node's HTTP layer THROWS on header values containing characters above
* U+00FF, so the raw filename must never be interpolated into the header
* directly — that turns a download of a diacritic-named file into a 500.
* Per RFC 5987 / RFC 6266 the real UTF-8 name is carried in the `filename*`
* parameter (percent-encoded), while `filename` holds an ASCII-only fallback
* for legacy clients. Modern browsers prefer `filename*`.
*/
export function contentDisposition(
type: "inline" | "attachment",
fileName: string,
): string {
const asciiFallback = fileName
.replace(/[^\x20-\x7e]/g, "_")
.replace(/["\\]/g, "");
// encodeURIComponent leaves ' ( ) * raw, but they are not RFC 5987
// attr-chars (the apostrophe is even the ext-value delimiter) — strict
// parsers would reject the filename* and fall back to the mangled ASCII
// name. Percent-encode them like the `content-disposition` npm package.
const encoded = encodeURIComponent(fileName).replace(
/['()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}

View File

@@ -8,6 +8,22 @@
* of JSON serialization (e.g., building lookup keys, shift_date strings).
*/
/**
* UTC midnight of the LOCAL calendar day of `d` (default: now).
*
* Prisma truncates a JS Date used against a `@db.Date` column (filter or
* write) to its **UTC** date part. With TZ=Europe/Prague, a local-midnight
* Date — `new Date(y, m, d)` — is 22:00/23:00 UTC of the PREVIOUS day, so it
* filters/stores as the previous calendar date; and a bare `new Date()`
* written to a `@db.Date` column stores yesterday during the 00:0002:00
* Prague window. Always build `@db.Date` values and range boundaries from
* `Date.UTC(...)` of the LOCAL calendar components — this helper does exactly
* that for "today" (or any reference Date).
*/
export function utcMidnightOfLocalDay(d: Date = new Date()): Date {
return new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
}
/** YYYY-MM-DD in local time */
export function localDateStr(d: Date): string {
const y = d.getFullYear();

View File

@@ -63,7 +63,23 @@ async function getBrowser(): Promise<Browser> {
}
}
export async function htmlToPdf(html: string): Promise<Buffer> {
export interface HtmlToPdfOptions {
/**
* Chromium footerTemplate HTML rendered in the bottom margin of EVERY page
* — the only true repeating footer Puppeteer supports (CSS @page margin
* boxes are not implemented in Chromium). Styles must be inline and the
* font-size explicit (Chromium defaults it to 0); `.pageNumber` /
* `.totalPages` spans are substituted by Chromium. When set, the bottom
* margin grows to make room and an empty headerTemplate suppresses
* Chromium's default date/title header.
*/
footerTemplate?: string;
}
export async function htmlToPdf(
html: string,
options: HtmlToPdfOptions = {},
): Promise<Buffer> {
const b = await getBrowser();
const page = await b.newPage();
// Per-request timeouts so one stuck render (e.g. image tag pointing at a
@@ -78,7 +94,19 @@ export async function htmlToPdf(html: string): Promise<Buffer> {
const pdf = await page.pdf({
format: "A4",
printBackground: true,
margin: { top: "10mm", bottom: "10mm", left: "10mm", right: "10mm" },
margin: {
top: "10mm",
bottom: options.footerTemplate ? "18mm" : "10mm",
left: "10mm",
right: "10mm",
},
...(options.footerTemplate
? {
displayHeaderFooter: true,
headerTemplate: "<span></span>",
footerTemplate: options.footerTemplate,
}
: {}),
timeout: 15_000,
});
return Buffer.from(pdf);

116
src/utils/pdf-shared.ts Normal file
View File

@@ -0,0 +1,116 @@
import { localDateCzStr } from "./date";
/**
* Shared helpers for the four PDF routes (offers-pdf, issued-orders-pdf,
* invoices-pdf, orders-pdf). These used to be near-verbatim copies in each
* route file; the canonical variants live here:
*
* - `cleanQuillHtml` is the STRICT variant (strips javascript:/data:/vbscript:
* in BOTH href and src — defense-in-depth, DOMPurify runs first).
* - `formatNum` uses the NBSP (U+00A0) thousands separator so amounts never
* line-wrap inside the number.
* - `formatCurrency` is the per-currency-symbol variant from offers
* (€ / $ / Kč / £, anything else falls back to "<num> <code>").
*/
/** dd.MM.yyyy in Europe/Prague; invalid input echoes back as-is. */
export function formatDate(date: Date | string | null | undefined): string {
if (!date) return "";
const d = new Date(date);
if (isNaN(d.getTime())) return String(date);
return localDateCzStr(d);
}
/** Format number with comma decimal separator and NBSP thousands separator. */
export function formatNum(n: number, decimals = 2): string {
const abs = Math.abs(n);
const fixed = abs.toFixed(decimals);
const [intPart, decPart] = fixed.split(".");
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
const result = decPart ? `${withSep},${decPart}` : withSep;
return n < 0 ? `-${result}` : result;
}
/** Per-currency symbol formatting (offers variant). */
export function formatCurrency(amount: number, currency: string): string {
const n = Number(amount) || 0;
switch (currency) {
case "EUR":
return `${formatNum(n, 2)}`;
case "USD":
return `$${Math.abs(n)
.toFixed(2)
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
case "CZK":
return `${formatNum(n, 2)}`;
case "GBP":
return `£${Math.abs(n)
.toFixed(2)
.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`;
default:
return `${formatNum(n, 2)} ${currency}`;
}
}
export function escapeHtml(str: string | null | undefined): string {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Sanitize Quill HTML for the PDF templates: remove dangerous tags, event
* handlers, javascript:/data:/vbscript: URLs (href AND src), inline styles,
* and merge adjacent identical spans. Runs AFTER DOMPurify at every call site
* (regex pass = defense-in-depth, not the primary sanitizer).
*/
export function cleanQuillHtml(html: string | null | undefined): string {
if (!html) return "";
let s = html;
// Remove dangerous tags with content
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*>[\s\S]*?<\/\1>/gi,
"",
);
s = s.replace(
/<(script|iframe|object|embed|style|link|meta|base|form|input|textarea|button|select|svg|math)[^>]*\/?>/gi,
"",
);
// Strip event handlers
s = s.replace(/\s+on\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
s = s.replace(/\s+on\w+\s*=\s*[^\s>]*/gi, "");
// Strip javascript:/data:/vbscript: in href and src
s = s.replace(
/href\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'href="#"',
);
s = s.replace(
/src\s*=\s*["']?\s*(javascript|data|vbscript)\s*:[^"'>\s]*/gi,
'src=""',
);
// Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, " ");
s = s.replace(/\s+style\s*=\s*("[^"]*"|'[^']*'|[^\s>]*)/gi, "");
// Merge adjacent spans with same attributes
let prev = "";
while (prev !== s) {
prev = s;
s = s.replace(/<span([^>]*)>(.*?)<\/span>\s*<span\1>/gs, "<span$1>$2");
}
return s;
}
/** CSS rules for Quill's .ql-indent-1..9 classes (identical in all templates). */
export function buildQuillIndentCss(): string {
let indentCSS = "";
for (let n = 1; n <= 9; n++) {
const pad = n * 3;
const liPad = n * 3 + 1.5;
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`;
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`;
}
return indentCSS;
}