Compare commits

...

30 Commits

Author SHA1 Message Date
BOHA
0b69dbfde0 chore(release): v2.4.6 - ARES company lookup, suppliers on the customers model (structured address + custom fields)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:12:57 +02:00
BOHA
db7a5c3d15 feat(suppliers)!: full customers model - structured address + custom fields
The dodavatele modal is now an exact mirror of the customers modal
(minus the PDF field-order picker): Nazev+ARES, Ulice, Mesto+PSC, Zeme,
ICO+ARES, DIC, and the same "Vlastni pole" editor (maxWidth md).

Two migrations on sklad_suppliers:
- structured street/city/postal_code/country replace the free-text
  address blob (best-effort split: street / PSC regex / city)
- custom_fields LONGTEXT replaces contact_person/email/phone/notes;
  existing values are preserved as labeled custom fields

The encode/decode of the custom_fields blob is shared with customers
via the new utils/custom-fields.ts. The PO PDF supplier block renders
the structured address + custom-field lines like the customer block;
the supplier picker/detail selects and types follow. Legacy clients
sending the dropped keys are silently stripped (tested). Suppliers
list shows Ulice/Mesto instead of the dropped contact columns; search
covers name/ico/city.

BREAKING CHANGE: sklad_suppliers.address, contact_person, email,
phone, notes columns dropped (data migrated); deploy must run
prisma migrate deploy + generate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:11:22 +02:00
BOHA
f1ce76d21d feat(ares): ARES company lookup with prefill in customer/supplier modals
New /api/admin/ares proxy (browser cannot reach ares.gov.cz - CORS+CSP):
GET /ico/:ico and GET /search?q= map the MFCR REST API to the form
shape (street incl. orientation numbers, "511 01" PSC format, DIC).
Guarded by customers.create/edit + warehouse.manage; token errors map
to Czech messages. Shared AresAdornment button sits in the Nazev and
ICO fields of both modals: ICO mode fills directly, name mode searches
with a result picker. Tested against live ARES (BOHA, ICO 22599851).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:11:08 +02:00
BOHA
575c07aac8 chore(release): v2.4.5 - invoice Obsah sections + internal notes, unified per-page PDF header/footer, project status tabs+tints, mobile DnD long-press, offer un-stick on order delete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:31:15 +02:00
BOHA
4bf245d35c feat(projects): status filter tabs + per-status row tints on the list
Mirrors the offers page: centered status tabs (Vsechny/Aktivni/
Dokonceny/Zruseny) filtering server-side (?status= already supported),
filtered-vs-empty empty states, and row tints - dokonceny rows get the
info-channel low-alpha wash matching the badge, zruseny rows are faded
like invalidated offers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:30:09 +02:00
BOHA
79d026e756 fix(dnd): mobile item reorder works via long-press instead of scrolling
PointerSensor also receives touch-derived pointer events and its 5px
distance constraint fired before the TouchSensor's hold delay - the
browser claimed the gesture for scrolling (pointercancel) and the drag
died. Replaced with MouseSensor (desktop unchanged), TouchSensor now
activates on a 300ms hold (tolerance 8px), and all drag handles carry
touch-action: none so the browser never starts a scroll from them.
Applies to DocumentItemsEditor (offers/issued orders) and the invoice
items list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:30:01 +02:00
BOHA
40a859f5e1 feat(invoices)!: Obsah sections, internal-only notes, unified per-page PDF header+footer
Invoices now mirror the issued-orders document model:

- New invoice_sections table (CZ/EN rich-text "Obsah") edited via the
  shared SectionsEditor, printed inline right after the items on the
  PDF. Full-replace on update, same transaction as items.
- Printed notes dropped: the notes column is removed (migration merges
  existing content into internal_notes first); the form field is now
  "Interni poznamky", never printed. Legacy payloads sending notes are
  silently stripped.
- Form cleanup: Cislo faktury and Vystavil fields removed (number lives
  in the header, issued_by auto-fills); page header title restyled to
  the orders/offers pattern (number span + status chip).
- Unified per-page PDF header for the red-accent family: shared
  buildPdfHeaderTemplate in pdf-shared (22mm logo, red heading, red
  rule) rendered by a Puppeteer headerTemplate on EVERY page of both
  invoices and issued orders (incl. the /file fallback render); body
  headers are print-hidden. htmlToPdf gained the headerTemplate option.
- Footer parity: invoices get the per-page "Vystavil + Strana X z Y"
  footer; the invoice bottom block (notice + QR/VAT recap + Prevzal)
  is break-inside: avoid so a page break can never split it.
- @page margins now match the template space (32mm top, 18mm bottom) -
  Chromium lays out by CSS @page margins, which also fixes issued
  orders' content running into the 18mm footer zone on full pages.

BREAKING CHANGE: invoices.notes column dropped (data merged into
internal_notes); deploy must run prisma migrate deploy + generate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:02:37 +02:00
BOHA
ffae1a4e74 fix(offers): deleting an order returns the source offer to active
deleteOrder only nulled quotations.order_id and left status "ordered" -
a dead end (ordered only transitions to invalidated), so the offer could
never be ordered again and the "Vytvorit objednavku" button stayed
hidden. The delete transaction now reverts ordered -> active together
with the back-reference clear; regression test included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:02:19 +02:00
BOHA
5c9ebddf50 docs: consolidate CLAUDE.md (merge audit sections, dedupe, fix stale facts); runbooks to docs/release.md
Applied the two-review consolidation: merged the duplicate 2026-06-06/06-09 'Conventions (enforced)' sections into one canonical block (query-invalidation rule stated once instead of three times, Zod-helper 'tracked follow-up' vs 'MANDATORY' contradiction resolved, seed-is-dev-only stated once); removed stale-by-design facts (version pins, test counts, file counts, React 18 typo) and migration-era storytelling; fixed prisma migrate diff flags (--from-url removed in Prisma 7) and documented the non-interactive migration recipe; added the new rules earned this week (@db.Date UTC-truncation, document business rules incl. VAT-only-on-invoices and PO suppliers, document-platform conventions, shared-module reuse list, dashboard invalidation, app_test migrate step). Release/hotfix/drift/baseline runbooks moved to docs/release.md with a pointer (keeps prod details out of every-turn context). Also dropped the db:push npm script - it contradicted the golden rule. CLAUDE.md: 570 -> ~390 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:07:39 +02:00
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
115 changed files with 11974 additions and 5900 deletions

View File

@@ -1,14 +1,23 @@
// Block direct .env file edits (not .env.example, .env.production, .env.test) // Block direct .env file edits (not .env.example, .env.production, .env.test)
let data = ''; //
process.stdin.on('data', chunk => data += chunk); // Hook exit-code contract (Claude Code PreToolUse):
process.stdin.on('end', () => { // 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 { try {
const input = JSON.parse(data); const input = JSON.parse(data);
const filePath = input.tool_input?.file_path || ''; const filePath = input.tool_input?.file_path || "";
const basename = filePath.split('/').pop().split('\\').pop(); const basename = filePath.split("/").pop().split("\\").pop();
if (basename === '.env') { if (basename === ".env") {
console.log(JSON.stringify({ decision: 'block', reason: 'Direct .env edits are blocked. Use .env.example instead.' })); console.error("Direct .env edits are blocked. Use .env.example instead.");
process.exit(1); 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.
}
}); });

755
CLAUDE.md
View File

@@ -1,25 +1,26 @@
# CLAUDE.md — boha-app-ts # CLAUDE.md — boha-app-ts
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js. Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js.
Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operations. Handles attendance, invoicing, offers/orders, leave/trips, projects, warehouse, vehicles, and HR operations.
--- ---
## Tech Stack ## Tech Stack
| Layer | Technology | | Layer | Technology |
| -------------- | ---------------------------------------------------------------------------------------------------------------- | | -------------- | ----------------------------------------------------------------------------------------------------- |
| Runtime | Node.js, TypeScript 5.9.3 (strict) | | Runtime | Node.js, TypeScript (strict, all tsconfigs) |
| HTTP Framework | Fastify 5.8.2 | | HTTP Framework | Fastify 5 |
| ORM | Prisma 7.8.0 (Rust-free client + @prisma/adapter-mariadb driver adapter; datasource in prisma.config.ts) → MySQL | | ORM | Prisma 7 (Rust-free client + @prisma/adapter-mariadb; datasource lives in `prisma.config.ts`) → MySQL |
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs | | Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
| Validation | Zod 4.3.6 | | Validation | Zod 4 |
| Frontend | React 19.2.7 + Vite 8.0.0 + Material UI v7 (Emotion) | | Frontend | React 19 + Vite + Material UI v7 (Emotion) + React Query |
| Testing | Vitest 4.1.0 + Supertest | | Testing | Vitest + Supertest against a real `app_test` DB |
| PDF | Puppeteer 24.x | | PDF | Puppeteer (stay on 24.x — v25 is ESM-only, conflicts with the CJS server build) |
| Email | nodemailer 8.x | | Email / Cron | nodemailer / node-cron |
| Cron | node-cron 4.x | | AI | @anthropic-ai/sdk → claude-sonnet-4-6 ("Odin" assistant) |
| AI | @anthropic-ai/sdk 0.102 — Claude Sonnet 4.6 ("Odin" assistant) |
(Exact versions live in `package.json` — don't trust any pinned here.)
--- ---
@@ -27,34 +28,28 @@ Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operation
``` ```
src/ src/
├── server.ts # Fastify server entry point — plugins, routes, error handler ├── server.ts # Fastify entry — plugins, routes, error handler
├── routes/admin/ # HTTP route handlers (one file per entity) ├── routes/admin/ # HTTP route handlers (one file per entity)
├── services/ # Business logic (no classes, exported functions, uses Prisma directly) ├── services/ # Business logic (no classes, exported functions, own Prisma)
├── schemas/ # Zod validation schemas (one file per entity) ├── schemas/ # Zod validation schemas (one file per entity; shared coercers in common.ts)
├── middleware/ # auth.ts (requireAuth, requirePermission, optionalAuth) ├── middleware/ # auth.ts (requireAuth/requirePermission), security.ts (CSP, HSTS)
# security.ts (CSP, HSTS, security headers) ├── utils/ # totp, email, audit, date, pdf-shared, content-disposition, html-to-pdf, …
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc. ├── config/ # env.ts (config singleton, TZ + Date.toJSON override)
├── config/ # env.ts (config singleton, Date.toJSON override) ├── types/ # AuthData, JwtPayload, ApiResponse, Prisma re-exports
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma) ├── admin/ # React 19 + MUI v7 frontend
├── admin/ # React 18 + Material UI frontend (~114 .tsx files)
│ ├── AdminApp.tsx # Router + lazy-loaded pages │ ├── AdminApp.tsx # Router + lazy-loaded pages
│ ├── theme.ts # MUI theme — light/dark color schemes, tokens, component defaults │ ├── theme.ts / GlobalStyles.tsx
│ ├── GlobalStyles.tsx # App-wide global styles via MUI <GlobalStyles> (reset, typography, utilities) │ ├── ui/ # MUI component kit — pages import from here
│ ├── ui/ # MUI component kit (AppShell, Button, DataTable, Modal, …) — pages import from here │ ├── components/ # Non-page components incl. document/ (shared doc editors), odin/, warehouse/
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/) │ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
│ ├── components/ # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets, odin/ (AI chat)
│ ├── pages/ # One file per page/feature │ ├── pages/ # One file per page/feature
│ ├── lib/ # React Query options & mutations (queries/) + shared label maps │ ├── lib/queries/ # React Query options + useApiMutation
│ ├── hooks/ # usePaginatedQuery, useTableSort, useDebounce, useReducedMotion, … │ ├── hooks/ # usePaginatedQuery, useDocumentLock, useUnsavedChangesGuard, useDocumentPdf, …
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers │ └── utils/ # api.ts (apiFetch with token refresh), formatters
└── __tests__/ # Vitest tests (17 files: auth, numbering, warehouse, plan, invoices, ai/Odin, received-invoices VAT, …) └── __tests__/ # Vitest suites (server-side only; real app_test DB)
prisma/ prisma/ # schema.prisma (snake_case columns) + migrations/
├── schema.prisma # 52 models, MySQL, snake_case columns dist/ dist-client/ # Compiled server (CommonJS) / built frontend (Vite)
└── migrations/ # Applied migrations
dist/ # Compiled server (CommonJS, ES2022)
dist-client/ # Built frontend (Vite, ES2020)
``` ```
--- ---
@@ -62,502 +57,364 @@ dist-client/ # Built frontend (Vite, ES2020)
## Commands ## Commands
```bash ```bash
# Development npm run dev:server # tsx watch src/server.ts (frontend: npm run dev:client)
npm run dev # Starts server in watch mode (manage frontend separately) npm run build # server + client
npm run dev:server # tsx watch src/server.ts npm test # vitest run — against app_test via .env.test
npm run dev:client # Vite dev server npm run typecheck # tsc -b --noEmit (NOT -p tsconfig.json — that solution file checks nothing)
npm run lint # eslint . — react-hooks/rules-of-hooks is an ERROR; keep 0 errors
# Build
npm run build # Build server + client
npm run build:server # tsc -p tsconfig.server.json → dist/
npm run build:client # vite build → dist-client/
# Run (production)
npm start # node dist/server.js
# Tests
npm test # vitest run (single pass) — runs against the app_test DB via .env.test
npm run test:watch # vitest watch
# Quality gates
npm run typecheck # tsc -b --noEmit (also type-checks the tests via tsconfig.test.json)
npm run lint # eslint . — react-hooks/rules-of-hooks is an ERROR; keep at 0 errors
npm run format # prettier --write . npm run format # prettier --write .
npx prisma generate # after every schema change (client is not committed)
# Database npx prisma studio # DB browser
npx prisma migrate dev # Create migration from schema changes + apply to dev
npx prisma migrate dev --name <descriptive_name> # Named migration
npx prisma migrate deploy # Apply pending migrations to production
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma studio # DB browser GUI
npx prisma db seed # (Re)seed the dev database
npx prisma migrate diff --from-url <url> --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy
npx prisma migrate resolve --applied <migration> # Mark migration as applied without running SQL
``` ```
**Do not start the dev server.** The user manages it separately. **Do not start the dev server.** The user manages it separately.
**Before running `prisma migrate dev` or `prisma db push`, ask the user to stop their dev server and wait for confirmation.** Migrations can conflict with an active database connection from the running server. **Before any migration, ask the user to stop their dev server and wait for
confirmation** (the running server holds DB connections).
--- ---
## Environment Variables ## Environment Variables
Required: Required: `DATABASE_URL`, `JWT_SECRET`, `TOTP_ENCRYPTION_KEY` (64-char hex).
``` Optional (defaults in `src/config/env.ts`): `PORT` (prod 3001; dev default 3050
DATABASE_URL=mysql://user:pass@host:3306/dbname hardcoded), `HOST`, `APP_ENV=local|production` (controls CSP/CORS/HSTS),
JWT_SECRET=<64-char hex string> `ACCESS_TOKEN_EXPIRY`, `REFRESH_TOKEN_SESSION_EXPIRY`,
TOTP_ENCRYPTION_KEY=<64-char hex string> `REFRESH_TOKEN_REMEMBER_EXPIRY`, `NAS_PATH` (project files),
``` `NAS_INVOICES` + `NAS_ORDERS` (document PDF archives — split trees),
`MAX_UPLOAD_SIZE`, `CONTACT_EMAIL_TO/FROM`, `SMTP_FROM`, `LEAVE_NOTIFY_EMAIL`,
`APP_URL`, `CORS_ORIGINS`, `ANTHROPIC_API_KEY` (Odin self-hides without it).
Optional (with defaults): `.env` for dev, `.env.test` for tests (both gitignored — **never edit env
files; the user manages them**).
```
PORT=3001 # Production port (dev default: 3050, hardcoded in server.ts)
HOST=127.0.0.1
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
REFRESH_TOKEN_SESSION_EXPIRY=3600 # 1 hour
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # 30 days
NAS_PATH=Z:/02_PROJEKTY # Network share for project files
MAX_UPLOAD_SIZE=52428800 # 50MB
CONTACT_EMAIL_TO=
CONTACT_EMAIL_FROM=
SMTP_FROM=
LEAVE_NOTIFY_EMAIL=
APP_URL= # Used in email links
CORS_ORIGINS= # Comma-separated, production only
```
Use `.env` for dev, `.env.test` for tests.
--- ---
## Architecture & Key Patterns ## Architecture & Key Patterns
### Request Flow ### Request flow
``` ```
Request → CORS → Cookie → Rate-limit → Security headers Request → CORS → Cookie → Rate-limit → Security headers
→ requirePermission() or requireAuth() → requirePermission() / requireAnyPermission()
Zod schema validation (parseBody helper) parseBody(Schema) / parseId(param)
→ Route handler → Route handler → Service function → Prisma
Service function success(reply, data) | error(reply, czechMessage, status)
→ Prisma
→ success(reply, data) or error(reply, message, status)
``` ```
### Response Format ### Response format
All responses use this shape: `{ success: true, data, message?, pagination? }` on success,
`{ success: false, error }` on failure. Always the `success()`/`error()`/
paginated helpers — never raw `reply.send()`.
```typescript ### Services
// Success
{ success: true, data: T, message?: string, pagination?: {...} }
// Error Plain exported async functions; services own Prisma, routes stay thin.
{ success: false, error: string } Preferred error shape: return **token literals** (`"not_found"`,
``` `"invalid_transition"`, `"supplier_not_found"`, …) and let the ROUTE map
tokens to Czech messages + HTTP codes (the offers/issued-orders modules are
Use the `success()` and `error()` helpers in routes — never write raw `reply.send()`. the reference). Legacy services return `{ error: czech, status }` — fine to
keep until touched; never the discriminated-union style. Respect soft-delete
### Service Pattern (`is_deleted: false`) in reads where the model has it.
Services are plain exported async functions, no classes:
```typescript
// src/services/foo.service.ts
export async function getFoo(id: number) {
const result = await prisma.foo.findUnique({ where: { id } });
if (!result) return { error: "Not found", status: 404 };
return { data: result };
}
// src/routes/admin/foo.ts
const result = await getFoo(id);
if ("error" in result) return error(reply, result.error, result.status ?? 400);
return success(reply, result.data);
```
### Error Handling
- Routes map service errors to HTTP responses using the pattern above.
- Global error handler in `server.ts` catches all unhandled exceptions; returns 500 with Czech message.
- **Never silently swallow errors.** Even if a failure is non-fatal, log it: `app.log.error(e, 'context')`.
- Error messages are in Czech (this is intentional — user-facing messages, Czech company).
### Permissions ### Permissions
```typescript `requirePermission("entity.action")` / `requireAnyPermission(…)`. The admin
// Route-level guard role bypasses checks (shortcut: `authData.roleName === "admin"`
fastify.addHook("preHandler", requirePermission("invoices.view")); ⚠️ `AuthData` has **`roleName`**, not `role`; don't type `authData` as `any`).
// or multiple GET list/detail endpoints need a permission guard too, NOT bare `requireAuth`
fastify.addHook( (bare-auth reads leak data to any logged-in user). Frontend rule: a _view_
"preHandler", permission opens pages read-only; an _edit_ permission unlocks fields and
requirePermission("invoices.view", "invoices.edit"), save buttons.
);
// Admin role bypasses all permission checks ### Audit
// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete")
```
### Audit Logging `logAudit()` on every create/update/delete (and security-relevant actions)
with `oldValues`/`newValues`. Use a `"(koncept)"`-style fallback in
Call `logAudit()` from `src/utils/audit.ts` whenever data is created/updated/deleted. descriptions for unnumbered drafts. Audit failures are non-fatal.
Pass `oldData` and `newData` so the diff is stored. Audit failures are non-fatal.
### Validation
Use Zod schemas from `src/schemas/`. All route bodies must be validated:
```typescript
const body = parseBody(FooSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
```
--- ---
## Date & Timezone Handling (Critical Gotcha) ## Dates & Timezones (Critical — three rules)
`src/config/env.ts` sets `process.env.TZ = 'Europe/Prague'` and overrides 1. **Global override:** `src/config/env.ts` sets `TZ=Europe/Prague` and
`Date.prototype.toJSON()` to return local time (not UTC). This means: monkey-patches `Date.prototype.toJSON()` to emit LOCAL time (PHP-migration
parity). All API date strings are local. Never assume UTC; never manually
offset. Do not remove the override.
2. **`@db.Date` columns — Prisma truncates filter Dates to the UTC date
part.** A local-midnight boundary (`new Date(y, m, d)` = 22:00/23:00 UTC of
the _previous_ day) silently shifts the queried window a day back — this was
a 14-site production bug class. Build `@db.Date` filter boundaries and
"today" writes as UTC-midnight instants of the LOCAL calendar day:
`new Date(Date.UTC(y, m, d))` or `utcMidnightOfLocalDay()` from
`src/utils/date.ts`; use half-open `gte`/`lt` ranges; never write a bare
`new Date()` into a `@db.Date` column (stores yesterday between 00:0002:00
Prague).
3. **Two deliberate write regimes — don't "fix" either:** the plan module
does date-only math in UTC; attendance/leave writes `@db.Date` at **local
noon** (`new Date(y, m, d, 12, 0, 0)`). Frontend "today" comes from
`localDateStr` (server) / `normalizeDateStr`/`todayLocalStr` (client) —
**never** `new Date().toISOString().split("T")[0]` (UTC date, a day early
in the Prague evening).
- `JSON.stringify(new Date())` returns local Czech time, not UTC. ---
- All API responses with Date fields will contain local time strings.
- Prisma stores dates as UTC internally, but they read back as local due to the TZ setting. ## Document Modules (offers · orders · issued orders · invoices)
- **Never assume UTC** when working with Date objects in this codebase.
- When writing new date comparisons or DB queries, use `new Date()` (already local) — do not manually offset. **Business rules (user/accountant decisions — do not revert):**
- The override exists for PHP migration compatibility and Czech date display.
- **VAT exists ONLY on invoices and received invoices.** Offers, order
confirmations and issued orders are NOT tax documents: net prices only, one
total labeled "Celkem bez DPH" / "Total excl. VAT", no VAT columns, no
explanatory VAT notice. Their VAT DB columns were dropped. Invoices created
from an order prefill the company default VAT rate.
- **Issued orders (objednávky vydané) take suppliers** (`sklad_suppliers`,
picker lookup at `GET /issued-orders/suppliers` under orders._ perms);
offers take customers. Issued orders share the `orders._` permission family
(deliberate).
- **PDF families:** the offer PDF keeps its own monochrome customer-facing
design; invoices + issued orders share the red-accent (#de3a3a) family.
- **Free-form PO content lives in the rich-text sections** ("Obsah" on issued
orders, "Rozsah projektu" on offers — CZ/EN titles, Quill content). Issued
orders deliberately have NO scope-template picker, NO item templates, NO
duplicate action.
**Platform conventions:**
- **Deferred numbering:** drafts carry a NULL document number
(nullable-unique column); the sequence number is consumed in-transaction on
finalize (draft→active / draft→sent) via `numbering.service.ts`, and
released-if-highest on delete. Never hardcode numbering; previews use the
collision-advancing helpers.
- **Status machines:** `VALID_TRANSITIONS` maps in the services; detail
responses return `valid_transitions` and the UI renders transition buttons
from it. Field edits outside the editable states (offers: draft/active;
issued: draft/sent) are rejected with an explicit Czech 400 — status-only
payloads still pass.
- **Edit locking:** lock/heartbeat/unlock route trio on both offers and
issued orders (30 s server TTL = 3 missed 10 s client heartbeats); detail
enriches `locked_by {user_id, username, full_name}` only when fresh and
held by another user; client side is the shared `useDocumentLock` hook +
`LockBanner`.
- **PDF serving:** `GET …/:id/file` serves the archived NAS copy and falls
back to a live render (re-archiving it) on a miss; drafts 404 (no number),
so the UI hides PDF buttons on drafts. Numbered-document archives write to
a deterministic path and **overwrite in place** — never uniquePath `_N`
suffixes. The issued-order PDF gets language from the document's `language`
column and carries a Puppeteer `footerTemplate` footer on every page
("Vystavil" left, "Strana X z Y"/"Page X of Y" centered) — Chromium has no
CSS margin-box footers; use `htmlToPdf(html, { footerTemplate })`.
- **Shared modules — extend, never fork local copies:**
`src/admin/components/document/` (DocumentItemsEditor, SectionsEditor,
LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` /
`useDocumentPdf` (+ list variant), `src/admin/lib/documentStatus.ts`,
CustomerPicker/SupplierPicker, `src/utils/pdf-shared.ts` (escapeHtml,
strict cleanQuillHtml, formatNum NBSP, formatCurrency, formatDate),
`src/utils/content-disposition.ts` (RFC 5987 — raw filenames with
diacritics in headers make Node throw 500s).
--- ---
## TOTP / 2FA ## TOTP / 2FA
- Secret stored AES-256-GCM encrypted in `users.totp_secret`. Secret AES-256-GCM encrypted in `users.totp_secret` (PHP-legacy base64 and TS
- Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex). hex formats both supported); encrypted backup codes with their own
- Backup codes stored as encrypted JSON array in `users.totp_backup_codes`. `/totp/backup-verify` login endpoint. `company_settings.require_2fa` forces
- When `company_settings.require_2fa = true`, all users must enroll before accessing the app. enrollment. Login flow: password → (if enrolled) single-use 5-min
- Login flow: password → if 2FA enabled → issue `loginToken` (5 min, single-use) → TOTP verify → issue access + refresh tokens. `loginToken` → TOTP/backup verify → access + refresh tokens. The enrollment
QR is generated locally with the `qrcode` package (never an external QR URL —
CSP blocks it and it would leak the secret).
--- ---
## Testing ## Testing
Tests live in `src/__tests__/`. They use Vitest + Supertest against a **real, throwaway test database** (`app_test`). - The suite MUTATES a real MySQL DB → it runs against **`app_test`** (never
dev `app`), configured in `.env.test`; `src/__tests__/setup.ts` hard-throws
- **Isolated test DB (`app_test`):** the suite MUTATES a real MySQL DB, so it runs against `app_test` (NOT dev `app`), configured in **`.env.test`** (gitignored). `src/__tests__/setup.ts` **hard-throws** if `DATABASE_URL` doesn't name a `*test*` database — a stray URL can never corrupt dev/prod data. To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test` with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → `DATABASE_URL=<app_test-url> npx prisma migrate deploy``DATABASE_URL=<app_test-url> npx tsx prisma/seed.ts`. unless `DATABASE_URL` names a `*test*` database.
- The suite spans 18 files / 247 tests — auth (incl. happy-path login/refresh-rotation), numbering, warehouse (incl. FIFO oldest-first), plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create, AI/Odin, received-invoices VAT. Server-side only (no component tests yet). - **After any schema change, apply migrations to the test DB too** or the
- Tests are now **type-checked** by `tsc -b` (via `tsconfig.test.json`) — keep them compiling. suite fails: set `DATABASE_URL` to the app_test URL and run
- Use `buildApp()` helper to spin up the Fastify instance for tests. `npx prisma migrate deploy`.
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout; `fileParallelism: false` (serialized) to avoid `number_sequences` deadlocks. - To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test`
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs. with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → migrate deploy →
`npx tsx prisma/seed.ts`.
When adding new features, add tests in `src/__tests__/`. Name test files `<feature>.test.ts`. - **Do not mock Prisma** — real DB catches schema/query bugs. Mock only true
externals (Puppeteer via `vi.mock("../utils/html-to-pdf")`, NAS reads via
`vi.spyOn`). Files run serialized (`fileParallelism: false`) to avoid
sequence deadlocks. Tests are type-checked by `tsc -b`.
- Fixture hygiene: far-future dates (year 2098) or unique prefixes + full
cleanup; FK-safe deletion order. New features get tests in
`src/__tests__/<feature>.test.ts`.
--- ---
## Frontend Conventions ## Frontend Conventions
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`. - Pages lazy-load via `React.lazy()` in `AdminApp.tsx`; auth via `useAuth()`,
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it. toasts via `useAlert()`.
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them. - **React Query for all fetching** (query options live in
- Data fetching uses **React Query** (query options + mutations in `src/admin/lib/queries/`); `src/admin/utils/api.ts` (`apiFetch`) handles token refresh automatically — dedupes concurrent refreshes, and token responses carry `expires_in` so the client refreshes BEFORE expiry (no reactive 401 churn). `src/admin/lib/queries/`; no fetch-in-useEffect; prefer deriving state over
- Custom hooks: `usePaginatedQuery`, `useTableSort`, `useDebounce`, `useModalLock`, `useReducedMotion`. effects). Mutations via `useApiMutation` (opt-in `envelope` mode passes the
- Styling: **Material UI v7** (Emotion) — theme in `src/admin/theme.ts`, `sx`/`styled()` in components, app-wide rules in `GlobalStyles.tsx`. **No hand-written `.css` files, no Tailwind.** Use `theme.vars` for colours so light/dark resolve automatically. server message through). `apiFetch` refreshes tokens proactively.
- **Rules of Hooks:** every hook runs BEFORE any early return — the
`<Forbidden/>` guard goes after all hooks. Lint enforces this as an error.
- **Invalidation:** broad domain keys (`["offers"]`, not `["offers","list"]`
— prefix matching covers sub-queries), and a mutation invalidates every
domain that embeds its data (user CRUD → trips+attendance; vehicle → trips;
invoice → orders; attendance/leave → **`["dashboard"]`**). The dashboard
also uses `refetchOnMount: "always"` as a backstop. Raw-`apiFetch` writes
still must invalidate their domains.
- Single sources of truth: status chips/labels in `lib/documentStatus.ts`;
audit entity labels in `lib/entityTypeLabels.ts`; plan categories from the
DB. Don't duplicate label maps.
### Query Invalidation Convention ### Styling (MUI v7 — no custom .css, no Tailwind)
Mutations must invalidate the **full domain** of any entity they touch. Prefer broad invalidation: - Theme in `src/admin/theme.ts` (cssVariables, light+dark schemes); global
rules in `GlobalStyles.tsx`; pages compose the kit in `src/admin/ui/`
- `["users"]` over `["users", "list"]` reach for raw `@mui/material` only for one-off layout/infra.
- `["trips"]` over `["trips", "vehicles"]` - Colours via **`theme.vars!.palette.*`**; alpha via channel tokens
(`rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`); per-scheme
Reason: React Query's `invalidateQueries` uses prefix matching, so `["trips"]` matches all one-offs via `theme.applyStyles("dark", …)`. No hardcoded hex.
`["trips", ...]` sub-queries. This means new queries are automatically invalidated without - Don't regress: status row tints are channel-alpha washes (never `.light`
updating every mutation handler. Inactive queries are only marked stale, not refetched, so fills — invisible text in dark mode); icon badges are solid `X.main` +
the performance cost is minimal. Optimize to targeted keys only when profiling shows a problem. white glyph; the theme persists ONLY under MUI's `mui-mode` localStorage
key (ThemeContext owns `<html data-theme>`); dialogs lock `<html>` via
When entity A embeds/references entity B, A's mutation handlers invalidate B's domain: `useDialogScrollLock`; Modal/ConfirmDialog freeze content through the close
fade; ConfirmDialogs stay open with `loading` during their request.
- User CRUD invalidates `["trips"]` and `["attendance"]` (both embed user data) - When refactoring pages, **preserve ALL data logic verbatim** (hooks,
- Vehicle CRUD invalidates `["trips"]` (trips reference vehicles) mutations, invalidate arrays, validation, permissions).
- Invoice CRUD invalidates `["orders"]` (orders reference invoices)
--- ---
## Frontend — Styling & MUI (migration complete, shipped in v2.0.0) ## AI Assistant — "Odin"
The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 lines of hand-written CSS are gone — **there are no custom `.css` files in `src/admin`**; the only stylesheets imported anywhere are the two third-party libs (`react-quill-new/dist/quill.snow.css`, `leaflet/dist/leaflet.css`). Look-and-feel is "Soft-SaaS shell + dense tables", dark/light preserved. Full history/decisions/gotchas live in agent memory (`project_mui_migration.md`); the original spec/plans are under `docs/superpowers/`. Admin-only (`ai.use` permission) Claude assistant at `/odin`. **Phase-1 scope
is invoice import ONLY** — general chat is guarded off in `OdinChat.submit`
**Where styling lives** (text-only messages get a canned reply, no API call). Backend
`src/services/ai.service.ts` + `src/routes/admin/ai.ts`; per-call cost ledger
- **Theme — `src/admin/theme.ts`:** `cssVariables` with `colorSchemeSelector: "[data-theme='%s']"`, light + dark `colorSchemes`, tokens, component defaults. Use **`theme.vars!.palette.*`** (the `vars` field is typed optional → `!`) so colours resolve per scheme; for alpha use the channel tokens — `rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`; for per-scheme one-offs use `theme.applyStyles("dark", { … })`. in `ai_usage` with a monthly budget cap (402 when exceeded). Invoice flow:
- **Global rules — `src/admin/GlobalStyles.tsx`:** reset, typography, scrollbar, `::selection`, view-transition timing, and the utility classes (`.text-*`, `.flex-*`, `.mb-*`, …), all theme-aware. (The pre-React bootstrap spinner is inlined in `src/App.tsx` because it mounts before MUI.) PDF → `extract-invoices` (structured output) → review cards → existing
- **Component kit — `src/admin/ui/`:** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField/MonthField/TimeField (date-fns v4, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable + mobile card layout + `rowSx`/`rowDanger`/`rowInactive`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField/SwitchField, Field, Alert, PageHeader, FilterBar, StatCard, ProgressBar, FileUpload, EmptyState, LoadingState, ThemeToggle, PageEnter (staggered page entrance), RichEditorRoot/RichTextView (Quill). Dev-only `/ui-kit` showcase route. `POST /received-invoices`. **`received_invoices.amount` is GROSS
(VAT-inclusive)** — VAT is back-calculated (`vatFromGross`). Phase-2 design
**Building / editing pages** notes live in `docs/superpowers/specs/`.
- Pages import from the **kit** (`src/admin/ui/`); reach for `@mui/material` (`styled`/`sx`) directly only for one-off layout or infra (theme, GlobalStyles, the Quill/Leaflet wrappers).
- Wrap a page's top-level sections in `<PageEnter>`. Filters go in `<FilterBar>` with **bare** controls (no `<Field>` label) at the standard widths.
- When refactoring, **preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation.
**Conventions learned — don't regress**
- **Status row tints** = subtle channel-alpha washes (`rgba(var(--mui-palette-X-mainChannel) / 0.12)`), NEVER a solid `.light` fill: `.light` is a light colour in BOTH schemes, so white dark-mode text on it is invisible. Voided/disabled rows = `opacity` + muted text.
- **Icon badges** = solid `X.main` tile + **white** glyph (not an `X.light` tile + `X.main` glyph — that's low-contrast).
- **Theme is single-source:** `src/context/ThemeContext.tsx` owns the `<html data-theme>` attribute + the View-Transitions cross-fade, and persists under MUI's **`mui-mode`** localStorage key (the same key MUI reads on mount). Do NOT add a second theme key — that desyncs page vs toggle on refresh.
- **Dialogs** lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroller); Modal/ConfirmDialog freeze title/label/loading through the close fade so nothing flashes. Login renders OUTSIDE AppShell.
**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`, `npm run lint`. The solution `tsconfig.json` now references `tsconfig.test.json` too, so `tsc -b` **type-checks the test files** (previously skipped). ESLint (flat config in `eslint.config.mjs`) enforces `react-hooks/rules-of-hooks` as an **error** — that rule catches the "hook after an early `return`" bug class; keep `npm run lint` at zero errors (warnings are advisory). Prettier config is `.prettierrc.json` (`npm run format`).
--- ---
## AI Assistant — "Odin" (shipped v2.1.5) ## Database & Migrations
Admins-only Claude assistant on the `/odin` page (sidebar nav item "Odin"). **Phase-1 scope is invoice import ONLY** — general chat is guarded off in `OdinChat.submit` to avoid spending API credits: a text-only message gets a canned reply and makes NO AI call. Removing that one guard re-enables the `/chat` path for a later "general assistant" phase. Conventions: snake_case columns; `created_at`/`updated_at` timestamps;
soft-delete via `is_deleted` where present; `number_sequences` owns all
document numbering.
- **SDK / model:** `@anthropic-ai/sdk``claude-sonnet-4-6`. Server-side only — `ANTHROPIC_API_KEY` in `.env` (already set on prod; **don't touch env, the user manages it**). The whole feature self-hides when the key is absent (`/ai/usage` returns `configured:false`). **Golden rules:**
- **Backend:** `src/services/ai.service.ts` (chat, `extractInvoice` [PDF→structured-output JSON], cost/budget tracking, conversation CRUD with server-side auto-title), `src/routes/admin/ai.ts` (`/api/admin/ai/*`: usage, budget, chat, extract-invoices, conversations[/:id/messages]), `src/schemas/ai.schema.ts`. Every route `requirePermission("ai.use")` (granted to **admin only** via migration).
- **Data:** `ai_usage` (per-call token-cost ledger), `ai_conversations` + `ai_chat_messages` (per-user, FK cascade, auto-title from first message), `company_settings.ai_monthly_budget_usd` (default $50; `assertBudgetAvailable` → 402 at the cap).
- **Frontend:** `src/admin/pages/Odin.tsx``src/admin/components/odin/`: `OdinChat` (orchestrator + state + the proven submit/extract/save logic), `OdinSidebar` (conversation list; inline ≥md, slide-in Drawer below md), `OdinThread` (messages, framer-motion entrance, Newsreader serif greeting hero), `OdinComposer` (claude-style rounded multiline pill, attach/send icons), `InvoiceReviewCard`, `OdinMark` (animated brand mark — CSS keyframes via `styled`, not inline style; opacity-glow fallback under reduced-motion), `types.ts`. Queries in `src/admin/lib/queries/ai.ts`. `Fraunces``Newsreader` font added to `index.html`.
- **Invoice flow:** attach PDF(s) → `extract-invoices` → editable review cards → save to the EXISTING `POST /received-invoices`. **`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is back-calculated via `vatFromGross` in `received-invoices.ts` (`amount * rate/(100+rate)`), used by both the manual form and the AI import. The save button is gated on `invoices.create`.
- **Phase 2 (general assistant — NOT built):** forward-looking design notes in `docs/superpowers/specs/2026-06-08-odin-phase2-general-assistant-notes.md` (tool-use over services, structured message-block storage, per-action authorization). Phase-1 spec/plan also under `docs/superpowers/`.
--- - **NEVER `prisma db push`** — it bypasses migration history and causes drift.
- **Every DB change is a tracked migration** — schema, permission/seed rows,
backfills, one-shot fixes. A migration's `migration.sql` may contain plain
`INSERT/UPDATE/DELETE`. No raw SQL against production, ever; `prisma db
seed` is dev-only convenience (`prisma/seed.ts` refuses to run with
`APP_ENV=production`) — prod baselines belong in migrations.
## Database Conventions **Making a schema change (this shell is non-interactive — `prisma migrate
dev` will refuse to run; use this recipe):**
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
- Soft-delete via `is_deleted` boolean (not all tables, check schema).
- Timestamps: `created_at`, `updated_at` (auto-managed by Prisma).
- Number sequences (`number_sequences` table) manage invoice/quotation numbering — never hardcode numbering logic.
- All significant tables have audit log entries. Check `audit_logs` model for the schema.
---
## Database Migrations (Critical Workflow)
**Golden rule: NEVER use `prisma db push`.** It bypasses migration history and causes drift
between the schema and migration tracking. Always use `prisma migrate dev` for every schema change.
**Every database change or manipulation must be a tracked Prisma migration.** This includes:
- Schema changes (tables, columns, indexes, constraints) — via `prisma migrate dev`
- Permission/role/seed data changes — via a migration that does the INSERTs/DELETEs explicitly
- Lookup data, default settings, or any other row-level baseline that production needs
- Backfills, data fixes, and one-shot corrections that should be in sync across environments
**What is NOT acceptable:**
- Running raw SQL against production (`mysql -e "..."`, `npx prisma db execute`, `pm2 exec`)
- `npx prisma db seed` as the only way to populate data (seed is dev-only convenience; prod must run the same SQL via a migration)
- "I'll fix it in the seed file" or "I'll add a row manually" without a migration to back it
The reason: every change to the production database must be reviewable, reversible, and reproducible from a fresh checkout. External SQL commands and seed-only changes cause drift — prod and dev diverge, rollback gets harder with every manual change, and the next deploy surprises us.
If you need to insert/update/seed data on production, write a migration. The migration's `migration.sql` is a normal SQL file — `INSERT INTO ...`, `UPDATE ...`, `DELETE ...` are all valid. Prisma will run it via `prisma migrate deploy` like any other migration.
### Making schema changes
```
1. Edit prisma/schema.prisma
2. npx prisma migrate dev --name descriptive_name
→ This creates a migration in prisma/migrations/ AND applies it to dev DB
3. npx prisma generate
4. Commit BOTH schema.prisma AND the new migration folder
git add prisma/schema.prisma prisma/migrations/
```
### Verifying before production deploy
```bash ```bash
# Preview what SQL will run on production (no changes applied) # 1. Edit prisma/schema.prisma (ask the user to stop their dev server first!)
npx prisma migrate diff \ # 2. Generate the migration SQL into a new folder:
--from-url "mysql://user:pass@prod:3306/app" \ mkdir prisma/migrations/<yyyyMMddHHmmss>_<name>
--to-schema-datamodel prisma/schema.prisma \ npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
--script > prisma/migrations/<...>/migration.sql # strip the BOM if written via PowerShell Out-File!
# 3. Review the SQL, then apply + regenerate:
# Empty output = no diff. If it shows SQL, review it before deploying. npx prisma migrate deploy
npx prisma generate
# 4. Apply to the test DB as well (suite breaks otherwise):
# DATABASE_URL=<app_test url> npx prisma migrate deploy
# 5. Commit schema.prisma AND the migration folder together.
``` ```
### Deploying migrations to production Deployment, drift checks, hotfix-migration shipping and baselining:
see **`docs/release.md`**.
The release process runs `prisma migrate deploy` on production.
This applies only pending migrations — safe, idempotent.
### If migrations get out of sync (drift)
```bash
# Diff production DB against local schema to find drift
npx prisma migrate diff \
--from-url "mysql://prod" \
--to-schema-datamodel prisma/schema.prisma \
--script
# If drift is safe (CREATE only, no DROPs): apply with db push ONCE, then baseline
# If drift includes DROPs: investigate before touching production
```
### Baselinining a database that has no migrations
If production was synced with `db push` and has no `_prisma_migrations` table:
```bash
# 1. Create initial migration locally
npx prisma migrate dev --name init
# 2. Copy to production and mark as applied (no SQL runs)
scp -r prisma/migrations user@prod:/var/www/app-ts/prisma/
ssh user@prod
cd /var/www/app-ts
npx prisma migrate resolve --applied <migration_name>
```
--- ---
## Conventions (enforcedverified by the 2026-06-06 audit) ## Enforced Conventions (single source — merged 2026-06 audits)
These are the unified rules across the codebase. Follow them for new code; the
audit found and fixed the deviations. Full report: `docs/codebase-audit-2026-06-06.md`.
**Routes** **Routes**
- **Responses:** always `success(reply, data[, status, message])` / `error(reply, msg, status)` / the paginated helper. Never raw `reply.send({ success: true, ... })`. (Some legacy files still do — convert when you touch them.) - `success()`/`error()`/paginated helpers only; `parseId` for numeric params
- **Route ids:** parse numeric params with `parseId((request.params as any).id, reply)` then `if (id === null) return;`. Do not use raw `parseInt` (it yields `NaN`, not a 400). (`if (id === null) return;`); `parseBody` for every body; permission guards
- **Bodies:** validate every body with `parseBody(Schema, request.body)``if ("error" in body) return error(reply, body.error, 400)`. on reads AND writes; `logAudit` with old/new values on every mutation.
- **Permissions:** guard with `requirePermission` / `requireAnyPermission`. The admin shortcut is `authData.roleName === "admin"`. ⚠️ `AuthData` has **`roleName`**, not `role` (`role` only exists on `JwtPayload`). Don't type `authData` as `any` — it hides exactly this bug. - Role-management writes are admin-only and re-checked (no creating or
- **Audit:** call `logAudit` on every create/update/delete (and on security-relevant actions like session/token termination) with `oldValues`/`newValues`. rewriting the `admin` role).
**Services**
- Plain exported async functions; return `{ data }` or `{ error, status }` (preferred over discriminated unions). Services own Prisma; routes stay thin.
- Respect soft-delete (`is_deleted: false`) in reads where the model has it.
**Schemas (Zod 4)** **Schemas (Zod 4)**
- Use Zod 4 idioms: `z.strictObject({...})` / `z.looseObject({...})` (not deprecated `.strict()` / `.passthrough()`). - `z.strictObject`/`z.looseObject` (not deprecated `.strict()`/`.passthrough()`).
- Shared coercion helpers (number-from-form, nullable-number, boolean-from-form) belong in `src/schemas/common.ts` — don't copy-paste the `z.union([z.number(), z.string()]).transform(Number)` idiom (it's duplicated ~150× today; consolidating is a tracked follow-up). New number coercions should guard against `NaN`. - **Shared coercion helpers in `src/schemas/common.ts` are MANDATORY** for
- User-facing messages in **Czech**; identifiers/keys in English. form-coerced fields: `numberFromForm`, `numberInRange`,
`nonNegativeNumberFromForm`, `positiveNumberFromForm`,
`intIdFromForm`/`nullableIntIdFromForm`, `nonNegativeIntFromForm`,
`booleanFromForm`, `isoDateString`, `dateTimeString`/`nullableDateTimeString`,
`timeString`, `emailOrEmpty`, `DocumentSectionSchema`. NEVER the raw
`z.union([z.number(), z.string()]).transform(Number)` idiom (silent NaN —
the single biggest historical bug class). FK ids → intIdFromForm;
quantities/prices → nonNegative/positive; bounded → numberInRange.
- The date/time helpers are deliberately **lenient** (strip trailing time
components) because edit forms re-submit `toJSON`-serialized values.
Optional emails: `emailOrEmpty.nullish()` (a bare `.email()` 400s the form).
- Align `max()` caps with the DB column widths (an over-cap string 500s at
Prisma instead of 400ing at Zod). Update schemas derive via
`.partial()` (+ `.omit()` for immutable fields like document numbers) —
and therefore must NOT carry top-level `.default()`s (Zod 4 `.partial()`
still injects field defaults into partial payloads).
- User-facing messages in **Czech**; identifiers/tokens in English.
**Frontend** **Determinism & concurrency**
- **Query invalidation:** invalidate the **broad domain key** (`["offers"]`, not `["offers","list"]`). Prefix-matching covers sub-queries. - Timestamp/date-only sorts ALWAYS get an `{ id }` tiebreak (second-precision
- **Single source of truth for shared maps:** audit `entity_type` → Czech label lives in `src/admin/lib/entityTypeLabels.ts` and MUST be keyed to the server's `EntityType` values (add the new key there when you add an entity type). Plan categories come from the DB via `lib/queries/plan.ts`. Don't duplicate label maps across files or across server/client. columns make single-key sorts non-deterministic).
- Prefer deriving state over `useEffect`; use React Query for fetching, not effects. - Read-modify-write of shared rows (stock, balances, sequences) runs inside
`prisma.$transaction` with `SELECT … FOR UPDATE` via **`tx.$queryRaw`**
(not `$executeRaw`), locking in global ascending-id order. Multi-statement
writes (header + items + sections) belong in ONE transaction.
- Uniqueness checks go INSIDE the create transaction (pass the `tx` client to
the checker) and catch `P2002` → 409 as backstop.
**Dates (two deliberate regimes — don't "fix" either)** **Errors**
- **Plan module** does all date-only math in **UTC** (`setUTCDate`, `toISOString().slice(0,10)`) because its columns are `@db.Date` (UTC-midnight). Correct and stable. - Never silently swallow — every catch logs (`console.*` in services; there
- **Attendance/leave** writes `@db.Date` at **local noon** (`new Date(y, m, d, 12, 0, 0)`). is no request-scoped logger there). The only allowed silent catch is an
- **Frontend "today" / date-string round-trips:** use `utils/date.ts` `localDateStr` (server) / `normalizeDateStr` (client). **Never** use `new Date().toISOString().split("T")[0]` for "today" — it's the UTC date and is a day early during the late-evening Prague window. _expected_ condition (e.g. ENOENT-as-existence-check) **with a comment**.
- Prefer explicit Czech 4xx over silently ignoring submitted fields.
## Conventions (enforced — added by the 2026-06-09 full audit)
The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. These are now rules — `npm run lint` + `tsc -b` (which now type-checks tests) catch some automatically.
**Zod validation — shared coercion helpers are MANDATORY**
- All numeric/boolean/date/email form fields MUST use the helpers in **`src/schemas/common.ts`**: `numberFromForm`, `numberInRange(min,max)`, `nonNegativeNumberFromForm`, `positiveNumberFromForm`, `intIdFromForm`, `nullableNumberFromForm`/`nullableIntIdFromForm`, `booleanFromForm`, `isoDateString`, `timeString`, `emailOrEmpty`.
- **NEVER** the raw `z.union([z.number(), z.string()]).transform(Number)` idiom — it silently yields `NaN` that flows into Prisma/business math (this was the single biggest bug class). FK ids → `intIdFromForm`/`nullableIntIdFromForm`; quantities/prices → `nonNegative`/`positive`; bounded values (VAT `0100`, month `112`) → `numberInRange`.
- `isoDateString`/`timeString` are **lenient** (they strip a trailing time/seconds component) because an edit form re-submits a `@db.Date` that the `toJSON` override serialised as `"YYYY-MM-DDT00:00:00"`. Use them for date/time fields, not a bare regex.
- An optional email a form may submit as `""`**`emailOrEmpty.nullish()`** (a bare `.email()` 400s the whole form, blocking unrelated saves).
**Deterministic ordering — always tiebreak a timestamp sort with `id`**
- `created_at`/`received_at` are **second-precision** (`@db.Timestamp(0)`/`DateTime(0)`), so `orderBy: { created_at: "desc" }` alone is non-deterministic for same-second rows. ALWAYS add `{ id: "desc" }` (or `asc`) as the secondary sort. (Bit `plan.service.resolveCell`/`resolveGrid` and warehouse FIFO `selectFifoBatches`.)
**Concurrency — locking discipline for stock / balance / sequence mutations**
- Any service op that read-modify-writes shared rows (stock, batches, reservations, balances, number sequences) MUST: run inside a `prisma.$transaction`; take the row lock with `SELECT … FOR UPDATE` via **`tx.$queryRaw`** (NOT `$executeRaw` — it does not reliably hold the lock); and lock rows in one global **ascending-id order** (parent → items → batches) so concurrent paths can't deadlock. See `warehouse.service.ts` `lockParentRow`/`lockRowsForUpdate` and `attendance.service.ts` `lockUserRow`.
- **Uniqueness checks** (username/email/document number) go INSIDE the create transaction (re-check immediately before insert) and catch `P2002` → 409. A pre-transaction check is a TOCTOU race that surfaces as a 500.
**Permissions — guard reads, not just writes**
- GET list/detail endpoints need a `requirePermission`/`requireAnyPermission` guard, NOT bare `requireAuth` (bare-auth reads leak data to any logged-in user). Role-management writes are admin-only and re-checked (no privilege escalation; no creating/rewriting the `admin` role).
**Frontend — Rules of Hooks (lint-enforced) + no UTC-today**
- ALL hooks (`useState`, `useApiMutation`, `useMemo`, …) run BEFORE any early `return` (the `<Forbidden/>`/`<Navigate/>` permission guard goes AFTER every hook). `eslint.config.mjs` sets `react-hooks/rules-of-hooks` to **error**`npm run lint` must stay at 0 errors. This was a systemic crash bug across ~14 pages.
- Mutations invalidate the **broad domain key**; a mutation that writes via raw `apiFetch` must still `invalidateQueries` the domain it touched (several dashboard widgets were missing this).
**Safety**
- `prisma/seed.ts` **refuses to run when `APP_ENV=production`** (it wipes/reseeds permissions). Never seed prod.
- Every catch logs (never silently swallow) — the NAS managers were the worst offenders.
--- ---
## Known Issues & Gotchas ## Known Gotchas
1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization. 1. **CJS/ESM:** the server compiles to CommonJS; Vitest runs ESM
(`vitest.config.ts` bridges it). Beware ESM-only dependencies.
2. **CJS/ESM mismatch in tests** — Server compiles to CommonJS (`tsconfig.server.json`), but Vitest runs in ESM by default. The `vitest.config.ts` resolves this, but be careful when adding dependencies that only support ESM. 2. **Rich text / PDF security:** every rich-text/HTML field gets server-side
DOMPurify + the strict `cleanQuillHtml` from `src/utils/pdf-shared.ts` on
3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes. the PDF path AND sanitization before any `dangerouslySetInnerHTML`. Never
pass unsanitized user data into Puppeteer templates; string-built HTML
4. **Never swallow errors** — log at minimum; never use empty `catch` blocks. The service layer logs via `console.*` (there is no request-scoped pino logger in services). The NAS managers' previously-silent filesystem catches are now logged. One exception: a `catch` that handles an _expected_ condition (e.g. `ENOENT` used as an existence check) may stay silent **only with a comment** saying so. (print views, PDF templates) must `escapeHtml` every interpolation.
3. **NAS paths** may be unmounted in dev (`Z:`) — NAS features must degrade
5. **HTML sanitization is in place — keep applying it** — Rich-text fields (invoice notes, quotation scope, order notes) ARE sanitized: server-side DOMPurify (jsdom) plus a `cleanQuillHtml` regex pass at all three PDF routes, and the frontend sanitizes before every `dangerouslySetInnerHTML` (InvoiceDetail, OrderDetail). (The old "sanitization gap" note was stale — verified by the 2026-06-06 audit.) When you add ANY new rich-text/HTML field, apply the same sanitization on BOTH the PDF path and the render path. with logged errors, and tests stub NAS reads.
4. **Prisma client**: regenerate after every schema change; it is not
6. **Puppeteer PDF generation** — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates. committed. (On prod this is a mandatory deploy step — see docs/release.md.)
5. **No CSRF tokens** by design (SameSite=Strict + CORS) — do not weaken CORS.
7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable. 6. **Czech locale hardcoded** in messages/month names — intentional.
7. **Cross-login caches:** React Query keys are user-agnostic; the query
8. **Prisma client regeneration** — After any schema change and migration, run `npx prisma generate`. The generated client is not committed to git. cache is cleared on login/logout in AuthContext — keep it that way.
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
11. **Seed file is dev-only**`prisma/seed.ts` is for local dev convenience. It is **not** the production data source. Any data the production database must have (permissions, default roles, baseline rows) belongs in a migration's `migration.sql`, not in the seed file. `npx prisma db seed` must never be run against production.
--- ---
## Release Process ## Release
1. Bump version in `package.json` Process, deployment commands, hotfix-migration shipping and verification
2. `npm run build` checklist: **`docs/release.md`**. Run the full test suite before tagging; the
3. Commit and tag (`git tag -a vX.Y.Z`) user picks version numbers. **Never push to production or restart services
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`) without explicit confirmation.**
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
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`
- 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`
- Apply Prisma migrations: `npx prisma migrate deploy`
- Restart: `pm2 restart app-ts --update-env`
### Hotfixing a migration that was added after the tarball was built
If you write a new `prisma/migrations/<timestamp>_*` folder **after** the
release tarball has already been built and shipped, that migration is
NOT in the tarball and `prisma migrate deploy` on prod will report
"No pending migrations". The release tarball re-build re-runs the whole
release, but for a one-off hotfix you can ship just the new migration
folder:
```bash
# Local
cd prisma/migrations
tar -czf /tmp/warehouse_perms_migration.tar.gz <timestamp>_<name>/
scp /tmp/warehouse_perms_migration.tar.gz boha_admin@192.168.50.100:/tmp/
# On prod
ssh boha_admin@192.168.50.100
cd /var/www/app-ts/prisma/migrations
sudo -u boha_admin tar -xzf /tmp/warehouse_perms_migration.tar.gz
cd /var/www/app-ts
npx prisma migrate deploy
pm2 restart app-ts --update-env
```
The migration is still tracked in git and applied via `prisma migrate
deploy` — the only thing the tarball is doing is delivering the
`migration.sql` to prod, which is the same mechanism the main release
uses. This is preferred over running raw SQL on prod (which the policy
in "Database Migrations" forbids).
Do not push directly to production or restart services without confirmation.

96
docs/release.md Normal file
View File

@@ -0,0 +1,96 @@
# Release & deployment runbook — boha-app-ts
Referenced from CLAUDE.md. **Never deploy to production or restart services
without explicit user confirmation.**
## Standard release
1. Run the full gates locally: `npx vitest run` (all green), `npx tsc -b --noEmit`,
`npm run lint` (0 errors).
2. Bump version: `npm version X.Y.Z --no-git-tag-version` (the user picks the number).
3. `npm run build`
4. Commit and tag: `git tag -a vX.Y.Z`
5. Push to Gitea: `git push origin master && git push origin vX.Y.Z`
6. Create tarball:
```bash
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.
7. Deploy via SSH to production (`boha_admin@192.168.50.100`, path `/var/www/app-ts`):
```bash
scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/
ssh boha_admin@192.168.50.100
cd /var/www/app-ts
rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json
tar -xzf /tmp/app-ts-X.Y.Z.tar.gz
npm install --omit=dev
npx prisma generate # MANDATORY — see below
npx prisma migrate deploy
pm2 restart app-ts --update-env
```
**`npx prisma generate` is mandatory.** `npm install` skips client
regeneration when dependencies didn't change, leaving a stale client that
still selects dropped/renamed columns → P2022 500s in prod (this caused
the v2.4.0 incident: every issued-orders query failed until generate ran).
8. Verify: `pm2 list` (status online, correct version, restart counter not
climbing), `npx prisma migrate status` ("up to date"),
`curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3001/` → 200,
and grep logs for errors from the CURRENT pid only (the out log keeps old
errors from prior pids).
9. Clean up tarballs locally and in `/tmp/` on the server.
Risky releases (framework jumps, FK/constraint-altering migrations) get a
read-only pre-flight first: `prisma migrate status` on prod, verify FK
constraint names the migration DROPs, check no data violates new
UNIQUE/RESTRICT constraints — and confirm with the user before the
irreversible steps. The prod DB is backed up by a cron job (no manual
mysqldump needed).
## Hotfixing a migration added after the tarball was built
A migration folder created after the release tarball shipped is not on prod,
so `prisma migrate deploy` reports "No pending migrations". Ship just the
migration folder (still tracked in git — this is NOT raw SQL on prod):
```bash
# Local
cd prisma/migrations
tar -czf /tmp/<name>_migration.tar.gz <timestamp>_<name>/
scp /tmp/<name>_migration.tar.gz boha_admin@192.168.50.100:/tmp/
# On prod
ssh boha_admin@192.168.50.100
cd /var/www/app-ts/prisma/migrations
tar -xzf /tmp/<name>_migration.tar.gz
cd /var/www/app-ts
npx prisma migrate deploy
pm2 restart app-ts --update-env
```
## Drift check / preview before deploy
```bash
# Preview what SQL would bring the dev DB (per prisma.config.ts) to the local schema
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script
```
Empty output = no diff. (Prisma 7 removed `--from-url`; diffs against another
DB need its URL in a config/env override.) If drift includes DROPs,
investigate before touching production.
## Baselining a database that has no migrations
If a database was synced without migration history (no `_prisma_migrations`
table): create the initial migration locally, copy `prisma/migrations` to the
server, then mark it applied without running SQL:
```bash
npx prisma migrate resolve --applied <migration_name>
```

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", "name": "app-ts",
"version": "2.1.5", "version": "2.4.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.1.5", "version": "2.4.6",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.102.0", "@anthropic-ai/sdk": "^0.102.0",
@@ -26,7 +26,6 @@
"@prisma/adapter-mariadb": "^7.8.0", "@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"@tanstack/react-query": "^5.100.5", "@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
@@ -52,8 +51,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/bcryptjs": "^2.4.6", "@types/jsdom": "^28.0.1",
"@types/dompurify": "^3.0.5",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
@@ -64,7 +62,6 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
@@ -1729,9 +1726,9 @@
} }
}, },
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "1.19.11", "version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18.14.1" "node": ">=18.14.1"
@@ -3010,13 +3007,6 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -3042,16 +3032,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -3076,6 +3056,7 @@
"version": "28.0.1", "version": "28.0.1",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-28.0.1.tgz",
"integrity": "sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==", "integrity": "sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "*",
@@ -3088,6 +3069,7 @@
"version": "7.25.0", "version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz",
"integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==", "integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
@@ -3236,14 +3218,15 @@
"version": "4.0.5", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true, "license": "MIT",
"license": "MIT" "optional": true
}, },
"node_modules/@types/yauzl": { "node_modules/@types/yauzl": {
"version": "2.10.3", "version": "2.10.3",
@@ -4154,36 +4137,6 @@
"node": ">=18" "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": { "node_modules/chart.js": {
"version": "4.5.1", "version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@@ -4297,31 +4250,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/confbox": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
@@ -4713,6 +4641,7 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=0.12" "node": ">=0.12"
@@ -5835,16 +5764,6 @@
"integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==",
"license": "MIT" "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": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -7258,6 +7177,7 @@
"version": "7.3.0", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"entities": "^6.0.0" "entities": "^6.0.0"
@@ -8087,16 +8007,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -8244,19 +8154,6 @@
"node": ">=8" "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": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -8577,22 +8474,6 @@
"node": ">=14.18.0" "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": { "node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -8817,16 +8698,6 @@
"node": ">=20" "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": { "node_modules/ts-algebra": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.2.0", "version": "2.4.6",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
@@ -14,7 +14,6 @@
"preview": "vite preview", "preview": "vite preview",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:pull": "prisma db pull", "db:pull": "prisma db pull",
"db:push": "prisma db push",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
@@ -47,7 +46,6 @@
"@prisma/adapter-mariadb": "^7.8.0", "@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"@tanstack/react-query": "^5.100.5", "@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
@@ -73,8 +71,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/bcryptjs": "^2.4.6", "@types/jsdom": "^28.0.1",
"@types/dompurify": "^3.0.5",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
@@ -85,7 +82,6 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/supertest": "^7.2.0", "@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
@@ -98,5 +94,8 @@
"typescript-eslint": "^8.61.0", "typescript-eslint": "^8.61.0",
"vite": "^8.0.0", "vite": "^8.0.0",
"vitest": "^4.1.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

@@ -0,0 +1,32 @@
-- Invoices: rich-text "Obsah" sections (mirrors issued_order_sections) replace
-- the printed notes block; notes become internal-only. Existing printed notes
-- are preserved by merging them into internal_notes before the column drop
-- (both are Quill HTML — concatenation is valid markup).
-- Preserve data: move printed notes into internal_notes
UPDATE `invoices`
SET `internal_notes` = CASE
WHEN `internal_notes` IS NULL OR `internal_notes` = '' THEN `notes`
ELSE CONCAT(`internal_notes`, `notes`)
END
WHERE `notes` IS NOT NULL AND `notes` <> '';
-- AlterTable
ALTER TABLE `invoices` DROP COLUMN `notes`;
-- CreateTable
CREATE TABLE `invoice_sections` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`invoice_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 `invoice_sections_invoice_id`(`invoice_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `invoice_sections` ADD CONSTRAINT `invoice_sections_fk` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;

View File

@@ -0,0 +1,28 @@
-- Suppliers get a structured address (street/city/postal_code/country) like
-- customers; the single free-text `address` blob is dropped. Existing data is
-- split best-effort: newlines normalized to ", ", first comma segment ->
-- street, the PSC (3+2 digits) -> postal_code, the remainder -> city.
-- Unparseable one-segment addresses keep their full text in `street`.
-- AlterTable: add the structured columns
ALTER TABLE `sklad_suppliers`
ADD COLUMN `street` VARCHAR(255) NULL AFTER `phone`,
ADD COLUMN `city` VARCHAR(255) NULL AFTER `street`,
ADD COLUMN `postal_code` VARCHAR(20) NULL AFTER `city`,
ADD COLUMN `country` VARCHAR(100) NULL AFTER `postal_code`;
-- Preserve data: best-effort split of the legacy free-text address
UPDATE `sklad_suppliers`
SET
`street` = NULLIF(TRIM(SUBSTRING_INDEX(REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), ',', 1)), ''),
`postal_code` = REGEXP_SUBSTR(`address`, '[0-9]{3}[ ]?[0-9]{2}'),
`city` = NULLIF(TRIM(BOTH ',' FROM TRIM(REGEXP_REPLACE(
SUBSTRING(
REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '),
CHAR_LENGTH(SUBSTRING_INDEX(REPLACE(REPLACE(`address`, '\r', ''), '\n', ', '), ',', 1)) + 2
),
'[0-9]{3}[ ]?[0-9]{2}', ''))), '')
WHERE `address` IS NOT NULL AND `address` <> '';
-- AlterTable: drop the legacy blob
ALTER TABLE `sklad_suppliers` DROP COLUMN `address`;

View File

@@ -0,0 +1,43 @@
-- Suppliers become a full mirror of the customers model: dedicated
-- contact_person/email/phone/notes columns are replaced by the same
-- custom_fields JSON blob customers use ({"fields":[{name,value,showLabel}],
-- "field_order":[]}). Existing contact data is preserved as custom fields.
-- AlterTable: add the custom_fields blob
ALTER TABLE `sklad_suppliers` ADD COLUMN `custom_fields` LONGTEXT NULL AFTER `country`;
-- Seed an empty container for rows that carry any contact data
UPDATE `sklad_suppliers`
SET `custom_fields` = JSON_OBJECT('fields', JSON_ARRAY(), 'field_order', JSON_ARRAY())
WHERE COALESCE(`contact_person`, '') <> ''
OR COALESCE(`email`, '') <> ''
OR COALESCE(`phone`, '') <> ''
OR COALESCE(`notes`, '') <> '';
-- Preserve data: each legacy column becomes one labeled custom field
UPDATE `sklad_suppliers`
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
JSON_OBJECT('name', 'Kontaktní osoba', 'value', `contact_person`, 'showLabel', TRUE))
WHERE COALESCE(`contact_person`, '') <> '';
UPDATE `sklad_suppliers`
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
JSON_OBJECT('name', 'E-mail', 'value', `email`, 'showLabel', TRUE))
WHERE COALESCE(`email`, '') <> '';
UPDATE `sklad_suppliers`
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
JSON_OBJECT('name', 'Telefon', 'value', `phone`, 'showLabel', TRUE))
WHERE COALESCE(`phone`, '') <> '';
UPDATE `sklad_suppliers`
SET `custom_fields` = JSON_ARRAY_APPEND(`custom_fields`, '$.fields',
JSON_OBJECT('name', 'Poznámky', 'value', `notes`, 'showLabel', TRUE))
WHERE COALESCE(`notes`, '') <> '';
-- AlterTable: drop the dedicated columns
ALTER TABLE `sklad_suppliers`
DROP COLUMN `contact_person`,
DROP COLUMN `email`,
DROP COLUMN `phone`,
DROP COLUMN `notes`;

View File

@@ -7,29 +7,29 @@ datasource db {
} }
model attendance { model attendance {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user_id Int user_id Int
shift_date DateTime @db.Date shift_date DateTime @db.Date
arrival_time DateTime? @db.DateTime(0) arrival_time DateTime? @db.DateTime(0)
arrival_lat Decimal? @db.Decimal(10, 8) arrival_lat Decimal? @db.Decimal(10, 8)
arrival_lng Decimal? @db.Decimal(11, 8) arrival_lng Decimal? @db.Decimal(11, 8)
arrival_accuracy Decimal? @db.Decimal(10, 2) arrival_accuracy Decimal? @db.Decimal(10, 2)
arrival_address String? @db.VarChar(500) arrival_address String? @db.VarChar(500)
break_start DateTime? @db.DateTime(0) break_start DateTime? @db.DateTime(0)
break_end DateTime? @db.DateTime(0) break_end DateTime? @db.DateTime(0)
departure_time DateTime? @db.DateTime(0) departure_time DateTime? @db.DateTime(0)
departure_lat Decimal? @db.Decimal(10, 8) departure_lat Decimal? @db.Decimal(10, 8)
departure_lng Decimal? @db.Decimal(11, 8) departure_lng Decimal? @db.Decimal(11, 8)
departure_accuracy Decimal? @db.Decimal(10, 2) departure_accuracy Decimal? @db.Decimal(10, 2)
departure_address String? @db.VarChar(500) departure_address String? @db.VarChar(500)
notes String? @db.Text notes String? @db.Text
project_id Int? project_id Int?
leave_type attendance_leave_type? @default(work) leave_type attendance_leave_type? @default(work)
leave_hours Decimal? @db.Decimal(4, 2) leave_hours Decimal? @db.Decimal(4, 2)
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0) updated_at DateTime? @default(now()) @db.Timestamp(0)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1") users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
attendance_project_logs attendance_project_logs[] attendance_project_logs attendance_project_logs[]
@@index([user_id, shift_date], map: "idx_attendance_user_date") @@index([user_id, shift_date], map: "idx_attendance_user_date")
@@index([user_id, departure_time], map: "idx_attendance_user_departure") @@index([user_id, departure_time], map: "idx_attendance_user_departure")
@@ -127,54 +127,54 @@ model bank_accounts {
} }
model company_settings { model company_settings {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
company_name String? @db.VarChar(255) company_name String? @db.VarChar(255)
street String? @db.VarChar(255) street String? @db.VarChar(255)
city String? @db.VarChar(255) city String? @db.VarChar(255)
postal_code String? @db.VarChar(20) postal_code String? @db.VarChar(20)
country String? @db.VarChar(100) country String? @db.VarChar(100)
company_id String? @db.VarChar(50) company_id String? @db.VarChar(50)
vat_id String? @db.VarChar(50) vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText custom_fields String? @db.LongText
logo_data Bytes? logo_data Bytes?
logo_data_dark Bytes? @db.MediumBlob logo_data_dark Bytes? @db.MediumBlob
quotation_prefix String? @db.VarChar(20) quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10) default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
uuid String? @unique @db.VarChar(36) uuid String? @unique @db.VarChar(36)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false) is_deleted Boolean? @default(false)
sync_version Int? @default(0) sync_version Int? @default(0)
order_type_code String? @db.VarChar(10) order_type_code String? @db.VarChar(10)
invoice_type_code String? @db.VarChar(10) invoice_type_code String? @db.VarChar(10)
require_2fa Boolean @default(false) require_2fa Boolean @default(false)
break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2) break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2)
break_duration_short Int? @default(15) break_duration_short Int? @default(15)
break_duration_long Int? @default(30) break_duration_long Int? @default(30)
clock_rounding_minutes Int? @default(15) clock_rounding_minutes Int? @default(15)
invoice_alert_email String? @db.VarChar(255) invoice_alert_email String? @db.VarChar(255)
leave_notify_email String? @db.VarChar(255) leave_notify_email String? @db.VarChar(255)
max_login_attempts Int? @default(5) max_login_attempts Int? @default(5)
lockout_minutes Int? @default(15) lockout_minutes Int? @default(15)
max_requests_per_minute Int? @default(300) max_requests_per_minute Int? @default(300)
available_vat_rates String? @db.LongText available_vat_rates String? @db.LongText
available_currencies String? @db.LongText available_currencies String? @db.LongText
smtp_from String? @db.VarChar(255) smtp_from String? @db.VarChar(255)
smtp_from_name String? @db.VarChar(255) smtp_from_name String? @db.VarChar(255)
offer_number_pattern String? @db.VarChar(100) offer_number_pattern String? @db.VarChar(100)
order_number_pattern String? @db.VarChar(100) order_number_pattern String? @db.VarChar(100)
invoice_number_pattern String? @db.VarChar(100) invoice_number_pattern String? @db.VarChar(100)
issued_order_number_pattern String? @db.VarChar(100) issued_order_number_pattern String? @db.VarChar(100)
issued_order_type_code String? @db.VarChar(10) issued_order_type_code String? @db.VarChar(10)
project_number_pattern String? @db.VarChar(100) project_number_pattern String? @db.VarChar(100)
project_type_code String? @db.VarChar(10) project_type_code String? @db.VarChar(10)
warehouse_receipt_prefix String? @db.VarChar(20) warehouse_receipt_prefix String? @db.VarChar(20)
warehouse_receipt_number_pattern String? @db.VarChar(100) warehouse_receipt_number_pattern String? @db.VarChar(100)
warehouse_issue_prefix String? @db.VarChar(20) warehouse_issue_prefix String? @db.VarChar(20)
warehouse_issue_number_pattern String? @db.VarChar(100) warehouse_issue_number_pattern String? @db.VarChar(100)
warehouse_inventory_prefix String? @db.VarChar(20) warehouse_inventory_prefix String? @db.VarChar(20)
warehouse_inventory_number_pattern String? @db.VarChar(100) warehouse_inventory_number_pattern String? @db.VarChar(100)
ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2) ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2)
} }
model customers { model customers {
@@ -193,55 +193,54 @@ model customers {
sync_version Int? @default(0) sync_version Int? @default(0)
invoices invoices[] invoices invoices[]
orders orders[] orders orders[]
issued_orders issued_orders[]
projects projects[] projects projects[]
quotations quotations[] quotations quotations[]
} }
model invoice_items { model invoice_items {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
invoice_id Int invoice_id Int
description String? @db.VarChar(500) description String? @db.VarChar(500)
item_description String? @db.Text item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3) quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20) unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2) unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
position Int? @default(0) position Int? @default(0)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1") invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
@@index([invoice_id], map: "invoice_id") @@index([invoice_id], map: "invoice_id")
} }
model invoices { model invoices {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50) invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
order_id Int? order_id Int?
customer_id Int? customer_id Int?
status String? @default("issued") @db.VarChar(30) status String? @default("issued") @db.VarChar(30)
currency String? @default("CZK") @db.VarChar(10) currency String? @default("CZK") @db.VarChar(10)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true) apply_vat Boolean? @default(true)
payment_method String? @db.VarChar(50) payment_method String? @db.VarChar(50)
constant_symbol String? @db.VarChar(20) constant_symbol String? @db.VarChar(20)
bank_name String? @db.VarChar(255) bank_name String? @db.VarChar(255)
bank_swift String? @db.VarChar(20) bank_swift String? @db.VarChar(20)
bank_iban String? @db.VarChar(50) bank_iban String? @db.VarChar(50)
bank_account String? @db.VarChar(50) bank_account String? @db.VarChar(50)
issue_date DateTime? @db.Date issue_date DateTime? @db.Date
due_date DateTime? @db.Date due_date DateTime? @db.Date
tax_date DateTime? @db.Date tax_date DateTime? @db.Date
paid_date DateTime? @db.Date paid_date DateTime? @db.Date
issued_by String? @db.VarChar(255) issued_by String? @db.VarChar(255)
billing_text String? @db.VarChar(500) billing_text String? @db.VarChar(500)
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
notes String? @db.Text internal_notes String? @db.Text
internal_notes String? @db.Text created_at DateTime? @default(now()) @db.DateTime(0)
created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) invoice_items invoice_items[]
invoice_items invoice_items[] invoice_sections invoice_sections[]
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_1") orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_2") customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_2")
@@index([customer_id], map: "customer_id") @@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date") @@index([due_date], map: "idx_invoices_due_date")
@@ -250,6 +249,19 @@ model invoices {
@@index([order_id], map: "order_id") @@index([order_id], map: "order_id")
} }
model invoice_sections {
id Int @id @default(autoincrement())
invoice_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)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_sections_fk")
@@index([invoice_id], map: "invoice_sections_invoice_id")
}
model item_templates { model item_templates {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String? @db.VarChar(255) name String? @db.VarChar(255)
@@ -348,8 +360,6 @@ model orders {
status String? @default("prijata") @db.VarChar(30) status String? @default("prijata") @db.VarChar(30)
currency String? @default("CZK") @db.VarChar(10) currency String? @default("CZK") @db.VarChar(10)
language String? @default("cs") @db.VarChar(5) 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) exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
scope_title String? @db.VarChar(500) scope_title String? @db.VarChar(500)
scope_description String? @db.Text scope_description String? @db.Text
@@ -368,31 +378,45 @@ model orders {
} }
model issued_orders { model issued_orders {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50) 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) status issued_orders_status @default(draft)
currency String? @default("CZK") @db.VarChar(10) currency String? @default("CZK") @db.VarChar(10)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
apply_vat Boolean? @default(true) order_date DateTime? @db.Date
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4) delivery_date DateTime? @db.Date
order_date DateTime? @db.Date language String? @default("cs") @db.VarChar(5)
delivery_date DateTime? @db.Date order_text String? @db.VarChar(500)
language String? @default("cs") @db.VarChar(5) internal_notes String? @db.Text
delivery_terms String? @db.VarChar(500) locked_by Int?
payment_terms String? @db.VarChar(500) locked_at DateTime? @db.DateTime(0)
issued_by String? @db.VarChar(255) created_at DateTime? @default(now()) @db.DateTime(0)
notes String? @db.Text modified_at DateTime? @db.DateTime(0)
internal_notes String? @db.Text issued_order_items issued_order_items[]
created_at DateTime? @default(now()) @db.DateTime(0) issued_order_sections issued_order_sections[]
modified_at DateTime? @db.DateTime(0) suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
issued_order_items issued_order_items[]
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_ibfk_1")
@@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") @@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 { model issued_order_items {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
issued_order_id Int issued_order_id Int
@@ -401,7 +425,6 @@ model issued_order_items {
quantity Decimal? @default(1.000) @db.Decimal(12, 3) quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20) unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2) unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
position Int? @default(0) 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") issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
@@ -439,29 +462,29 @@ model project_notes {
} }
model projects { model projects {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
project_number String? @unique @db.VarChar(50) project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255) name String? @db.VarChar(255)
customer_id Int? customer_id Int?
responsible_user_id Int? responsible_user_id Int?
quotation_id Int? quotation_id Int?
order_id Int? order_id Int?
status String? @default("aktivni") @db.VarChar(30) status String? @default("aktivni") @db.VarChar(30)
start_date DateTime? @db.Date start_date DateTime? @db.Date
end_date DateTime? @db.Date end_date DateTime? @db.Date
notes String? @db.Text notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[] attendance_project_logs attendance_project_logs[]
project_notes project_notes[] project_notes project_notes[]
sklad_issues sklad_issues[] sklad_issues sklad_issues[]
sklad_reservations sklad_reservations[] sklad_reservations sklad_reservations[]
work_plan_entries work_plan_entries[] work_plan_entries work_plan_entries[]
work_plan_overrides work_plan_overrides[] work_plan_overrides work_plan_overrides[]
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user") users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_1") customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_1")
quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_2") quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_2")
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_3") orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_3")
@@index([customer_id], map: "customer_id") @@index([customer_id], map: "customer_id")
@@index([responsible_user_id], map: "fk_projects_responsible_user") @@index([responsible_user_id], map: "fk_projects_responsible_user")
@@ -486,28 +509,26 @@ model quotation_items {
} }
model quotations { model quotations {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
quotation_number String? @unique @db.VarChar(50) quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(100) project_code String? @db.VarChar(100)
customer_id Int? customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
valid_until DateTime? @db.Date valid_until DateTime? @db.Date
currency String? @default("CZK") @db.VarChar(10) currency String? @default("CZK") @db.VarChar(10)
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) order_id Int?
apply_vat Boolean? @default(true) status String @default("active") @db.VarChar(20)
order_id Int? scope_title String? @db.VarChar(500)
status String @default("active") @db.VarChar(20) scope_description String? @db.Text
scope_title String? @db.VarChar(500) locked_by Int?
scope_description String? @db.Text locked_at DateTime? @db.DateTime(0)
locked_by Int? modified_at DateTime? @db.DateTime(0)
locked_at DateTime? @db.DateTime(0) orders orders[]
modified_at DateTime? @db.DateTime(0) projects projects[]
orders orders[] quotation_items quotation_items[]
projects projects[] customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "quotations_ibfk_1")
quotation_items quotation_items[] scope_sections scope_sections[]
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "quotations_ibfk_1")
scope_sections scope_sections[]
@@index([customer_id], map: "customer_id") @@index([customer_id], map: "customer_id")
@@index([quotation_number], map: "idx_quotations_number") @@index([quotation_number], map: "idx_quotations_number")
@@ -581,14 +602,14 @@ model roles {
} }
model scope_sections { model scope_sections {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
quotation_id Int quotation_id Int
position Int? @default(0) position Int? @default(0)
title String? @db.VarChar(500) title String? @db.VarChar(500)
title_cz String? @db.VarChar(500) title_cz String? @db.VarChar(500)
content String? @db.Text content String? @db.Text
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1") quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
@@index([quotation_id], map: "quotation_id") @@index([quotation_id], map: "quotation_id")
} }
@@ -657,38 +678,38 @@ model trips {
} }
model users { model users {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @unique(map: "username") @db.VarChar(50) username String @unique(map: "username") @db.VarChar(50)
email String @unique(map: "email") @db.VarChar(255) email String @unique(map: "email") @db.VarChar(255)
password_hash String @db.VarChar(255) password_hash String @db.VarChar(255)
first_name String @db.VarChar(50) first_name String @db.VarChar(50)
last_name String @db.VarChar(50) last_name String @db.VarChar(50)
role_id Int? role_id Int?
is_active Boolean? @default(true) is_active Boolean? @default(true)
last_login DateTime? @db.Timestamp(0) last_login DateTime? @db.Timestamp(0)
failed_login_attempts Int? @default(0) failed_login_attempts Int? @default(0)
locked_until DateTime? @db.Timestamp(0) locked_until DateTime? @db.Timestamp(0)
password_changed_at DateTime? @default(now()) @db.Timestamp(0) password_changed_at DateTime? @default(now()) @db.Timestamp(0)
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0) updated_at DateTime? @default(now()) @db.Timestamp(0)
totp_secret String? @db.VarChar(255) totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false) totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text totp_backup_codes String? @db.Text
totp_last_used_counter Int? totp_last_used_counter Int?
attendance attendance[] attendance attendance[]
leave_balances leave_balances[] leave_balances leave_balances[]
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers") leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers") leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers")
projects projects[] projects projects[]
trips trips[] trips trips[]
sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy") sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy") sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy")
sklad_reservations sklad_reservations[] @relation("SkladReservedBy") sklad_reservations sklad_reservations[] @relation("SkladReservedBy")
work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator") work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator")
work_plan_entries_owned work_plan_entries[] work_plan_entries_owned work_plan_entries[]
work_plan_overrides work_plan_overrides[] @relation("work_plan_overrides_creator") work_plan_overrides work_plan_overrides[] @relation("work_plan_overrides_creator")
work_plan_overrides_owned work_plan_overrides[] work_plan_overrides_owned work_plan_overrides[]
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1") roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
@@index([is_active], map: "idx_users_is_active") @@index([is_active], map: "idx_users_is_active")
@@index([role_id], map: "idx_users_role_id") @@index([role_id], map: "idx_users_role_id")
@@ -783,20 +804,21 @@ model sklad_categories {
} }
model sklad_suppliers { model sklad_suppliers {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @db.VarChar(255) name String @db.VarChar(255)
ico String? @db.VarChar(20) ico String? @db.VarChar(20)
dic String? @db.VarChar(20) dic String? @db.VarChar(20)
contact_person String? @db.VarChar(255) street String? @db.VarChar(255)
email String? @db.VarChar(255) city String? @db.VarChar(255)
phone String? @db.VarChar(50) postal_code String? @db.VarChar(20)
address String? @db.Text country String? @db.VarChar(100)
notes String? @db.Text custom_fields String? @db.LongText
is_active Boolean @default(true) is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[] receipts sklad_receipts[]
issued_orders issued_orders[]
@@map("sklad_suppliers") @@map("sklad_suppliers")
} }
@@ -810,7 +832,7 @@ model sklad_locations {
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
items sklad_item_locations[] items sklad_item_locations[]
receipt_lines sklad_receipt_lines[] receipt_lines sklad_receipt_lines[]
issue_lines sklad_issue_lines[] issue_lines sklad_issue_lines[]
inventory_lines sklad_inventory_lines[] inventory_lines sklad_inventory_lines[]
@@ -819,19 +841,19 @@ model sklad_locations {
} }
model sklad_items { model sklad_items {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
item_number String? @unique @db.VarChar(50) item_number String? @unique @db.VarChar(50)
name String @db.VarChar(255) name String @db.VarChar(255)
description String? @db.Text description String? @db.Text
category_id Int? category_id Int?
unit String @db.VarChar(20) unit String @db.VarChar(20)
min_quantity Decimal? @db.Decimal(12, 3) min_quantity Decimal? @db.Decimal(12, 3)
is_active Boolean @default(true) is_active Boolean @default(true)
notes String? @db.Text notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
category sklad_categories? @relation(fields: [category_id], references: [id], onDelete: SetNull) category sklad_categories? @relation(fields: [category_id], references: [id], onDelete: SetNull)
batches sklad_batches[] batches sklad_batches[]
item_locations sklad_item_locations[] item_locations sklad_item_locations[]
receipt_lines sklad_receipt_lines[] receipt_lines sklad_receipt_lines[]
@@ -846,50 +868,50 @@ model sklad_items {
model sklad_batches { model sklad_batches {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
item_id Int item_id Int
receipt_line_id Int @unique receipt_line_id Int @unique
quantity Decimal @db.Decimal(12, 3) quantity Decimal @db.Decimal(12, 3)
original_qty Decimal @db.Decimal(12, 3) original_qty Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2) unit_price Decimal @db.Decimal(12, 2)
received_at DateTime? @db.DateTime(0) received_at DateTime? @db.DateTime(0)
is_consumed Boolean @default(false) is_consumed Boolean @default(false)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id], onDelete: Restrict) receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id], onDelete: Restrict)
issue_lines sklad_issue_lines[] issue_lines sklad_issue_lines[]
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo") @@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
@@map("sklad_batches") @@map("sklad_batches")
} }
model sklad_item_locations { model sklad_item_locations {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
item_id Int item_id Int
location_id Int location_id Int
quantity Decimal @default(0) @db.Decimal(12, 3) quantity Decimal @default(0) @db.Decimal(12, 3)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations @relation(fields: [location_id], references: [id], onDelete: Restrict) location sklad_locations @relation(fields: [location_id], references: [id], onDelete: Restrict)
@@unique([item_id, location_id], map: "sklad_item_locations_unique") @@unique([item_id, location_id], map: "sklad_item_locations_unique")
@@map("sklad_item_locations") @@map("sklad_item_locations")
} }
model sklad_receipts { model sklad_receipts {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
receipt_number String? @unique @db.VarChar(50) receipt_number String? @unique @db.VarChar(50)
supplier_id Int? supplier_id Int?
delivery_note_number String? @db.VarChar(100) delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0) delivery_note_date DateTime? @db.DateTime(0)
received_by Int? received_by Int?
notes String? @db.Text notes String? @db.Text
status sklad_receipt_status @default(DRAFT) status sklad_receipt_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull) supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull)
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull) received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull)
items sklad_receipt_lines[] items sklad_receipt_lines[]
attachments sklad_receipt_attachments[] attachments sklad_receipt_attachments[]
@@index([supplier_id], map: "sklad_receipts_supplier_id") @@index([supplier_id], map: "sklad_receipts_supplier_id")
@@index([received_by], map: "sklad_receipts_received_by") @@index([received_by], map: "sklad_receipts_received_by")
@@ -897,50 +919,50 @@ model sklad_receipts {
} }
model sklad_receipt_lines { model sklad_receipt_lines {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
receipt_id Int receipt_id Int
item_id Int item_id Int
quantity Decimal @db.Decimal(12, 3) quantity Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2) unit_price Decimal @db.Decimal(12, 2)
location_id Int? location_id Int?
notes String? @db.VarChar(255) notes String? @db.VarChar(255)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade) receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict) location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
batch sklad_batches? batch sklad_batches?
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id") @@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
@@map("sklad_receipt_lines") @@map("sklad_receipt_lines")
} }
model sklad_receipt_attachments { model sklad_receipt_attachments {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
receipt_id Int receipt_id Int
file_name String @db.VarChar(255) file_name String @db.VarChar(255)
file_mime String @db.VarChar(100) file_mime String @db.VarChar(100)
file_size Int file_size Int
file_path String @db.VarChar(500) file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade) receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
@@map("sklad_receipt_attachments") @@map("sklad_receipt_attachments")
} }
model sklad_issues { model sklad_issues {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
issue_number String? @unique @db.VarChar(50) issue_number String? @unique @db.VarChar(50)
project_id Int project_id Int
issued_by Int? issued_by Int?
notes String? @db.Text notes String? @db.Text
status sklad_issue_status @default(DRAFT) status sklad_issue_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict) project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull) issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull)
items sklad_issue_lines[] items sklad_issue_lines[]
@@index([project_id], map: "sklad_issues_project_id") @@index([project_id], map: "sklad_issues_project_id")
@@index([issued_by], map: "sklad_issues_issued_by") @@index([issued_by], map: "sklad_issues_issued_by")
@@ -948,72 +970,72 @@ model sklad_issues {
} }
model sklad_issue_lines { model sklad_issue_lines {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
issue_id Int issue_id Int
item_id Int item_id Int
batch_id Int batch_id Int
quantity Decimal @db.Decimal(12, 3) quantity Decimal @db.Decimal(12, 3)
location_id Int? location_id Int?
reservation_id Int? reservation_id Int?
notes String? @db.VarChar(255) notes String? @db.VarChar(255)
issue sklad_issues @relation(fields: [issue_id], references: [id], onDelete: Cascade) issue sklad_issues @relation(fields: [issue_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
batch sklad_batches @relation(fields: [batch_id], references: [id], onDelete: Restrict) batch sklad_batches @relation(fields: [batch_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict) location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull) reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull)
@@index([issue_id], map: "sklad_issue_lines_issue_id") @@index([issue_id], map: "sklad_issue_lines_issue_id")
@@map("sklad_issue_lines") @@map("sklad_issue_lines")
} }
model sklad_reservations { model sklad_reservations {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
item_id Int item_id Int
project_id Int project_id Int
quantity Decimal @db.Decimal(12, 3) quantity Decimal @db.Decimal(12, 3)
remaining_qty Decimal @db.Decimal(12, 3) remaining_qty Decimal @db.Decimal(12, 3)
reserved_by Int? reserved_by Int?
notes String? @db.Text notes String? @db.Text
status sklad_reservation_status @default(ACTIVE) status sklad_reservation_status @default(ACTIVE)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict) project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id], onDelete: SetNull) reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id], onDelete: SetNull)
issue_lines sklad_issue_lines[] issue_lines sklad_issue_lines[]
@@index([item_id, status], map: "sklad_reservations_item_status") @@index([item_id, status], map: "sklad_reservations_item_status")
@@map("sklad_reservations") @@map("sklad_reservations")
} }
model sklad_inventory_sessions { model sklad_inventory_sessions {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
session_number String? @unique @db.VarChar(50) session_number String? @unique @db.VarChar(50)
notes String? @db.Text notes String? @db.Text
status sklad_inventory_status @default(DRAFT) status sklad_inventory_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
items sklad_inventory_lines[] items sklad_inventory_lines[]
@@map("sklad_inventory_sessions") @@map("sklad_inventory_sessions")
} }
model sklad_inventory_lines { model sklad_inventory_lines {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
session_id Int session_id Int
item_id Int item_id Int
location_id Int? location_id Int?
system_qty Decimal @db.Decimal(12, 3) system_qty Decimal @db.Decimal(12, 3)
actual_qty Decimal @db.Decimal(12, 3) actual_qty Decimal @db.Decimal(12, 3)
difference Decimal @db.Decimal(12, 3) difference Decimal @db.Decimal(12, 3)
notes String? @db.VarChar(255) notes String? @db.VarChar(255)
session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade) session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict) location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
@@index([session_id], map: "sklad_inventory_lines_session_id") @@index([session_id], map: "sklad_inventory_lines_session_id")
@@map("sklad_inventory_lines") @@map("sklad_inventory_lines")
@@ -1031,20 +1053,20 @@ model plan_categories {
} }
model work_plan_entries { model work_plan_entries {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user_id Int user_id Int
date_from DateTime @db.Date date_from DateTime @db.Date
date_to DateTime @db.Date date_to DateTime @db.Date
project_id Int? project_id Int?
category String @default("work") @db.VarChar(50) category String @default("work") @db.VarChar(50)
note String @db.VarChar(500) note String @db.VarChar(500)
created_by Int created_by Int
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0) updated_at DateTime? @default(now()) @db.Timestamp(0)
is_deleted Boolean? @default(false) is_deleted Boolean? @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction) projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
creator users @relation("work_plan_entries_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction) creator users @relation("work_plan_entries_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([user_id, date_from], map: "idx_wpe_user_from") @@index([user_id, date_from], map: "idx_wpe_user_from")
@@index([user_id, date_to], map: "idx_wpe_user_to") @@index([user_id, date_to], map: "idx_wpe_user_to")
@@ -1053,19 +1075,19 @@ model work_plan_entries {
} }
model work_plan_overrides { model work_plan_overrides {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user_id Int user_id Int
shift_date DateTime @db.Date shift_date DateTime @db.Date
project_id Int? project_id Int?
category String @db.VarChar(50) category String @db.VarChar(50)
note String @db.VarChar(500) note String @db.VarChar(500)
created_by Int created_by Int
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0) updated_at DateTime? @default(now()) @db.Timestamp(0)
is_deleted Boolean? @default(false) is_deleted Boolean? @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction) projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
creator users @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction) creator users @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([user_id, shift_date], map: "idx_wpo_user_date") @@index([user_id, shift_date], map: "idx_wpo_user_date")
@@index([shift_date], map: "idx_wpo_date") @@index([shift_date], map: "idx_wpo_date")

116
src/__tests__/ares.test.ts Normal file
View File

@@ -0,0 +1,116 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { aresLookupByIco, aresSearchByName } from "../services/ares.service";
// ARES is a true external — mock global fetch (the only allowed mock class).
const SUBJECT = {
ico: "22599851",
obchodniJmeno: "BOHA Automation s.r.o.",
dic: "CZ22599851",
sidlo: {
nazevStatu: "Česká republika",
nazevObce: "Turnov",
nazevCastiObce: "Turnov",
nazevUlice: "Nádražní",
cisloDomovni: 485,
psc: 51101,
textovaAdresa: "Nádražní 485, 51101 Turnov",
},
};
function mockFetchOnce(status: number, body?: unknown) {
return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response);
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("aresLookupByIco", () => {
it("maps a subject to the normalized prefill shape", async () => {
mockFetchOnce(200, SUBJECT);
const res = await aresLookupByIco("22599851");
expect(res).toEqual({
ico: "22599851",
dic: "CZ22599851",
name: "BOHA Automation s.r.o.",
street: "Nádražní 485",
city: "Turnov",
postal_code: "511 01",
country: "Česká republika",
});
});
it("builds Prague-style orientation numbers as 'Ulice 485/12a'", async () => {
mockFetchOnce(200, {
...SUBJECT,
sidlo: {
...SUBJECT.sidlo,
cisloOrientacni: 12,
cisloOrientacniPismeno: "a",
},
});
const res = await aresLookupByIco("22599851");
expect("street" in res && res.street).toBe("Nádražní 485/12a");
});
it("rejects a non-8-digit IČO without calling ARES", async () => {
const spy = vi.spyOn(globalThis, "fetch");
expect(await aresLookupByIco("123")).toEqual({ error: "invalid_ico" });
expect(await aresLookupByIco("abcdefgh")).toEqual({
error: "invalid_ico",
});
expect(spy).not.toHaveBeenCalled();
});
it("accepts an IČO with stray whitespace", async () => {
const spy = mockFetchOnce(200, SUBJECT);
const res = await aresLookupByIco(" 225 99851 ");
expect("ico" in res && res.ico).toBe("22599851");
expect(String(spy.mock.calls[0][0])).toContain("/22599851");
});
it("maps 404 to not_found and network failure to ares_unavailable", async () => {
mockFetchOnce(404);
expect(await aresLookupByIco("12345678")).toEqual({ error: "not_found" });
vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("ETIMEDOUT"));
expect(await aresLookupByIco("12345678")).toEqual({
error: "ares_unavailable",
});
});
});
describe("aresSearchByName", () => {
it("maps the result list and sends the name in the POST body", async () => {
const spy = mockFetchOnce(200, { ekonomickeSubjekty: [SUBJECT] });
const res = await aresSearchByName("BOHA Automation");
expect(Array.isArray(res)).toBe(true);
if (Array.isArray(res)) {
expect(res).toHaveLength(1);
expect(res[0].name).toBe("BOHA Automation s.r.o.");
expect(res[0].postal_code).toBe("511 01");
}
const init = spy.mock.calls[0][1] as RequestInit;
expect(JSON.parse(String(init.body))).toMatchObject({
obchodniJmeno: "BOHA Automation",
pocet: 10,
});
});
it("returns [] for a blank query without calling ARES", async () => {
const spy = vi.spyOn(globalThis, "fetch");
expect(await aresSearchByName(" ")).toEqual([]);
expect(spy).not.toHaveBeenCalled();
});
it("maps upstream failure to ares_unavailable", async () => {
mockFetchOnce(503);
expect(await aresSearchByName("BOHA")).toEqual({
error: "ares_unavailable",
});
});
});

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 prisma from "../config/database";
import { config } from "../config/env";
import { import {
previewIssuedOrderNumber, previewIssuedOrderNumber,
previewInvoiceNumber, previewInvoiceNumber,
@@ -19,23 +22,36 @@ import {
createOffer, createOffer,
updateOffer, updateOffer,
deleteOffer, deleteOffer,
invalidateOffer,
getOffer,
listOffers,
} from "../services/offers.service"; } 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. // Track every row we create so the suite leaves the (real) app_test DB clean.
const issuedOrderIds: number[] = []; const issuedOrderIds: number[] = [];
const invoiceIds: number[] = []; const invoiceIds: number[] = [];
const offerIds: number[] = []; const offerIds: number[] = [];
const customerIds: number[] = [];
const linkedOrderIds: number[] = [];
afterEach(async () => { afterEach(async () => {
for (const id of issuedOrderIds) for (const id of issuedOrderIds)
await prisma.issued_orders.deleteMany({ where: { id } }); await prisma.issued_orders.deleteMany({ where: { id } });
for (const id of invoiceIds) for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } }); 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) for (const id of offerIds)
await prisma.quotations.deleteMany({ where: { id } }); await prisma.quotations.deleteMany({ where: { id } });
for (const id of customerIds)
await prisma.customers.deleteMany({ where: { id } });
issuedOrderIds.length = 0; issuedOrderIds.length = 0;
invoiceIds.length = 0; invoiceIds.length = 0;
linkedOrderIds.length = 0;
offerIds.length = 0; offerIds.length = 0;
customerIds.length = 0;
// Reset every sequence this suite may have touched so preview values are // 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. // stable across tests and we don't leak consumed numbers into other files.
await prisma.number_sequences.deleteMany({ 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 */ /* 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 () => { it("a draft has no number and does NOT consume the sequence", async () => {
const before = (await previewIssuedOrderNumber()).number; const before = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({}); // defaults to draft const draft = await createIssuedOrder({}); // defaults to draft
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id); issuedOrderIds.push(draft.id);
expect(draft.status).toBe("draft"); expect(draft.status).toBe("draft");
expect(draft.po_number).toBeNull(); 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 () => { it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
const expected = (await previewIssuedOrderNumber()).number; const expected = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({}); const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id); issuedOrderIds.push(draft.id);
const res = await updateIssuedOrder(draft.id, { status: "sent" }); 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 () => { it("finalizing is idempotent — re-finalizing does not re-number", async () => {
const draft = await createIssuedOrder({}); const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id); issuedOrderIds.push(draft.id);
await updateIssuedOrder(draft.id, { status: "sent" }); 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 () => { it("two drafts coexist with null numbers (no unique violation)", async () => {
const a = await createIssuedOrder({}); const a = await createIssuedOrder({});
const b = 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); issuedOrderIds.push(a.id, b.id);
expect(a.po_number).toBeNull(); expect(a.po_number).toBeNull();
expect(b.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 () => { it("deleting a draft releases nothing (no number was consumed)", async () => {
const before = (await previewIssuedOrderNumber()).number; const before = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({}); const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
await deleteIssuedOrder(draft.id); await deleteIssuedOrder(draft.id);
const after = (await previewIssuedOrderNumber()).number; const after = (await previewIssuedOrderNumber()).number;
expect(after).toBe(before); expect(after).toBe(before);
@@ -273,3 +307,282 @@ describe("offer drafts — deferred numbering", () => {
expect(after).toBe(before); 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,18 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { invoiceTotalWithVat } from "../services/invoices.service"; import prisma from "../config/database";
import {
invoiceTotalWithVat,
createInvoice,
updateInvoice,
getInvoice,
listInvoices,
getInvoiceListTotals,
} from "../services/invoices.service";
import {
CreateInvoiceSchema,
UpdateInvoiceSchema,
} from "../schemas/invoices.schema";
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real // `invoiceTotalWithVat` receives a Prisma row whose money columns are real
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using // `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
@@ -118,3 +130,229 @@ describe("invoiceTotalWithVat", () => {
expect(invoiceTotalWithVat(inv)).toBe(968); 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({ internal_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();
});
});
/* -------------------------------------------------------------------------- */
/* "Obsah" sections + internal-only notes (mirrors issued orders) */
/* -------------------------------------------------------------------------- */
describe("invoice sections round-trip; dropped `notes` is stripped", () => {
const invoiceIds: number[] = [];
afterEach(async () => {
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
invoiceIds.length = 0;
});
it("creates, returns and replaces CZ/EN sections in position order", async () => {
const parsed = CreateInvoiceSchema.parse({
billing_text: "Sekce test",
sections: [
{ title: "Scope", title_cz: "Rozsah", content: "<p>obsah 1</p>" },
{ title: "", title_cz: "Podmínky", content: "<p>obsah 2</p>" },
],
});
const draft = await createInvoice(parsed);
invoiceIds.push(draft.id);
const detail = await getInvoice(draft.id);
expect(detail?.sections?.length).toBe(2);
expect(detail?.sections?.[0].title_cz).toBe("Rozsah");
expect(detail?.sections?.[1].title_cz).toBe("Podmínky");
expect(detail?.sections?.[0].position).toBe(0);
expect(detail?.sections?.[1].position).toBe(1);
// Full-replace on update (same model as items).
const upd = UpdateInvoiceSchema.parse({
sections: [
{ title: "Only", title_cz: "Jediná", content: "<p>nový obsah</p>" },
],
});
const res = await updateInvoice(draft.id, upd);
expect("error" in res).toBe(false);
const after = await getInvoice(draft.id);
expect(after?.sections?.length).toBe(1);
expect(after?.sections?.[0].title_cz).toBe("Jediná");
expect(after?.sections?.[0].content).toBe("<p>nový obsah</p>");
});
it("sections survive an items-only update untouched (absent = keep)", async () => {
const draft = await createInvoice(
CreateInvoiceSchema.parse({
sections: [{ title: "", title_cz: "Drží", content: "<p>x</p>" }],
}),
);
invoiceIds.push(draft.id);
await updateInvoice(
draft.id,
UpdateInvoiceSchema.parse({
items: [{ description: "Práce", quantity: 1, unit_price: 100 }],
}),
);
const after = await getInvoice(draft.id);
expect(after?.sections?.length).toBe(1);
expect(after?.sections?.[0].title_cz).toBe("Drží");
expect(after?.items?.length).toBe(1);
});
it("silently strips the dropped `notes` key from legacy payloads", async () => {
// The printed-notes column was dropped (notes are internal-only now) —
// a legacy client still sending `notes` must not 500 or write anything.
const parsedCreate = CreateInvoiceSchema.parse({
notes: "<p>stará poznámka</p>",
internal_notes: "<p>interní</p>",
});
expect("notes" in parsedCreate).toBe(false);
const draft = await createInvoice(parsedCreate);
invoiceIds.push(draft.id);
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.internal_notes).toBe("<p>interní</p>");
const parsedUpdate = UpdateInvoiceSchema.parse({ notes: "x" });
expect("notes" in parsedUpdate).toBe(false);
const res = await updateInvoice(draft.id, parsedUpdate);
expect("error" in res).toBe(false);
});
});
/* -------------------------------------------------------------------------- */
/* 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, status: "prijata" as const,
currency: "CZK", currency: "CZK",
language: "cs", language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1, exchange_rate: 1,
}; };
@@ -117,8 +115,6 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
status: "active", status: "active",
currency: "CZK", currency: "CZK",
language: "cs", language: "cs",
vat_rate: 21,
apply_vat: true,
}, },
}); });
createdQuotationIds.push(quotation.id); createdQuotationIds.push(quotation.id);
@@ -146,6 +142,84 @@ describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
expect(project?.project_number).not.toBe(order?.order_number); expect(project?.project_number).not.toBe(order?.order_number);
expect(project?.order_id).toBe(orderId); 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 reverts the source offer (stuck-ordered regression)", () => {
it("returns the linked quotation to active with order_id cleared", async () => {
const quotation = await prisma.quotations.create({
data: {
quotation_number: `Q-REVERT-${Date.now()}`,
status: "active",
currency: "CZK",
language: "cs",
},
});
createdQuotationIds.push(quotation.id);
const res = await createOrderFromQuotation({ quotationId: quotation.id });
if (!("data" in res) || !res.data) failResult(res);
const project = await prisma.projects.findFirst({
where: { order_id: res.data.order_id },
});
if (project) createdProjectIds.push(project.id);
const afterCreate = await prisma.quotations.findUnique({
where: { id: quotation.id },
});
expect(afterCreate!.status).toBe("ordered");
expect(afterCreate!.order_id).toBe(res.data.order_id);
const del = await deleteOrder(res.data.order_id);
if ("error" in del) failResult(del);
// The offer must be orderable again — `ordered` with no order_id is a
// dead end (the transition table only allows ordered -> invalidated).
const afterDelete = await prisma.quotations.findUnique({
where: { id: quotation.id },
});
expect(afterDelete!.order_id).toBeNull();
expect(afterDelete!.status).toBe("active");
});
}); });
describe("deleteOrder releases the project number (release regression)", () => { 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")), blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")),
).toBeNull(); ).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", () => { describe("getOfferTotals per-currency aggregation", () => {
it("sums offer TOTAL incl. VAT per currency over the full filtered set", async () => { it("sums offer NET total per currency over the full filtered set", async () => {
// Two CZK offers + one EUR. 21% VAT applied on the whole subtotal // Two CZK offers + one EUR. NET only (enrichQuotation math — offers are
// (enrichQuotation math). // not tax documents, no VAT anywhere).
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420 // CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025 // CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363 // EUR : 3 x 100 = 300 => EUR total 300
const mk = async (currency: string, qty: number, price: number) => { const mk = async (currency: string, qty: number, price: number) => {
const res = await createOffer({ const res = await createOffer({
status: "draft", // draft so no offer number is consumed status: "draft", // draft so no offer number is consumed
currency, currency,
vat_rate: 21,
apply_vat: true,
project_code: OFFER_MARKER, project_code: OFFER_MARKER,
items: [{ description: "X", quantity: qty, unit_price: price }], items: [{ description: "X", quantity: qty, unit_price: price }],
}); });
@@ -79,8 +77,8 @@ describe("getOfferTotals per-currency aggregation", () => {
await mk("EUR", 3, 100); await mk("EUR", 3, 100);
const { totals } = await getOfferTotals({ search: OFFER_MARKER }); const { totals } = await getOfferTotals({ search: OFFER_MARKER });
expect(amountFor(totals, "CZK")).toBe(3025); expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(363); expect(amountFor(totals, "EUR")).toBe(300);
}); });
it("respects the where: a status filter narrows the result", async () => { it("respects the where: a status filter narrows the result", async () => {
@@ -88,8 +86,6 @@ describe("getOfferTotals per-currency aggregation", () => {
const res = await createOffer({ const res = await createOffer({
status, status,
currency: "CZK", currency: "CZK",
vat_rate: 21,
apply_vat: true,
project_code: OFFER_MARKER, project_code: OFFER_MARKER,
items: [{ description: "X", quantity: 1, unit_price: 1000 }], items: [{ description: "X", quantity: 1, unit_price: 1000 }],
}); });
@@ -103,23 +99,23 @@ describe("getOfferTotals per-currency aggregation", () => {
await mk("draft"); await mk("draft");
await mk("invalidated"); 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 }); 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({ const onlyDraft = await getOfferTotals({
search: OFFER_MARKER, search: OFFER_MARKER,
status: "draft", 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({ const onlyInvalid = await getOfferTotals({
search: OFFER_MARKER, search: OFFER_MARKER,
status: "invalidated", 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 // 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 // `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 // endpoints sum order NET total per currency across the WHOLE filtered set
// set, and that the where (month/year + status) is respected. // (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 // 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). // window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
@@ -46,19 +47,17 @@ function amountFor(
} }
describe("getOrderTotals (received orders) per-currency aggregation", () => { describe("getOrderTotals (received orders) per-currency aggregation", () => {
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => { it("sums NET total per currency over the full filtered set", async () => {
// Two CZK orders + one EUR, all in the same far-future month. 21% VAT, // Two CZK orders + one EUR, all in the same far-future month. NET only
// applied on the whole subtotal (enrichOrder math). // (enrichOrder math — orders carry no VAT).
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420 // CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025 // CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363 // EUR : 3 x 100 = 300 => EUR total 300
const mk = async (currency: string, qty: number, price: number) => { const mk = async (currency: string, qty: number, price: number) => {
const res = await createOrder({ const res = await createOrder({
status: "prijata", status: "prijata",
currency, currency,
language: "cs", language: "cs",
vat_rate: 21,
apply_vat: true,
create_project: false, create_project: false,
items: [{ description: "X", quantity: qty, unit_price: price }], items: [{ description: "X", quantity: qty, unit_price: price }],
}); });
@@ -83,8 +82,8 @@ describe("getOrderTotals (received orders) per-currency aggregation", () => {
month: STATS_MONTH, month: STATS_MONTH,
year: STATS_YEAR, year: STATS_YEAR,
}); });
expect(amountFor(totals, "CZK")).toBe(3025); expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(363); expect(amountFor(totals, "EUR")).toBe(300);
}); });
it("respects the where: a different month and a status filter change the result", async () => { 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, status,
currency: "CZK", currency: "CZK",
language: "cs", language: "cs",
vat_rate: 21,
apply_vat: true,
create_project: false, create_project: false,
items: [{ description: "X", quantity: 1, unit_price: 1000 }], 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("stornovana", 0);
await mk("prijata", 1); 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({ const whole = await getOrderTotals({
month: STATS_MONTH, month: STATS_MONTH,
year: STATS_YEAR, 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({ const onlyPrijata = await getOrderTotals({
month: STATS_MONTH, month: STATS_MONTH,
year: STATS_YEAR, year: STATS_YEAR,
status: "prijata", 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({ const nextMonth = await getOrderTotals({
month: STATS_MONTH + 1, month: STATS_MONTH + 1,
year: STATS_YEAR, year: STATS_YEAR,
}); });
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210); expect(amountFor(nextMonth.totals, "CZK")).toBe(1000);
}); });
}); });
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => { describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
it("sums TOTAL incl. VAT per currency over the full filtered set", async () => { it("sums NET total per currency over the full filtered set", async () => {
// order_date is settable at create, so no post-create pin needed. VAT is // order_date is settable at create, so no post-create pin needed. NET only
// applied per-line (computeIssuedOrderTotals) but with a single line per // (computeIssuedOrderTotals — issued orders carry no VAT).
// order here the result matches the on-the-whole subtotal math. // CZK #1: 2 x 1000 = 2000
// CZK #1: 2 x 1000 @21% = 2420 // CZK #2: 1 x 500 = 500 => CZK total 2500
// CZK #2: 1 x 500 @21% = 605 => CZK total 3025 // EUR : 3 x 100 = 300 => EUR total 300
// EUR : 3 x 100 @21% = 363 => EUR total 363
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`; const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const mk = async (currency: string, qty: number, price: number) => { const mk = async (currency: string, qty: number, price: number) => {
const o = await createIssuedOrder({ const o = await createIssuedOrder({
currency, currency,
vat_rate: 21,
apply_vat: true,
order_date: dateStr, order_date: dateStr,
items: [ items: [{ description: "X", quantity: qty, unit_price: price }],
{ description: "X", quantity: qty, unit_price: price, vat_rate: 21 },
],
}); });
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
createdIssuedIds.push(o.id); createdIssuedIds.push(o.id);
return o.id; return o.id;
}; };
@@ -177,69 +170,63 @@ describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () =>
month: STATS_MONTH, month: STATS_MONTH,
year: STATS_YEAR, year: STATS_YEAR,
}); });
expect(amountFor(totals, "CZK")).toBe(3025); expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(363); expect(amountFor(totals, "EUR")).toBe(300);
}); });
it("respects the where: a different month and a status filter change the result", async () => { it("respects the where: a different month and a status filter change the result", async () => {
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`; const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-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({ const draft = await createIssuedOrder({
currency: "CZK", currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: inMonth, order_date: inMonth,
items: [ items: [{ description: "X", quantity: 1, unit_price: 1000 }],
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
}); });
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
createdIssuedIds.push(draft.id); createdIssuedIds.push(draft.id);
const sent = await createIssuedOrder({ const sent = await createIssuedOrder({
status: "sent", status: "sent",
currency: "CZK", currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: inMonth, order_date: inMonth,
items: [ items: [{ description: "X", quantity: 1, unit_price: 1000 }],
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
}); });
if ("error" in sent)
throw new Error(`createIssuedOrder failed: ${sent.error}`);
createdIssuedIds.push(sent.id); createdIssuedIds.push(sent.id);
const next = await createIssuedOrder({ const next = await createIssuedOrder({
currency: "CZK", currency: "CZK",
vat_rate: 21,
apply_vat: true,
order_date: nextMonth, order_date: nextMonth,
items: [ items: [{ description: "X", quantity: 1, unit_price: 1000 }],
{ description: "X", quantity: 1, unit_price: 1000, vat_rate: 21 },
],
}); });
if ("error" in next)
throw new Error(`createIssuedOrder failed: ${next.error}`);
createdIssuedIds.push(next.id); 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({ const whole = await getIssuedOrderTotals({
month: STATS_MONTH, month: STATS_MONTH,
year: STATS_YEAR, 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({ const onlySent = await getIssuedOrderTotals({
month: STATS_MONTH, month: STATS_MONTH,
year: STATS_YEAR, year: STATS_YEAR,
status: "sent", 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({ const nextStats = await getIssuedOrderTotals({
month: STATS_MONTH + 1, month: STATS_MONTH + 1,
year: STATS_YEAR, 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 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 createdOrderIds: number[] = [];
const createdCustomerIds: number[] = []; const createdCustomerIds: number[] = [];
@@ -19,8 +30,6 @@ const baseOrder = {
status: "prijata" as const, status: "prijata" as const,
currency: "CZK", currency: "CZK",
language: "cs", language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1, 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", () => { describe("listOrders month filter", () => {
it("returns only orders whose created_at is in the given month", async () => { it("returns only orders whose created_at is in the given month", async () => {
const customer = await makeCustomer(); 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", () => { 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", () => { it("rejects NaN string in item quantity", () => {
const result = CreateQuotationSchema.safeParse({ const result = CreateQuotationSchema.safeParse({
customer_id: 1, customer_id: 1,
@@ -268,18 +240,25 @@ describe("schema hardening — does not reject previously-valid input", () => {
).toBe(false); ).toBe(false);
}); });
it("warehouse supplier: empty email accepted, valid accepted, bad rejected", () => { it("warehouse supplier: dropped legacy contact/address keys are silently stripped", () => {
expect( // email/phone/contact_person/notes/address columns were replaced by the
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "" }).success, // customers-model custom_fields blob (2026-06); legacy clients still
).toBe(true); // sending them must not 400 — z.object strips unknown keys.
expect( const parsed = CreateSupplierSchema.safeParse({
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "x@y.cz" }) name: "Dodavatel",
.success, email: "x@y.cz",
).toBe(true); phone: "123",
expect( contact_person: "Jan",
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "nope" }) notes: "n",
.success, address: "Ulice 1",
).toBe(false); custom_fields: [{ name: "Kontakt", value: "Jan", showLabel: true }],
});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect("email" in parsed.data).toBe(false);
expect("address" in parsed.data).toBe(false);
expect(parsed.data.custom_fields).toHaveLength(1);
}
}); });
it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => { it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {
@@ -300,8 +279,8 @@ describe("schema hardening — does not reject previously-valid input", () => {
expect(b.success).toBe(true); expect(b.success).toBe(true);
if (b.success) expect(b.data.trip_date).toBe("2026-06-09"); if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
// Genuinely malformed dates are still rejected. // Genuinely malformed dates are still rejected.
expect(CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success).toBe( expect(
false, 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 Fastify from "fastify";
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit"; import rateLimit from "@fastify/rate-limit";
@@ -6,6 +15,7 @@ import jwt from "jsonwebtoken";
import prisma from "../config/database"; import prisma from "../config/database";
import { config } from "../config/env"; import { config } from "../config/env";
import warehouseRoutes from "../routes/admin/warehouse"; import warehouseRoutes from "../routes/admin/warehouse";
import { nasInvoicesManager } from "../services/nas-financials-manager";
import { securityHeaders } from "../middleware/security"; 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 // 6. Issue flow
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -0,0 +1,153 @@
import { useState, useRef } from "react";
import InputAdornment from "@mui/material/InputAdornment";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import CircularProgress from "@mui/material/CircularProgress";
import { jsonQuery } from "../lib/apiAdapter";
import { useAlert } from "../context/AlertContext";
/** Normalized company record returned by the /api/admin/ares proxy. */
export interface AresCompany {
ico: string;
dic: string | null;
name: string;
street: string;
city: string;
postal_code: string;
country: string;
}
interface AresAdornmentProps {
/** "ico" looks the query up directly; "name" searches and offers a picker. */
mode: "ico" | "name";
/** Current value of the host field (IČO or name fragment). */
query: string;
/** Called with the chosen company — the modal prefills its form from it. */
onFill: (company: AresCompany) => void;
}
/**
* "ARES" end-adornment button for the Název/IČO fields of the customer and
* supplier modals. IČO mode fetches the subject directly; name mode searches
* ARES and shows a result picker (filling straight away on a single hit).
* Pass via the TextField's `InputProps.endAdornment`.
*/
export default function AresAdornment({
mode,
query,
onFill,
}: AresAdornmentProps) {
const alert = useAlert();
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<AresCompany[]>([]);
const [menuOpen, setMenuOpen] = useState(false);
const anchorRef = useRef<HTMLButtonElement | null>(null);
const trimmed = query.trim();
const enabled =
mode === "ico"
? /^\d{8}$/.test(trimmed.replace(/\s+/g, ""))
: trimmed.length >= 2;
const tooltip =
mode === "ico"
? enabled
? "Načíst údaje z ARES podle IČO"
: "Vyplňte 8místné IČO"
: enabled
? "Vyhledat firmu v ARES podle názvu"
: "Vyplňte alespoň 2 znaky názvu";
const lookup = async () => {
if (!enabled || loading) return;
setLoading(true);
try {
if (mode === "ico") {
const company = await jsonQuery<AresCompany>(
`/api/admin/ares/ico/${encodeURIComponent(trimmed.replace(/\s+/g, ""))}`,
);
onFill(company);
} else {
const found = await jsonQuery<AresCompany[]>(
`/api/admin/ares/search?q=${encodeURIComponent(trimmed)}`,
);
if (found.length === 0) {
alert.error("Žádná firma tohoto názvu nebyla v ARES nalezena");
} else if (found.length === 1) {
onFill(found[0]);
} else {
setResults(found);
setMenuOpen(true);
}
}
} catch (e) {
alert.error(
e instanceof Error ? e.message : "Registr ARES je nedostupný",
);
} finally {
setLoading(false);
}
};
return (
<InputAdornment position="end">
<Tooltip title={tooltip}>
<span>
<IconButton
ref={anchorRef}
size="small"
onClick={lookup}
disabled={!enabled || loading}
aria-label="Načíst z ARES"
edge="end"
>
{loading ? (
<CircularProgress size={16} color="inherit" />
) : (
<Typography
component="span"
sx={{
fontSize: "0.65rem",
fontWeight: 800,
letterSpacing: "0.06em",
color: enabled ? "primary.main" : "text.disabled",
lineHeight: 1,
}}
>
ARES
</Typography>
)}
</IconButton>
</span>
</Tooltip>
<Menu
anchorEl={anchorRef.current}
open={menuOpen}
onClose={() => setMenuOpen(false)}
>
{results.map((c) => (
<MenuItem
key={c.ico}
onClick={() => {
setMenuOpen(false);
onFill(c);
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{c.name}
</Typography>
<Typography variant="caption" color="text.secondary">
IČO {c.ico}
{c.city ? ` · ${c.city}` : ""}
</Typography>
</Box>
</MenuItem>
))}
</Menu>
</InputAdornment>
);
}

View File

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

View File

@@ -1,6 +1,7 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { motion, useReducedMotion } from "framer-motion"; 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 Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -134,6 +135,28 @@ export default function DashProfile({
// locks <html> scroll like the kit dialogs. // locks <html> scroll like the kit dialogs.
useDialogScrollLock(show2FASetup); 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 [showModal, setShowModal] = useState(false);
const [formData, setFormData] = useState<ProfileFormData>({ const [formData, setFormData] = useState<ProfileFormData>({
username: "", username: "",
@@ -549,11 +572,11 @@ export default function DashProfile({
Naskenujte QR kód v autentizační aplikaci (Google Authenticator, Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
Authy, Microsoft Authenticator apod.) Authy, Microsoft Authenticator apod.)
</Typography> </Typography>
{totpQrUri && ( {totpQrUri && totpQrDataUrl && (
<Box sx={{ textAlign: "center", mb: 2 }}> <Box sx={{ textAlign: "center", mb: 2 }}>
<Box <Box
component="img" component="img"
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`} src={totpQrDataUrl}
alt="TOTP QR Code" alt="TOTP QR Code"
sx={{ sx={{
width: 200, width: 200,
@@ -561,10 +584,23 @@ export default function DashProfile({
borderRadius: 2, borderRadius: 2,
border: 1, border: 1,
borderColor: "divider", 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> </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 && ( {totpSecret && (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Typography <Typography

View File

@@ -0,0 +1,667 @@
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,
MouseSensor,
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",
touchAction: "none",
}}
>
{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",
touchAction: "none",
}}
>
{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"));
// MouseSensor (NOT PointerSensor): PointerSensor also receives touch-derived
// pointer events and its 5px distance constraint fires before the
// TouchSensor's long-press delay — the browser then claims the gesture for
// scrolling (pointercancel) and the drag dies. Mouse handles desktop,
// TouchSensor handles touch via long-press; the drag handles additionally
// carry touch-action: none so the browser never starts a scroll from them.
const dndSensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, {
activationConstraint: { delay: 300, tolerance: 8 },
}),
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, type ReactNode,
} from "react"; } from "react";
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api"; import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
import { queryClient } from "../lib/queryClient";
const API_BASE = "/api/admin"; const API_BASE = "/api/admin";
@@ -232,6 +233,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
remember, 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); setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
@@ -260,19 +265,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
) => { ) => {
setError(null); setError(null);
try { try {
const response = await fetch(`${API_BASE}/login/totp`, { // Backup codes have their own endpoint + schema (8-char codes would
method: "POST", // fail /login/totp's strict 6-digit TotpVerifySchema). Both endpoints
headers: { "Content-Type": "application/json" }, // complete the same login flow: tokens issued + refresh cookie set.
credentials: "include", const response = await fetch(
body: JSON.stringify({ isBackup
login_token: loginToken, ? `${API_BASE}/totp/backup-verify`
totp_code: code, : `${API_BASE}/login/totp`,
remember_me: remember, {
isBackup, 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(); const data = await response.json();
if (data.success) { 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); setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user)); setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user); cachedUserRef.current = mapUser(data.data.user);
@@ -312,6 +335,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
clearTimeout(refreshTimeoutRef.current); clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null; 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]); }, [getAccessTokenFn]);

View File

@@ -6,6 +6,7 @@ import {
calcProjectMinutesTotal, calcProjectMinutesTotal,
calcFormWorkMinutes, calcFormWorkMinutes,
calculateWorkMinutes, calculateWorkMinutes,
combineDatetime,
getDatePart, getDatePart,
getTimePart, getTimePart,
formatDate, formatDate,
@@ -133,9 +134,6 @@ interface DeleteConfirmState {
const API_BASE = "/api/admin"; 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. * Compute per-user totals from raw attendance records.
* This replaces the server-side `user_totals` that the PHP backend returned. * This replaces the server-side `user_totals` that the PHP backend returned.
@@ -869,7 +867,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
try { try {
const [yearStr, monthStr] = month.split("-"); 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}`; if (filterUserId) recordsUrl += `&user_id=${filterUserId}`;
const [recordsResponse, balancesResponse] = await Promise.all([ const [recordsResponse, balancesResponse] = await Promise.all([
@@ -1047,6 +1048,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) { if (result.success) {
setShowCreateModal(false); setShowCreateModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] }); 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 fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
alert.success( alert.success(
@@ -1119,6 +1124,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) { if (result.success) {
setShowBulkModal(false); setShowBulkModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] }); 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 fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
alert.success( alert.success(
@@ -1254,6 +1263,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) { if (result.success) {
setShowEditModal(false); setShowEditModal(false);
queryClient.invalidateQueries({ queryKey: ["attendance"] }); 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 fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
alert.success( alert.success(
@@ -1284,6 +1297,10 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
if (result.success) { if (result.success) {
setDeleteConfirm({ show: false, record: null }); setDeleteConfirm({ show: false, record: null });
queryClient.invalidateQueries({ queryKey: ["attendance"] }); 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 fetchData(false);
alert.success( alert.success(
result.message || result.data?.message || "Záznam smazán", 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"], queryKey: ["dashboard"],
queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"), queryFn: () => jsonQuery<Record<string, unknown>>("/api/admin/dashboard"),
staleTime: 60_000, 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 // require2FAOptions lives in ./settings.ts (the single definition consumers

View File

@@ -43,6 +43,14 @@ export interface InvoiceItem {
position?: number; position?: number;
} }
export interface InvoiceSection {
id?: number;
title: string | null;
title_cz: string | null;
content: string | null;
position?: number | null;
}
export interface InvoiceDetail { export interface InvoiceDetail {
id: number; id: number;
invoice_number: string; invoice_number: string;
@@ -62,7 +70,7 @@ export interface InvoiceDetail {
vat_rate: number; vat_rate: number;
apply_vat: boolean; apply_vat: boolean;
exchange_rate: string; exchange_rate: string;
notes?: string; internal_notes?: string | null;
payment_method?: string; payment_method?: string;
variable_symbol?: string; variable_symbol?: string;
constant_symbol?: string; constant_symbol?: string;
@@ -75,6 +83,7 @@ export interface InvoiceDetail {
bank_account?: string; bank_account?: string;
bank_account_id?: number | null; bank_account_id?: number | null;
items?: InvoiceItem[]; items?: InvoiceItem[];
sections?: InvoiceSection[];
subtotal: number; subtotal: number;
vat_amount: number; vat_amount: number;
total: number; total: number;

View File

@@ -1,19 +1,36 @@
import { queryOptions } from "@tanstack/react-query"; import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter"; 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 { export interface IssuedOrder {
id: number; id: number;
po_number: string | null; po_number: string | null;
customer_id: number | null; supplier_id: number | null;
customer_name: string | null; supplier_name: string | null;
status: string; status: string;
currency: string | null; currency: string | null;
order_date: string | null; order_date: string | null;
subtotal: number; // NET total — issued orders carry no VAT (not tax documents).
vat_amount: number;
total: number; 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;
street: string | null;
city: string | null;
postal_code: string | null;
country: string | null;
}
export interface IssuedOrderItem { export interface IssuedOrderItem {
id?: number; id?: number;
description: string | null; description: string | null;
@@ -21,26 +38,45 @@ export interface IssuedOrderItem {
quantity: number | string | null; quantity: number | string | null;
unit: string | null; unit: string | null;
unit_price: number | 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; position?: number;
} }
export interface IssuedOrderDetail extends IssuedOrder { export interface IssuedOrderDetail extends IssuedOrder {
apply_vat: boolean | null;
vat_rate: number | string | null;
exchange_rate: number | string | null; exchange_rate: number | string | null;
delivery_date: string | null; delivery_date: string | null;
language: string | null; language: string | null;
delivery_terms: string | null; order_text: string | null;
payment_terms: string | null;
issued_by: string | null;
notes: string | null;
internal_notes: string | null; internal_notes: string | null;
items: IssuedOrderItem[]; items: IssuedOrderItem[];
customer: Record<string, unknown> | null; sections: IssuedOrderSection[];
supplier: Record<string, unknown> | null;
valid_transitions: string[]; 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: { export const issuedOrderListOptions = (filters: {
search?: string; search?: string;
sort?: string; sort?: string;
@@ -48,6 +84,7 @@ export const issuedOrderListOptions = (filters: {
page?: number; page?: number;
perPage?: number; perPage?: number;
status?: string; status?: string;
supplier_id?: number;
month?: number; month?: number;
year?: number; year?: number;
}) => }) =>
@@ -61,6 +98,8 @@ export const issuedOrderListOptions = (filters: {
if (filters.page) params.set("page", String(filters.page)); if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage)); if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status); 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.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year)); if (filters.year) params.set("year", String(filters.year));
const qs = params.toString(); const qs = params.toString();
@@ -70,14 +109,10 @@ export const issuedOrderListOptions = (filters: {
}, },
}); });
export interface CurrencyAmount {
amount: number;
currency: string;
}
export const issuedOrderStatsOptions = (filters: { export const issuedOrderStatsOptions = (filters: {
search?: string; search?: string;
status?: string; status?: string;
supplier_id?: number;
month?: number; month?: number;
year?: number; year?: number;
}) => }) =>
@@ -87,6 +122,8 @@ export const issuedOrderStatsOptions = (filters: {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search); if (filters.search) params.set("search", filters.search);
if (filters.status) params.set("status", filters.status); 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.month) params.set("month", String(filters.month));
if (filters.year) params.set("year", String(filters.year)); if (filters.year) params.set("year", String(filters.year));
const qs = params.toString(); const qs = params.toString();
@@ -103,4 +140,16 @@ export const issuedOrderDetailOptions = (id: string | undefined) =>
queryFn: () => queryFn: () =>
jsonQuery<IssuedOrderDetail>(`/api/admin/issued-orders/${id}`), jsonQuery<IssuedOrderDetail>(`/api/admin/issued-orders/${id}`),
enabled: !!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; 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"; export type HttpMethod = "POST" | "PUT" | "PATCH" | "DELETE";
interface ApiResponseBody<T> { interface ApiResponseBody<T> {
@@ -22,6 +48,7 @@ async function performMutation<TIn, TOut>(opts: {
url: string | ((input: TIn) => string); url: string | ((input: TIn) => string);
method: HttpMethod | ((input: TIn) => HttpMethod); method: HttpMethod | ((input: TIn) => HttpMethod);
body?: TIn; body?: TIn;
envelope?: boolean;
}): Promise<TOut> { }): Promise<TOut> {
const url = const url =
typeof opts.url === "function" ? opts.url(opts.body as TIn) : opts.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; throw err;
} }
if (opts.envelope) {
return { data: result.data, message: result.message } as TOut;
}
return result.data as TOut; return result.data as TOut;
} }
@@ -67,6 +97,12 @@ export interface ApiMutationOptions<TIn, TOut> {
method: HttpMethod | ((input: TIn) => HttpMethod); method: HttpMethod | ((input: TIn) => HttpMethod);
/** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */ /** Query-key prefixes to invalidate on success. Broad per CLAUDE.md. */
invalidate?: readonly string[]; 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> & opts: ApiMutationOptions<TIn, TOut> &
Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">, Omit<UseMutationOptions<TOut, ApiError, TIn>, "mutationFn">,
) { ) {
const { url, method, invalidate, onSuccess, ...rest } = opts; const { url, method, invalidate, envelope, onSuccess, ...rest } = opts;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<TOut, ApiError, TIn, unknown>({ return useMutation<TOut, ApiError, TIn, unknown>({
mutationFn: (input: TIn) => mutationFn: (input: TIn) =>
performMutation<TIn, TOut>({ url, method, body: input }), performMutation<TIn, TOut>({ url, method, body: input, envelope }),
...rest, ...rest,
onSuccess: (data, variables, onMutateResult, context) => { onSuccess: (data, variables, onMutateResult, context) => {
if (invalidate) { if (invalidate) {

View File

@@ -52,7 +52,9 @@ export interface Customer {
export const offerCustomersOptions = () => export const offerCustomersOptions = () =>
queryOptions({ 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"), queryFn: () => jsonQuery<Customer[]>("/api/admin/customers"),
staleTime: 2 * 60_000, staleTime: 2 * 60_000,
}); });
@@ -70,6 +72,21 @@ export const scopeTemplatesOptions = () =>
queryFn: () => jsonQuery<ScopeTemplate[]>("/api/admin/offers-templates"), 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: { export const offerListOptions = (filters: {
search?: string; search?: string;
sort?: string; sort?: string;
@@ -92,7 +109,9 @@ export const offerListOptions = (filters: {
if (filters.customer_id) if (filters.customer_id)
params.set("customer_id", String(filters.customer_id)); params.set("customer_id", String(filters.customer_id));
const qs = params.toString(); 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; valid_until: string;
currency: string; currency: string;
language: string; language: string;
vat_rate: number;
apply_vat: boolean;
items?: OfferItemData[]; items?: OfferItemData[];
sections?: OfferSectionData[]; sections?: OfferSectionData[];
status: string; status: string;
order: OfferOrderInfo | null; order: OfferOrderInfo | null;
locked_by: OfferLockInfo | 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) => export const offerDetailOptions = (id: string | undefined) =>

View File

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

View File

@@ -40,6 +40,7 @@ export const projectListOptions = (filters: {
order?: string; order?: string;
page?: number; page?: number;
perPage?: number; perPage?: number;
status?: string;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["projects", "list", filters], queryKey: ["projects", "list", filters],
@@ -50,6 +51,7 @@ export const projectListOptions = (filters: {
if (filters.order) params.set("order", filters.order); if (filters.order) params.set("order", filters.order);
if (filters.page) params.set("page", String(filters.page)); if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage)); if (filters.perPage) params.set("per_page", String(filters.perPage));
if (filters.status) params.set("status", filters.status);
const qs = params.toString(); const qs = params.toString();
return paginatedJsonQuery<Project>( return paginatedJsonQuery<Project>(
`/api/admin/projects${qs ? `?${qs}` : ""}`, `/api/admin/projects${qs ? `?${qs}` : ""}`,

View File

@@ -86,3 +86,13 @@ export const require2FAOptions = () =>
queryFn: () => queryFn: () =>
jsonQuery<{ require_2fa: boolean }>("/api/admin/totp/required"), 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 { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter"; import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";
export interface TripVehicle { export interface TripVehicle {
id: number; id: number;
@@ -59,7 +59,9 @@ export const tripListOptions = (filters: {
if (filters.page) params.set("page", String(filters.page)); if (filters.page) params.set("page", String(filters.page));
if (filters.perPage) params.set("per_page", String(filters.perPage)); if (filters.perPage) params.set("per_page", String(filters.perPage));
const qs = params.toString(); 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; month?: string;
vehicleId?: number; vehicleId?: number;
userId?: number; userId?: number;
page?: number;
perPage?: number;
}) => }) =>
queryOptions({ queryOptions({
queryKey: [ queryKey: [
@@ -90,6 +94,8 @@ export const tripHistoryOptions = (filters: {
month: filters.month, month: filters.month,
vehicleId: filters.vehicleId, vehicleId: filters.vehicleId,
userId: filters.userId, userId: filters.userId,
page: filters.page,
perPage: filters.perPage,
}, },
], ],
queryFn: () => { queryFn: () => {
@@ -98,7 +104,53 @@ export const tripHistoryOptions = (filters: {
if (filters.vehicleId) if (filters.vehicleId)
params.set("vehicle_id", String(filters.vehicleId)); params.set("vehicle_id", String(filters.vehicleId));
if (filters.userId) params.set("user_id", String(filters.userId)); 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(); 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

@@ -157,16 +157,23 @@ export interface WarehouseLocation {
is_active: boolean; is_active: boolean;
} }
export interface WarehouseSupplierCustomField {
name: string;
value: string;
showLabel: boolean;
_key?: string;
}
export interface WarehouseSupplier { export interface WarehouseSupplier {
id: number; id: number;
name: string; name: string;
ico: string | null; ico: string | null;
dic: string | null; dic: string | null;
contact_person: string | null; street: string | null;
email: string | null; city: string | null;
phone: string | null; postal_code: string | null;
address: string | null; country: string | null;
notes: string | null; custom_fields?: WarehouseSupplierCustomField[];
is_active: boolean; is_active: boolean;
} }

View File

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

View File

@@ -10,6 +10,7 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { todayLocalStr } from "../utils/formatters"; import { todayLocalStr } from "../utils/formatters";
import { combineDatetime } from "../utils/attendanceHelpers";
import { import {
Button, Button,
Card, Card,
@@ -41,6 +42,24 @@ interface CreateForm {
notes: string; 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() { export default function AttendanceCreate() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
@@ -52,10 +71,10 @@ export default function AttendanceCreate() {
})); }));
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const createMutation = useApiMutation<CreateForm, { message?: string }>({ const createMutation = useApiMutation<CreatePayload, { message?: string }>({
url: () => `${API_BASE}/attendance`, url: () => `${API_BASE}/attendance`,
method: () => "POST", method: () => "POST",
invalidate: ["attendance", "users"], invalidate: ["attendance", "users", "dashboard"],
}); });
const [form, setForm] = useState<CreateForm>(() => { const [form, setForm] = useState<CreateForm>(() => {
@@ -88,7 +107,36 @@ export default function AttendanceCreate() {
setSubmitting(true); setSubmitting(true);
try { 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"); alert.success(result?.message || "Uloženo");
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`); navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} catch (e) { } catch (e) {

View File

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

View File

@@ -24,11 +24,14 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import RichEditor from "../components/RichEditor"; import RichEditor from "../components/RichEditor";
import SectionsEditor, {
type DocumentSection,
} from "../components/document/SectionsEditor";
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
KeyboardSensor, KeyboardSensor,
PointerSensor, MouseSensor,
TouchSensor, TouchSensor,
useSensor, useSensor,
useSensors, useSensors,
@@ -51,7 +54,12 @@ import { invoiceDetailOptions } from "../lib/queries/invoices";
import { offerCustomersOptions } from "../lib/queries/offers"; import { offerCustomersOptions } from "../lib/queries/offers";
import { bankAccountsOptions } from "../lib/queries/common"; import { bankAccountsOptions } from "../lib/queries/common";
import { jsonQuery } from "../lib/apiAdapter"; 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 { normalizeDateStr } from "../utils/attendanceHelpers";
import { import {
Button, Button,
@@ -193,7 +201,7 @@ interface InvoiceForm {
constant_symbol: string; constant_symbol: string;
issued_by: string; issued_by: string;
billing_text: string; billing_text: string;
notes: string; internal_notes: string;
language: string; language: string;
bank_account_id: number | string; bank_account_id: number | string;
bank_name: string; bank_name: string;
@@ -270,7 +278,11 @@ function SortableInvoiceRow({
{...listeners} {...listeners}
title="Přetáhnout" title="Přetáhnout"
aria-label="Přetáhnout" aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary" }} sx={{
cursor: "grab",
color: "text.secondary",
touchAction: "none",
}}
> >
{DragIcon} {DragIcon}
</IconButton> </IconButton>
@@ -380,7 +392,7 @@ function SortableInvoiceRow({
{...listeners} {...listeners}
title="Přetáhnout" title="Přetáhnout"
aria-label="Přetáhnout" aria-label="Přetáhnout"
sx={{ cursor: "grab", color: "text.secondary" }} sx={{ cursor: "grab", color: "text.secondary", touchAction: "none" }}
> >
{DragIcon} {DragIcon}
</IconButton> </IconButton>
@@ -503,10 +515,16 @@ export default function InvoiceDetail() {
[], [],
); );
// MouseSensor (NOT PointerSensor): PointerSensor also receives touch-derived
// pointer events and its 5px distance constraint fires before the
// TouchSensor's long-press delay — the browser then claims the gesture for
// scrolling (pointercancel) and the drag dies. Mouse handles desktop,
// TouchSensor handles touch via long-press; the drag handles additionally
// carry touch-action: none so the browser never starts a scroll from them.
const dndSensors = useSensors( const dndSensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { useSensor(TouchSensor, {
activationConstraint: { delay: 200, tolerance: 5 }, activationConstraint: { delay: 300, tolerance: 8 },
}), }),
useSensor(KeyboardSensor), useSensor(KeyboardSensor),
); );
@@ -535,7 +553,7 @@ export default function InvoiceDetail() {
constant_symbol: "0308", constant_symbol: "0308",
issued_by: user?.fullName || "", issued_by: user?.fullName || "",
billing_text: "", billing_text: "",
notes: "", internal_notes: "",
language: "cs", language: "cs",
bank_account_id: "", bank_account_id: "",
bank_name: "", bank_name: "",
@@ -545,6 +563,9 @@ export default function InvoiceDetail() {
})); }));
const [dueDays, setDueDays] = useState(14); const [dueDays, setDueDays] = useState(14);
// Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF —
// shared editor with offers/issued orders).
const [sections, setSections] = useState<DocumentSection[]>([]);
const [items, setItems] = useState<InvoiceItem[]>(() => [ const [items, setItems] = useState<InvoiceItem[]>(() => [
{ {
_key: "inv-1", _key: "inv-1",
@@ -621,7 +642,6 @@ export default function InvoiceDetail() {
}); });
// ─── Edit mode state ─── // ─── Edit mode state ───
const [notes, setNotes] = useState("");
const [statusChanging, setStatusChanging] = useState<string | null>(null); const [statusChanging, setStatusChanging] = useState<string | null>(null);
const [statusConfirm, setStatusConfirm] = useState<{ const [statusConfirm, setStatusConfirm] = useState<{
show: boolean; show: boolean;
@@ -686,12 +706,12 @@ export default function InvoiceDetail() {
tax_date: normalizeDateStr(inv.tax_date), tax_date: normalizeDateStr(inv.tax_date),
currency: inv.currency || "CZK", currency: inv.currency || "CZK",
apply_vat: Number(inv.apply_vat) ? 1 : 0, 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", payment_method: inv.payment_method || "Příkazem",
constant_symbol: inv.constant_symbol || "0308", constant_symbol: inv.constant_symbol || "0308",
issued_by: inv.issued_by || "", issued_by: inv.issued_by || "",
billing_text: inv.billing_text || "", billing_text: inv.billing_text || "",
notes: inv.notes || "", internal_notes: inv.internal_notes || "",
language: inv.language || "cs", language: inv.language || "cs",
bank_account_id: matchedBankId, bank_account_id: matchedBankId,
bank_name: inv.bank_name || "", bank_name: inv.bank_name || "",
@@ -700,7 +720,6 @@ export default function InvoiceDetail() {
bank_account: inv.bank_account || "", bank_account: inv.bank_account || "",
}; };
setForm(formData); setForm(formData);
setNotes(inv.notes || "");
setInvoiceNumber(inv.invoice_number || ""); setInvoiceNumber(inv.invoice_number || "");
// Calculate dueDays from existing dates // Calculate dueDays from existing dates
@@ -725,17 +744,33 @@ export default function InvoiceDetail() {
quantity: Number(item.quantity) || 1, quantity: Number(item.quantity) || 1,
unit: item.unit || "", unit: item.unit || "",
unit_price: Number(item.unit_price) || 0, unit_price: Number(item.unit_price) || 0,
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) { if (mappedItems.length > 0) {
setItems(mappedItems); setItems(mappedItems);
} }
// Populate sections from existing invoice
const mappedSections =
Array.isArray(inv.sections) && inv.sections.length > 0
? inv.sections.map((s) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
}))
: [];
setSections(mappedSections);
// Capture initial snapshot for dirty-checking // Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({ initialSnapshotRef.current = JSON.stringify({
form: formData, form: formData,
items: mappedItems, items: mappedItems,
sections: mappedSections,
}); });
setDataReady(true); setDataReady(true);
@@ -778,11 +813,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) { if (fromOrderId && orderDataQuery.data) {
const order = orderDataQuery.data; const order = orderDataQuery.data;
const vatRate = const vatRate = numberOr(companySettings?.default_vat_rate, 21);
Number(order.vat_rate) || (companySettings?.default_vat_rate ?? 21);
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
customer_id: order.customer_id as number, customer_id: order.customer_id as number,
@@ -792,7 +828,7 @@ export default function InvoiceDetail() {
(order.currency as string) || (order.currency as string) ||
companySettings?.default_currency || companySettings?.default_currency ||
"CZK", "CZK",
apply_vat: Number(order.apply_vat) || 0, apply_vat: 1,
vat_rate: vatRate, vat_rate: vatRate,
})); }));
const orderItems = order.items as Record<string, unknown>[] | undefined; const orderItems = order.items as Record<string, unknown>[] | undefined;
@@ -831,13 +867,15 @@ export default function InvoiceDetail() {
// Edit mode: captured inside the sync effect from raw query data. // Edit mode: captured inside the sync effect from raw query data.
// Create mode: captured on the first render after sync effects populate the form. // Create mode: captured on the first render after sync effects populate the form.
if (dataReady && !initialSnapshotRef.current) { if (dataReady && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items }); initialSnapshotRef.current = JSON.stringify({ form, items, sections });
} }
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false; if (!initialSnapshotRef.current) return false;
return JSON.stringify({ form, items }) !== initialSnapshotRef.current; return (
}, [form, items]); JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
);
}, [form, items, sections]);
useEffect(() => { useEffect(() => {
if (!isDirty) return; if (!isDirty) return;
@@ -995,6 +1033,12 @@ export default function InvoiceDetail() {
unit_price: Number(item.unit_price) || 0, unit_price: Number(item.unit_price) || 0,
position: i, position: i,
})), })),
sections: sections.map((s, i) => ({
title: s.title,
title_cz: s.title_cz,
content: s.content,
position: i,
})),
}; };
// Only set status when a target is given (create-as-draft / create-as-live // Only set status when a target is given (create-as-draft / create-as-live
// / finalize). Editing a live invoice sends no status — the backend keeps // / finalize). Editing a live invoice sends no status — the backend keeps
@@ -1007,7 +1051,7 @@ export default function InvoiceDetail() {
const data = await saveMutation.mutateAsync(payload); const data = await saveMutation.mutateAsync(payload);
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"); alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
initialSnapshotRef.current = JSON.stringify({ form, items }); initialSnapshotRef.current = JSON.stringify({ form, items, sections });
if (!isEdit) { if (!isEdit) {
navigate(`/invoices/${data.invoice_id}`); navigate(`/invoices/${data.invoice_id}`);
} }
@@ -1138,7 +1182,13 @@ export default function InvoiceDetail() {
}} }}
> >
<Typography variant="h4"> <Typography variant="h4">
Faktura {documentNumberLabel(invoice.invoice_number)} Faktura
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{documentNumberLabel(invoice.invoice_number)}
</Box>
</Typography> </Typography>
<StatusChip <StatusChip
label={statusLabel(INVOICE_STATUS, invoice.status)} label={statusLabel(INVOICE_STATUS, invoice.status)}
@@ -1271,11 +1321,6 @@ export default function InvoiceDetail() {
<Field label="Variabilní symbol"> <Field label="Variabilní symbol">
<Typography variant="body2">{invoice.invoice_number}</Typography> <Typography variant="body2">{invoice.invoice_number}</Typography>
</Field> </Field>
<Field label="Vystavil">
<Typography variant="body2">
{invoice.issued_by || "—"}
</Typography>
</Field>
</Box> </Box>
{invoice.paid_date && ( {invoice.paid_date && (
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
@@ -1587,14 +1632,55 @@ export default function InvoiceDetail() {
</Box> </Box>
</Card> </Card>
{/* Notes (read-only) */} {/* Obsah sections (read-only) — shown only when any section has content */}
{(invoice.sections ?? []).some(
(s) =>
(s.title || "").trim() ||
(s.title_cz || "").trim() ||
(s.content || "").replace(/<[^>]*>/g, "").trim(),
) && (
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Obsah
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{(invoice.sections ?? []).map((s, idx) => (
<Box key={s.id ?? idx}>
{((invoice.language === "cs"
? s.title_cz || s.title
: s.title) ||
"") && (
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{invoice.language === "cs"
? s.title_cz || s.title
: s.title}
</Typography>
)}
{(s.content || "").replace(/<[^>]*>/g, "").trim() && (
<RichTextView
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(s.content || ""),
}}
/>
)}
</Box>
))}
</Box>
</Card>
)}
{/* Internal notes (read-only, never printed) */}
<Card sx={{ mb: 3 }}> <Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Veřejné poznámky na faktuře Interní poznámky
</Typography> </Typography>
{notes && notes.trim() && notes !== "<p><br></p>" ? ( {invoice.internal_notes &&
invoice.internal_notes.trim() &&
invoice.internal_notes !== "<p><br></p>" ? (
<RichTextView <RichTextView
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(notes) }} dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(invoice.internal_notes),
}}
/> />
) : ( ) : (
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
@@ -1649,43 +1735,44 @@ export default function InvoiceDetail() {
Zpět Zpět
</Button> </Button>
<Box> <Box>
{isEdit && invoice ? ( <Box
<Box sx={{
sx={{ display: "flex",
display: "flex", alignItems: "center",
alignItems: "center", gap: 1.5,
gap: 1.5, flexWrap: "wrap",
flexWrap: "wrap", }}
}} >
> <Typography variant="h4">
<Typography variant="h4"> {isEdit ? "Faktura" : "Nová faktura"}
Faktura {documentNumberLabel(invoice.invoice_number)} {/* Edit mode: a draft has no number yet → show "Koncept".
</Typography> Create mode: show the previewed next-number when available. */}
{(isEdit || invoiceNumber) && (
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{isEdit
? documentNumberLabel(invoice?.invoice_number)
: invoiceNumber}
</Box>
)}
</Typography>
{isEdit && invoice && (
<StatusChip <StatusChip
label={statusLabel(INVOICE_STATUS, invoice.status)} label={statusLabel(INVOICE_STATUS, invoice.status)}
color={statusColor(INVOICE_STATUS, invoice.status)} color={statusColor(INVOICE_STATUS, invoice.status)}
/> />
</Box> )}
) : ( </Box>
<> {!isEdit && fromOrderId && (
<Typography variant="h4"> <Typography
Nová faktura{" "} variant="body2"
{invoiceNumber && ( color="text.secondary"
<Box component="span" sx={{ color: "text.secondary" }}> sx={{ mt: 0.5 }}
({invoiceNumber}) >
</Box> Z objednávky
)} </Typography>
</Typography>
{fromOrderId && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.5 }}
>
Z objednávky
</Typography>
)}
</>
)} )}
</Box> </Box>
</Box> </Box>
@@ -1856,37 +1943,19 @@ export default function InvoiceDetail() {
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje Základní údaje
</Typography> </Typography>
<Box {/* Číslo faktury and Vystavil are not form fields anymore: the
sx={{ number lives in the page header (deferred numbering — "Koncept"
display: "grid", until finalize) and issued_by is auto-filled from the logged-in
gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, user and still submitted with the payload (the PDF prints it). */}
gap: 2, <Field label="Odběratel" error={errors.customer_id} required>
}} <CustomerPicker
> customers={customers}
<Field label="Číslo faktury"> value={form.customer_id}
<TextField onChange={selectCustomer}
value={invoiceNumber} error={errors.customer_id}
InputProps={{ readOnly: true }} placeholder="Hledat zákazníka (název, IČ)..."
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }} />
/> </Field>
</Field>
<Field label="Odběratel" error={errors.customer_id} required>
<CustomerPicker
customers={customers}
value={form.customer_id}
onChange={selectCustomer}
error={errors.customer_id}
placeholder="Hledat zákazníka (název, IČ)..."
/>
</Field>
<Field label="Vystavil">
<TextField
value={form.issued_by}
InputProps={{ readOnly: true }}
sx={{ "& .MuiInputBase-root": { bgcolor: "action.hover" } }}
/>
</Field>
</Box>
<Field label="Text fakturace (na PDF)"> <Field label="Text fakturace (na PDF)">
<TextField <TextField
@@ -2232,16 +2301,26 @@ export default function InvoiceDetail() {
</Box> </Box>
</Card> </Card>
{/* Notes */} {/* Rich-text PDF sections — printed after the items (like orders) */}
<SectionsEditor
sections={sections}
onChange={setSections}
readOnly={false}
title="Obsah"
language={form.language}
/>
{/* Internal notes */}
<Card sx={{ mb: 3 }}> <Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}> <Field label="Interní poznámky" hint="Nezobrazí se na PDF.">
Veřejné poznámky na faktuře <RichEditor
</Typography> value={form.internal_notes}
<RichEditor onChange={(val) =>
value={form.notes} setForm((prev) => ({ ...prev, internal_notes: val }))
onChange={(val) => setForm((prev) => ({ ...prev, notes: val }))} }
placeholder="Poznámky zobrazené na faktuře..." placeholder="Interní poznámky..."
/> />
</Field>
</Card> </Card>
</form> </form>

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 { useQuery } from "@tanstack/react-query";
import { useNavigate, Link as RouterLink } from "react-router-dom"; import { useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
@@ -10,18 +12,21 @@ import {
formatCurrency, formatCurrency,
formatDate, formatDate,
formatMultiCurrency, formatMultiCurrency,
czechPlural,
} from "../utils/formatters"; } from "../utils/formatters";
import useTableSort from "../hooks/useTableSort"; import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce"; import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api"; import { useDocumentListPdf } from "../hooks/useDocumentPdf";
import { import {
issuedOrderListOptions, issuedOrderListOptions,
issuedOrderStatsOptions, issuedOrderStatsOptions,
issuedOrderSuppliersOptions,
type IssuedOrder, type IssuedOrder,
} from "../lib/queries/issued-orders"; } from "../lib/queries/issued-orders";
import { import {
Button,
Card, Card,
DataTable, DataTable,
Pagination, Pagination,
@@ -44,9 +49,15 @@ import {
const API_BASE = "/api/admin"; 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, { const STATUS_OPTIONS = statusOptions(ISSUED_ORDER_STATUS, {
value: "", value: "",
label: "Všechny stavy", label: "Všechny",
}); });
const ViewIcon = ( const ViewIcon = (
@@ -62,6 +73,19 @@ const ViewIcon = (
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg> </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 = ( const PdfIcon = (
<svg <svg
width="18" width="18"
@@ -107,11 +131,18 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [supplierFilter, setSupplierFilter] = useState<number | "">("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Track first successful load so later refetches (filter/status/page change) // Track first successful load so later refetches (filter/status/page change)
// keep the table visible instead of flashing the full-page skeleton. // keep the table visible instead of flashing the full-page skeleton.
const hasLoadedOnce = useRef(false); 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<{ const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean; show: boolean;
order: IssuedOrder | null; order: IssuedOrder | null;
@@ -141,8 +172,8 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
sort, sort,
order, order,
page, page,
perPage: 20,
status: status || undefined, status: status || undefined,
supplier_id: supplierFilter || undefined,
month, month,
year, year,
}), }),
@@ -154,6 +185,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
issuedOrderStatsOptions({ issuedOrderStatsOptions({
search: debouncedSearch, search: debouncedSearch,
status: status || undefined, status: status || undefined,
supplier_id: supplierFilter || undefined,
month, month,
year, 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 // Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/status/page change) keep the table visible (the Card // refetches (filter/status/page change) keep the table visible (the Card
// dims via isFetching) so it doesn't flash. // 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", header: "Dodavatel",
width: "24%", 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", 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", key: "total",
header: "Celkem", header: "Celkem",
@@ -268,39 +276,52 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
header: "Akce", header: "Akce",
width: "14%", width: "14%",
align: "right", align: "right",
render: (o) => ( render: (o) => {
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}> // Offers' semantics: read-only rows (completed/cancelled) get the
<IconButton // view icon, editable rows get the edit icon.
size="small" const readOnly = o.status === "completed" || o.status === "cancelled";
onClick={() => navigate(`/orders/issued/${o.id}`)} return (
aria-label={hasPermission("orders.edit") ? "Upravit" : "Detail"} <Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
title={hasPermission("orders.edit") ? "Upravit" : "Detail"}
>
{ViewIcon}
</IconButton>
{hasPermission("orders.view") && (
<IconButton <IconButton
size="small" size="small"
onClick={() => handleExportPdf(o)} onClick={() => navigate(`/orders/issued/${o.id}`)}
aria-label="PDF" aria-label={readOnly ? "Zobrazit" : "Upravit"}
title="PDF" title={readOnly ? "Zobrazit" : "Upravit"}
> >
{PdfIcon} {readOnly ? ViewIcon : EditIcon}
</IconButton> </IconButton>
)} {/* Drafts have no number yet — /file 404s for them. */}
{hasPermission("orders.delete") && ( {hasPermission("orders.view") && o.po_number && (
<IconButton <IconButton
size="small" size="small"
color="error" onClick={() =>
onClick={() => setDeleteConfirm({ show: true, order: o })} openPdf(o.id, `${API_BASE}/issued-orders/${o.id}/file`)
aria-label="Smazat" }
title="Smazat" aria-label="Zobrazit objednávku"
> title="Zobrazit objednávku"
{DeleteIcon} disabled={pdfLoadingId === o.id}
</IconButton> >
)} {pdfLoadingId === o.id ? (
</Box> <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 {}; return {};
}; };
// Search/status filter is active → an empty list means "nothing matches" // Search/status/supplier filter is active → an empty list means "nothing
// (no create CTA); a genuinely empty list keeps the create-CTA empty state. // matches" (no create CTA); a genuinely empty list keeps the create-CTA
const isFiltered = !!debouncedSearch || !!status; // 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 ( return (
<> <>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
{countLine}
</Typography>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 320px" }}> <Box sx={{ flex: "1 1 320px" }}>
<TextField <TextField
@@ -354,6 +388,22 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
options={STATUS_OPTIONS} options={STATUS_OPTIONS}
/> />
</Box> </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> </FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}> <Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
@@ -367,14 +417,19 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
onSort={handleSort} onSort={handleSort}
empty={ empty={
isFiltered ? ( 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 <EmptyState
title="Zatím žádné vydané objednávky." title="Zatím žádné vydané objednávky."
description={ action={
hasPermission("orders.create") hasPermission("orders.create") ? (
? "Vytvořte první tlačítkem „Vytvořit objednávku." <Button component={RouterLink} to="/orders/issued/new">
: undefined Vytvořit objednávku
</Button>
) : undefined
} }
/> />
) )
@@ -392,7 +447,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
fontSize: "0.9rem", fontSize: "0.9rem",
}} }}
> >
<span>Celkem:</span> <span>Celkem bez DPH:</span>
<Box <Box
component="span" component="span"
sx={{ fontWeight: 700, color: "text.primary" }} sx={{ fontWeight: 700, color: "text.primary" }}
@@ -415,11 +470,10 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
title="Smazat objednávku" title="Smazat objednávku"
message={ message={
deleteConfirm.order 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" confirmText="Smazat"
cancelText="Zrušit"
confirmVariant="danger" confirmVariant="danger"
loading={deleteMutation.isPending} loading={deleteMutation.isPending}
/> />

View File

@@ -161,7 +161,7 @@ export default function LeaveApproval() {
>({ >({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`, url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT", method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users"], invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
onSuccess: () => { onSuccess: () => {
setApproveModal({ open: false, request: null }); setApproveModal({ open: false, request: null });
alert.success("Žádost byla schválena"); alert.success("Žádost byla schválena");
@@ -174,7 +174,7 @@ export default function LeaveApproval() {
>({ >({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`, url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT", method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users"], invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
onSuccess: () => { onSuccess: () => {
setRejectModal({ open: false, request: null }); setRejectModal({ open: false, request: null });
setRejectNote(""); 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 useDebounce from "../hooks/useDebounce";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { useDocumentListPdf } from "../hooks/useDocumentPdf";
import { import {
offerListOptions, offerListOptions,
offerStatsOptions, offerStatsOptions,
offerCustomersOptions, offerCustomersOptions,
type Quotation,
} from "../lib/queries/offers"; } from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { import {
@@ -69,20 +71,6 @@ const STATUS_FILTERS = statusOptions(OFFER_STATUS, {
label: "Všechny", label: "Všechny",
}).filter((o) => o.value !== "expired"); }).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 = ( const TemplatesIcon = (
<svg <svg
width="20" width="20"
@@ -336,25 +324,17 @@ export default function Offers() {
show: boolean; show: boolean;
quotation: Quotation | null; quotation: Quotation | null;
}>({ show: false, quotation: null }); }>({ show: false, quotation: null });
const [deleting, setDeleting] = useState(false);
const [invalidateConfirm, setInvalidateConfirm] = useState<{ const [invalidateConfirm, setInvalidateConfirm] = useState<{
show: boolean; show: boolean;
quotation: Quotation | null; quotation: Quotation | null;
}>({ show: false, quotation: null }); }>({ show: false, quotation: null });
const [invalidating, setInvalidating] = useState(false); 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 [duplicating, setDuplicating] = useState<number | null>(null);
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const [creatingOrder, setCreatingOrder] = 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<{ const [orderModal, setOrderModal] = useState<{
show: boolean; show: boolean;
quotation: Quotation | null; quotation: Quotation | null;
@@ -490,13 +470,10 @@ export default function Offers() {
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteConfirm.quotation) return; if (!deleteConfirm.quotation) return;
setDeleting(true);
try { try {
await deleteOfferMutation.mutateAsync(deleteConfirm.quotation.id); await deleteOfferMutation.mutateAsync(deleteConfirm.quotation.id);
} catch (e) { } catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení"); 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 // Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/customer/tab/page change) keep the table visible (the // refetches (filter/customer/tab/page change) keep the table visible (the
// Card dims via isFetching) so it doesn't flash. // Card dims via isFetching) so it doesn't flash.
@@ -573,6 +519,7 @@ export default function Offers() {
header: "Číslo", header: "Číslo",
width: "13%", width: "13%",
sortKey: "quotation_number", sortKey: "quotation_number",
mono: true,
render: (q) => ( render: (q) => (
<Box <Box
component={RouterLink} component={RouterLink}
@@ -597,7 +544,7 @@ export default function Offers() {
{ {
key: "customer_name", key: "customer_name",
header: "Zákazník", header: "Zákazník",
width: "13%", width: "16%",
render: (q) => q.customer_name || "—", 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", key: "total",
header: "Celkem", header: "Celkem",
width: "11%", width: "12%",
align: "right", align: "right",
mono: true, mono: true,
bold: true, bold: true,
@@ -693,7 +633,9 @@ export default function Offers() {
{OrderViewIcon} {OrderViewIcon}
</IconButton> </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") && ( hasPermission("orders.create") && (
<IconButton <IconButton
size="small" size="small"
@@ -714,15 +656,20 @@ export default function Offers() {
</IconButton> </IconButton>
) )
)} )}
{hasPermission("offers.view") && ( {/* Drafts have no number yet — /file 404s for them. */}
{hasPermission("offers.view") && q.quotation_number && (
<IconButton <IconButton
size="small" size="small"
onClick={() => handlePdf(q)} onClick={() => openPdf(q.id, `${API_BASE}/offers/${q.id}/file`)}
aria-label="Zobrazit nabídku" aria-label="Zobrazit nabídku"
title="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> </IconButton>
)} )}
{hasPermission("offers.delete") && ( {hasPermission("offers.delete") && (
@@ -906,7 +853,7 @@ export default function Offers() {
fontSize: "0.9rem", fontSize: "0.9rem",
}} }}
> >
<span>Celkem:</span> <span>Celkem bez DPH:</span>
<Box <Box
component="span" component="span"
sx={{ fontWeight: 700, color: "text.primary" }} sx={{ fontWeight: 700, color: "text.primary" }}
@@ -927,10 +874,10 @@ export default function Offers() {
onClose={() => setDeleteConfirm({ show: false, quotation: null })} onClose={() => setDeleteConfirm({ show: false, quotation: null })}
onConfirm={handleDelete} onConfirm={handleDelete}
title="Smazat nabídku" 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" confirmText="Smazat"
confirmVariant="danger" confirmVariant="danger"
loading={deleting} loading={deleteOfferMutation.isPending}
/> />
<ConfirmDialog <ConfirmDialog
@@ -938,7 +885,7 @@ export default function Offers() {
onClose={() => setInvalidateConfirm({ show: false, quotation: null })} onClose={() => setInvalidateConfirm({ show: false, quotation: null })}
onConfirm={handleInvalidate} onConfirm={handleInvalidate}
title="Zneplatnit nabídku" 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" confirmText="Zneplatnit"
confirmVariant="danger" confirmVariant="danger"
loading={invalidating} loading={invalidating}

View File

@@ -12,6 +12,7 @@ import {
type CustomField, type CustomField,
} from "../lib/queries/offers"; } from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import AresAdornment, { type AresCompany } from "../components/AresLookup";
import { import {
Button, Button,
Card, Card,
@@ -198,7 +199,8 @@ export default function OffersCustomers() {
? `${API_BASE}/customers/${editingCustomer.id}` ? `${API_BASE}/customers/${editingCustomer.id}`
: `${API_BASE}/customers`, : `${API_BASE}/customers`,
method: () => (editingCustomer ? "PUT" : "POST"), method: () => (editingCustomer ? "PUT" : "POST"),
invalidate: ["offer-customers", "offers"], // Broad domain key — covers the ["offers","customers"] picker query too.
invalidate: ["offers"],
onSuccess: (data) => { onSuccess: (data) => {
closeModal(); closeModal();
setTimeout( setTimeout(
@@ -214,7 +216,8 @@ export default function OffersCustomers() {
>({ >({
url: (id) => `${API_BASE}/customers/${id}`, url: (id) => `${API_BASE}/customers/${id}`,
method: () => "DELETE", method: () => "DELETE",
invalidate: ["offer-customers", "offers"], // Broad domain key — covers the ["offers","customers"] picker query too.
invalidate: ["offers"],
onSuccess: (data) => { onSuccess: (data) => {
setDeleteConfirm({ show: false, customer: null }); setDeleteConfirm({ show: false, customer: null });
alert.success(data?.message || "Zákazník byl smazán"); alert.success(data?.message || "Zákazník byl smazán");
@@ -265,6 +268,21 @@ export default function OffersCustomers() {
return key; return key;
}; };
// ARES prefill — overwrite the company fields with registry data (the
// button is an explicit user action, so overwriting is the expected UX).
const fillFromAres = (c: AresCompany) => {
setForm((prev) => ({
...prev,
name: c.name || prev.name,
street: c.street,
city: c.city,
postal_code: c.postal_code,
country: c.country,
company_id: c.ico,
vat_id: c.dic || "",
}));
};
const openCreateModal = () => { const openCreateModal = () => {
setEditingCustomer(null); setEditingCustomer(null);
setForm({ setForm({
@@ -533,6 +551,15 @@ export default function OffersCustomers() {
setForm((prev) => ({ ...prev, name: e.target.value })) setForm((prev) => ({ ...prev, name: e.target.value }))
} }
placeholder="Název firmy / jméno" placeholder="Název firmy / jméno"
InputProps={{
endAdornment: (
<AresAdornment
mode="name"
query={form.name}
onFill={fillFromAres}
/>
),
}}
/> />
</Field> </Field>
<Field label="Ulice"> <Field label="Ulice">
@@ -594,6 +621,15 @@ export default function OffersCustomers() {
company_id: e.target.value, company_id: e.target.value,
})) }))
} }
InputProps={{
endAdornment: (
<AresAdornment
mode="ico"
query={form.company_id}
onFill={fillFromAres}
/>
),
}}
/> />
</Field> </Field>
<Field label="DIČ"> <Field label="DIČ">

View File

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

View File

@@ -34,6 +34,8 @@ import {
CheckboxField, CheckboxField,
LoadingState, LoadingState,
PageEnter, PageEnter,
Tabs,
type TabDef,
type DataColumn, type DataColumn,
} from "../ui"; } from "../ui";
@@ -54,6 +56,12 @@ const STATUS_COLORS: Record<
zruseny: "default", zruseny: "default",
}; };
// Status filter tabs (mirrors the offers page): "" = all.
const STATUS_TABS: TabDef[] = [
{ value: "", label: "Všechny" },
...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })),
];
interface Project { interface Project {
id: number; id: number;
project_number: string; project_number: string;
@@ -120,6 +128,7 @@ export default function Projects() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300); const debouncedSearch = useDebounce(search, 300);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null); const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const [deleteFiles, setDeleteFiles] = useState(false); const [deleteFiles, setDeleteFiles] = useState(false);
@@ -172,7 +181,13 @@ export default function Projects() {
enabled: showCreate, 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`, url: () => `${API_BASE}/projects`,
method: () => "POST", method: () => "POST",
invalidate: ["projects", "orders", "offers", "invoices", "attendance"], invalidate: ["projects", "orders", "offers", "invoices", "attendance"],
@@ -204,7 +219,13 @@ export default function Projects() {
setCreating(true); setCreating(true);
try { 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) { } catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení"); alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally { } finally {
@@ -218,7 +239,13 @@ export default function Projects() {
isPending, isPending,
isFetching, isFetching,
} = usePaginatedQuery<Project>( } = usePaginatedQuery<Project>(
projectListOptions({ search: debouncedSearch, sort, order, page }), projectListOptions({
search: debouncedSearch,
sort,
order,
page,
status: statusFilter || undefined,
}),
); );
if (!hasPermission("projects.view")) return <Forbidden />; if (!hasPermission("projects.view")) return <Forbidden />;
@@ -360,6 +387,31 @@ export default function Projects() {
}, },
]; ];
// Per-status row tints (mirrors the offers list): LOW-ALPHA washes via the
// palette channel vars in the badge's color — never solid `.light` fills
// (invisible white text in dark mode). Cancelled rows are faded/muted like
// invalidated offers.
const rowSx = (p: Project) => {
if (p.status === "dokonceny") {
return {
backgroundColor: "rgba(var(--mui-palette-info-mainChannel) / 0.12)",
"&:hover": {
backgroundColor: "rgba(var(--mui-palette-info-mainChannel) / 0.18)",
},
};
}
if (p.status === "zruseny") {
return {
opacity: 0.6,
"& td": { color: "var(--mui-palette-text-secondary)" },
};
}
return {};
};
// Search/status filter active → empty means "nothing matches" (no CTA).
const isFiltered = !!debouncedSearch || !!statusFilter;
return ( return (
<PageEnter> <PageEnter>
<Box <Box
@@ -380,6 +432,17 @@ export default function Projects() {
)} )}
</Box> </Box>
<Box sx={{ display: "flex", justifyContent: "center", mb: 2.5 }}>
<Tabs
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
setPage(1);
}}
tabs={STATUS_TABS}
/>
</Box>
<FilterBar> <FilterBar>
<Box sx={{ flex: "1 1 320px" }}> <Box sx={{ flex: "1 1 320px" }}>
<TextField <TextField
@@ -399,19 +462,31 @@ export default function Projects() {
columns={columns} columns={columns}
rows={projects} rows={projects}
rowKey={(p) => p.id} rowKey={(p) => p.id}
rowSx={rowSx}
sortBy={sort} sortBy={sort}
sortDir={order} sortDir={order}
onSort={handleSort} onSort={handleSort}
empty={ empty={
<Box sx={{ textAlign: "center", py: 6 }}> isFiltered ? (
<Typography color="text.secondary" gutterBottom> <Box sx={{ textAlign: "center", py: 6 }}>
Zatím nejsou žádné projekty. <Typography color="text.secondary" gutterBottom>
</Typography> Žádné projekty neodpovídají filtru.
<Typography variant="body2" color="text.secondary"> </Typography>
Projekty vznikají z objednávek nebo je můžete vytvořit ručně <Typography variant="body2" color="text.secondary">
tlačítkem Přidat projekt". Zkuste změnit filtr nebo hledaný výraz.
</Typography> </Typography>
</Box> </Box>
) : (
<Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom>
Zatím nejsou žádné projekty.
</Typography>
<Typography variant="body2" color="text.secondary">
Projekty vznikají z objednávek nebo je můžete vytvořit ručně
tlačítkem Přidat projekt".
</Typography>
</Box>
)
} }
/> />
<Pagination <Pagination

View File

@@ -184,8 +184,6 @@ export default function OrdersReceived({
customer_id: "", customer_id: "",
customer_order_number: "", customer_order_number: "",
currency: "CZK", currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "", scope_title: "",
scope_description: "", scope_description: "",
notes: "", notes: "",
@@ -219,8 +217,6 @@ export default function OrdersReceived({
customer_id: "", customer_id: "",
customer_order_number: "", customer_order_number: "",
currency: "CZK", currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "", scope_title: "",
scope_description: "", scope_description: "",
notes: "", notes: "",
@@ -255,8 +251,6 @@ export default function OrdersReceived({
fd.append("customer_id", createForm.customer_id); fd.append("customer_id", createForm.customer_id);
fd.append("customer_order_number", createForm.customer_order_number); fd.append("customer_order_number", createForm.customer_order_number);
fd.append("currency", createForm.currency); 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_title", createForm.scope_title);
fd.append("scope_description", createForm.scope_description); fd.append("scope_description", createForm.scope_description);
fd.append("notes", createForm.notes); fd.append("notes", createForm.notes);
@@ -274,8 +268,6 @@ export default function OrdersReceived({
: null, : null,
customer_order_number: createForm.customer_order_number, customer_order_number: createForm.customer_order_number,
currency: createForm.currency, currency: createForm.currency,
vat_rate: createForm.vat_rate,
apply_vat: createForm.apply_vat,
scope_title: createForm.scope_title, scope_title: createForm.scope_title,
scope_description: createForm.scope_description, scope_description: createForm.scope_description,
notes: createForm.notes, notes: createForm.notes,
@@ -571,7 +563,7 @@ export default function OrdersReceived({
fontSize: "0.9rem", fontSize: "0.9rem",
}} }}
> >
<span>Celkem:</span> <span>Celkem bez DPH:</span>
<Box <Box
component="span" component="span"
sx={{ fontWeight: 700, color: "text.primary" }} sx={{ fontWeight: 700, color: "text.primary" }}
@@ -669,19 +661,6 @@ export default function OrdersReceived({
/> />
</Field> </Field>
</Box> </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>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
@@ -757,12 +736,6 @@ export default function OrdersReceived({
/> />
</Field> </Field>
<CheckboxField
label="Účtovat DPH"
checked={createForm.apply_vat}
onChange={(v) => setCreateForm({ ...createForm, apply_vat: v })}
/>
<CheckboxField <CheckboxField
label="Vytvořit propojený projekt" label="Vytvořit propojený projekt"
checked={createForm.create_project} checked={createForm.create_project}

View File

@@ -12,9 +12,11 @@ import { formatKm, todayLocalStr } from "../utils/formatters";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { import {
tripListOptions, tripListOptions,
tripStatsOptions,
tripVehiclesOptions, tripVehiclesOptions,
type BackendTrip, type BackendTrip,
} from "../lib/queries/trips"; } from "../lib/queries/trips";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { import {
Button, Button,
@@ -158,12 +160,20 @@ export default function Trips() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
const { data: tripsData, isPending: tripsLoading } = useQuery( // "Poslední jízdy" widget — only the 10 newest rows are shown, so fetch
tripListOptions({}), // exactly those; the stat cards come from the server-side stats endpoint
); // (computed over the whole filtered set, not one page).
const { data: vehiclesData } = useQuery(tripVehiclesOptions()); 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 vehicles = vehiclesData ?? [];
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
@@ -321,19 +331,14 @@ export default function Trips() {
return <LoadingState />; return <LoadingState />;
} }
const totals = trips.reduce( const totals = {
(acc, t) => { count: stats?.count ?? 0,
const dist = t.distance ?? t.end_km - t.start_km; total: stats?.total_km ?? 0,
acc.count++; business: stats?.business_km ?? 0,
acc.total += dist; private: stats?.private_km ?? 0,
if (t.is_business) acc.business += dist; };
else acc.private += dist;
return acc;
},
{ total: 0, business: 0, private: 0, count: 0 },
);
const recentTrips = trips.slice(0, 10); const recentTrips = trips;
const columns: DataColumn<BackendTrip>[] = [ 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 { useQuery } from "@tanstack/react-query";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
@@ -9,11 +9,15 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { import {
tripListOptions, tripListOptions,
tripStatsOptions,
tripVehiclesOptions, tripVehiclesOptions,
tripUsersOptions, tripUsersOptions,
type BackendTrip, type BackendTrip,
type TripStats,
} from "../lib/queries/trips"; } from "../lib/queries/trips";
import { companySettingsOptions } from "../lib/queries/settings"; import { companySettingsOptions } from "../lib/queries/settings";
import { jsonQuery } from "../lib/apiAdapter";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { formatDate } from "../utils/attendanceHelpers"; import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters"; import { formatKm } from "../utils/formatters";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
@@ -28,6 +32,7 @@ import {
Select, Select,
DateField, DateField,
MonthField, MonthField,
Pagination,
StatusChip, StatusChip,
FilterBar, FilterBar,
LoadingState, 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 = ( const PrintIcon = (
<svg <svg
width="18" width="18"
@@ -188,7 +377,7 @@ export default function TripsAdmin() {
}); });
const [filterVehicleId, setFilterVehicleId] = useState(""); const [filterVehicleId, setFilterVehicleId] = useState("");
const [filterUserId, setFilterUserId] = useState(""); const [filterUserId, setFilterUserId] = useState("");
const printRef = useRef<HTMLDivElement>(null); const [page, setPage] = useState(1);
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null); const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
@@ -217,16 +406,27 @@ export default function TripsAdmin() {
const { data: companySettings } = useQuery(companySettingsOptions()); const { data: companySettings } = useQuery(companySettingsOptions());
const companyName = companySettings?.company_name ?? ""; const companyName = companySettings?.company_name ?? "";
const { data: tripsData, isPending } = useQuery( // ONE shared filter object for the list, stats AND print requests so the
tripListOptions({ // three can never drift apart. An empty MonthField value yields
month: Number(filterPeriod.slice(5, 7)) || undefined, // month/year = undefined (omitted from the request), mirroring the
year: Number(filterPeriod.slice(0, 4)) || undefined, // `Number(...) || undefined` guard the queries always used.
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined, const filters = {
userId: filterUserId ? Number(filterUserId) : undefined, month: Number(filterPeriod.slice(5, 7)) || undefined,
perPage: 100, year: Number(filterPeriod.slice(0, 4)) || undefined,
}), vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
); userId: filterUserId ? Number(filterUserId) : undefined,
const trips = (tripsData ?? []).map(mapTrip); };
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 // useApiMutation JSON.stringifies the whole TIn as the request body, so
// TIn must match the backend schema (UpdateTripSchema) shape directly — // TIn must match the backend schema (UpdateTripSchema) shape directly —
@@ -318,10 +518,12 @@ export default function TripsAdmin() {
}; };
const getPeriodName = () => const getPeriodName = () =>
new Date( filterPeriod
Number(filterPeriod.slice(0, 4)), ? new Date(
Number(filterPeriod.slice(5, 7)) - 1, Number(filterPeriod.slice(0, 4)),
).toLocaleString("cs-CZ", { month: "long", year: "numeric" }); Number(filterPeriod.slice(5, 7)) - 1,
).toLocaleString("cs-CZ", { month: "long", year: "numeric" })
: "Všechna období";
const getSelectedVehicleName = () => { const getSelectedVehicleName = () => {
if (!filterVehicleId) return null; if (!filterVehicleId) return null;
const v = vehicles.find((v) => String(v.id) === filterVehicleId); const v = vehicles.find((v) => String(v.id) === filterVehicleId);
@@ -333,94 +535,61 @@ export default function TripsAdmin() {
return u?.name || null; return u?.name || null;
}; };
const handlePrint = () => { const handlePrint = async () => {
const periodName = getPeriodName(); // 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(() => { // Fetch the FULL filtered set for the printout — the list query is
if (printRef.current) { // paginated, so printing from it would truncate to the visible page.
const content = printRef.current.innerHTML; let printTrips: Trip[];
const printWindow = window.open("", "_blank"); let printTotals: TripStats;
if (!printWindow) return; try {
printWindow.document.write(` const params = new URLSearchParams();
<!DOCTYPE html> if (filters.month) params.set("month", String(filters.month));
<html lang="cs"> if (filters.year) params.set("year", String(filters.year));
<head> if (filters.vehicleId)
<meta charset="UTF-8"> params.set("vehicle_id", String(filters.vehicleId));
<meta name="viewport" content="width=device-width, initial-scale=1.0"> if (filters.userId) params.set("user_id", String(filters.userId));
<title>Kniha jízd - ${periodName}</title> const data = await jsonQuery<{ trips: BackendTrip[]; totals: TripStats }>(
<style> `${API_BASE}/trips/print?${params.toString()}`,
* { margin: 0; padding: 0; box-sizing: border-box; } );
body { printTrips = data.trips.map(mapTrip);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; printTotals = data.totals;
font-size: 10px; } catch (e) {
line-height: 1.4; printWindow.close();
color: #000; console.error("Trip print data fetch failed:", e);
background: #fff; alert.error(e instanceof Error ? e.message : "Chyba připojení");
padding: 10mm; return;
} }
.print-header {
display: flex; // The user closed the popup while the data was loading — a deliberate
justify-content: space-between; // cancel, not an error.
align-items: flex-start; if (printWindow.closed) return;
margin-bottom: 15px;
padding-bottom: 10px; // The document is built directly from the fetched data (no hidden-div /
border-bottom: 2px solid #333; // setTimeout round-trip), so it can be neither unmounted nor stale.
} printWindow.document.write(
.print-header-left { display: flex; align-items: center; gap: 12px; } buildPrintHtml({
.print-logo { height: 40px; width: auto; } trips: printTrips,
.print-header-text { text-align: left; } totals: printTotals,
.print-header-right { text-align: right; } periodName: getPeriodName(),
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; } companyName,
.print-header .company { font-size: 11px; color: #666; } vehicleName: getSelectedVehicleName(),
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; } userName: getSelectedUserName(),
.print-header .filters { font-size: 10px; color: #666; } logoUrl: `${window.location.origin}/api/admin/company-settings/logo?variant=light`,
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; } }),
.summary { );
display: flex; printWindow.document.close();
justify-content: space-around; printWindow.onload = () => {
margin-bottom: 15px; printWindow.print();
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);
}; };
const calculateDistance = (): number => { const calculateDistance = (): number => {
@@ -430,11 +599,9 @@ export default function TripsAdmin() {
}; };
const totals = { const totals = {
count: trips.length, count: stats?.count ?? 0,
total: trips.reduce((sum, t) => sum + t.distance, 0), total: stats?.total_km ?? 0,
business: trips business: stats?.business_km ?? 0,
.filter((t) => Number(t.is_business))
.reduce((sum, t) => sum + t.distance, 0),
}; };
const columns: DataColumn<Trip>[] = [ const columns: DataColumn<Trip>[] = [
@@ -541,7 +708,7 @@ export default function TripsAdmin() {
> >
<Typography variant="h4">Správa knihy jízd</Typography> <Typography variant="h4">Správa knihy jízd</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{trips.length > 0 && ( {(pagination?.total ?? 0) > 0 && (
<Button <Button
variant="outlined" variant="outlined"
color="inherit" color="inherit"
@@ -566,12 +733,21 @@ export default function TripsAdmin() {
{/* Filters */} {/* Filters */}
<FilterBar> <FilterBar>
<Box sx={{ flex: "0 0 180px" }}> <Box sx={{ flex: "0 0 180px" }}>
<MonthField value={filterPeriod} onChange={setFilterPeriod} /> <MonthField
value={filterPeriod}
onChange={(val) => {
setFilterPeriod(val);
setPage(1);
}}
/>
</Box> </Box>
<Box sx={{ flex: "0 0 220px" }}> <Box sx={{ flex: "0 0 220px" }}>
<Select <Select
value={filterVehicleId} value={filterVehicleId}
onChange={setFilterVehicleId} onChange={(value) => {
setFilterVehicleId(value);
setPage(1);
}}
options={[ options={[
{ value: "", label: "Všechna vozidla" }, { value: "", label: "Všechna vozidla" },
...vehicles.map((v) => ({ ...vehicles.map((v) => ({
@@ -584,7 +760,10 @@ export default function TripsAdmin() {
<Box sx={{ flex: "0 0 220px" }}> <Box sx={{ flex: "0 0 220px" }}>
<Select <Select
value={filterUserId} value={filterUserId}
onChange={setFilterUserId} onChange={(value) => {
setFilterUserId(value);
setPage(1);
}}
options={[ options={[
{ value: "", label: "Všichni řidiči" }, { value: "", label: "Všichni řidiči" },
...tripUsers.map((u) => ({ ...tripUsers.map((u) => ({
@@ -632,14 +811,21 @@ export default function TripsAdmin() {
{isPending ? ( {isPending ? (
<LoadingState /> <LoadingState />
) : ( ) : (
<DataTable<Trip> <>
columns={columns} <DataTable<Trip>
rows={trips} columns={columns}
rowKey={(trip) => trip.id} rows={trips}
empty={ rowKey={(trip) => trip.id}
<EmptyState title="Žádné záznamy jízd pro vybrané období." /> empty={
} <EmptyState title="Žádné záznamy jízd pro vybrané období." />
/> }
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</>
)} )}
</Card> </Card>
@@ -796,120 +982,6 @@ export default function TripsAdmin() {
confirmVariant="danger" confirmVariant="danger"
loading={deleteMutation.isPending} 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> </PageEnter>
); );
} }

View File

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

View File

@@ -59,6 +59,11 @@ interface ItemDetail extends WarehouseItem {
const API_BASE = "/api/admin/warehouse/items"; 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 { interface ItemForm {
item_number: string; item_number: string;
name: string; name: string;
@@ -449,15 +454,14 @@ export default function WarehouseItemDetail() {
</Select> </Select>
</Field> </Field>
<Field label="Jednotka" required error={errors.unit}> <Field label="Jednotka" required error={errors.unit}>
<TextField <Select
value={form.unit} value={form.unit}
error={!!errors.unit} error={!!errors.unit}
onChange={(e) => { onChange={(v) => {
updateForm("unit", e.target.value); updateForm("unit", v);
setErrors((prev) => ({ ...prev, unit: "" })); setErrors((prev) => ({ ...prev, unit: "" }));
}} }}
placeholder="ks, m, kg..." options={UNIT_OPTIONS.map((u) => ({ value: u, label: u }))}
fullWidth
/> />
</Field> </Field>
<Field <Field

View File

@@ -4,10 +4,12 @@ import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import Link from "@mui/material/Link";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { apiFetch } from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters"; import { formatCurrency, formatDate } from "../utils/formatters";
import { import {
warehouseReceiptDetailOptions, 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 confirming = confirmMutation.isPending;
const cancelling = cancelMutation.isPending; const cancelling = cancelMutation.isPending;
@@ -249,13 +278,14 @@ export default function WarehouseReceiptDetail() {
width: "45%", width: "45%",
bold: true, bold: true,
render: (att) => ( render: (att) => (
<a <Link
href={`/api/admin/warehouse/receipts/${r.id}/attachments/${att.id}`} component="button"
target="_blank" type="button"
rel="noopener noreferrer" onClick={() => handleDownloadAttachment(att)}
sx={{ textAlign: "left", font: "inherit" }}
> >
{att.file_name} {att.file_name}
</a> </Link>
), ),
}, },
{ {

View File

@@ -1,15 +1,17 @@
import { useState } from "react"; import { useState, useRef } from "react";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import AresAdornment, { type AresCompany } from "../components/AresLookup";
import useDebounce from "../hooks/useDebounce"; import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery"; import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { import {
warehouseSupplierListOptions, warehouseSupplierListOptions,
type WarehouseSupplier, type WarehouseSupplier,
type WarehouseSupplierCustomField,
} from "../lib/queries/warehouse"; } from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import { import {
@@ -21,6 +23,7 @@ import {
ConfirmDialog, ConfirmDialog,
Field, Field,
TextField, TextField,
CheckboxField,
StatusChip, StatusChip,
PageHeader, PageHeader,
PageEnter, PageEnter,
@@ -32,17 +35,29 @@ import {
const API_BASE = "/api/admin/warehouse/suppliers"; const API_BASE = "/api/admin/warehouse/suppliers";
// Mirror of the customers modal (minus the PDF field-order picker): the same
// company fields + "Vlastní pole"; contact person/e-mail/phone live as custom
// fields since the dedicated columns were dropped.
interface SupplierForm { interface SupplierForm {
name: string; name: string;
ico: string; ico: string;
dic: string; dic: string;
contact_person: string; street: string;
email: string; city: string;
phone: string; postal_code: string;
address: string; country: string;
notes: string;
} }
const EMPTY_SUPPLIER_FORM: SupplierForm = {
name: "",
ico: "",
dic: "",
street: "",
city: "",
postal_code: "",
country: "",
};
const PER_PAGE = 20; const PER_PAGE = 20;
const PlusIcon = ( const PlusIcon = (
@@ -88,6 +103,32 @@ const DeleteIcon = (
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /> <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg> </svg>
); );
const SmallPlusIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</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>
);
export default function WarehouseSuppliers() { export default function WarehouseSuppliers() {
const alert = useAlert(); const alert = useAlert();
@@ -113,16 +154,11 @@ export default function WarehouseSuppliers() {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [editingSupplier, setEditingSupplier] = const [editingSupplier, setEditingSupplier] =
useState<WarehouseSupplier | null>(null); useState<WarehouseSupplier | null>(null);
const [form, setForm] = useState<SupplierForm>({ const [form, setForm] = useState<SupplierForm>({ ...EMPTY_SUPPLIER_FORM });
name: "", const [customFields, setCustomFields] = useState<
ico: "", WarehouseSupplierCustomField[]
dic: "", >([]);
contact_person: "", const customFieldKeyCounter = useRef(0);
email: "",
phone: "",
address: "",
notes: "",
});
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const [deactivateConfirm, setDeactivateConfirm] = useState<{ const [deactivateConfirm, setDeactivateConfirm] = useState<{
@@ -131,13 +167,21 @@ export default function WarehouseSuppliers() {
}>({ show: false, supplier: null }); }>({ show: false, supplier: null });
const submitMutation = useApiMutation< const submitMutation = useApiMutation<
SupplierForm, SupplierForm & {
custom_fields: Array<{
name: string;
value: string;
showLabel?: boolean;
}>;
},
{ id?: number; message?: string } { id?: number; message?: string }
>({ >({
url: () => url: () =>
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE, editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
method: () => (editingSupplier ? "PUT" : "POST"), 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) => { onSuccess: (data) => {
setShowModal(false); setShowModal(false);
alert.success(data?.message || "Dodavatel byl uložen"); alert.success(data?.message || "Dodavatel byl uložen");
@@ -147,7 +191,9 @@ export default function WarehouseSuppliers() {
const deleteMutation = useApiMutation<number, { message?: string }>({ const deleteMutation = useApiMutation<number, { message?: string }>({
url: (id) => `${API_BASE}/${id}`, url: (id) => `${API_BASE}/${id}`,
method: () => "DELETE", 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) => { onSuccess: (data) => {
setDeactivateConfirm({ show: false, supplier: null }); setDeactivateConfirm({ show: false, supplier: null });
alert.success(data?.message || "Dodavatel byl smazán"); alert.success(data?.message || "Dodavatel byl smazán");
@@ -163,23 +209,33 @@ export default function WarehouseSuppliers() {
>({ >({
url: ({ id }) => `${API_BASE}/${id}`, url: ({ id }) => `${API_BASE}/${id}`,
method: () => "PUT", 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 />; if (!hasPermission("warehouse.manage")) return <Forbidden />;
// ARES prefill — overwrite the company fields with registry data (the
// button is an explicit user action, so overwriting is the expected UX).
const fillFromAres = (c: AresCompany) => {
setForm((prev) => ({
...prev,
name: c.name || prev.name,
ico: c.ico,
dic: c.dic || "",
street: c.street,
city: c.city,
postal_code: c.postal_code,
country: c.country,
}));
setErrors((prev) => ({ ...prev, name: "" }));
};
const openCreateModal = () => { const openCreateModal = () => {
setEditingSupplier(null); setEditingSupplier(null);
setForm({ setForm({ ...EMPTY_SUPPLIER_FORM });
name: "", setCustomFields([]);
ico: "",
dic: "",
contact_person: "",
email: "",
phone: "",
address: "",
notes: "",
});
setErrors({}); setErrors({});
setShowModal(true); setShowModal(true);
}; };
@@ -190,12 +246,19 @@ export default function WarehouseSuppliers() {
name: supplier.name, name: supplier.name,
ico: supplier.ico || "", ico: supplier.ico || "",
dic: supplier.dic || "", dic: supplier.dic || "",
contact_person: supplier.contact_person || "", street: supplier.street || "",
email: supplier.email || "", city: supplier.city || "",
phone: supplier.phone || "", postal_code: supplier.postal_code || "",
address: supplier.address || "", country: supplier.country || "",
notes: supplier.notes || "",
}); });
setCustomFields(
Array.isArray(supplier.custom_fields) && supplier.custom_fields.length > 0
? supplier.custom_fields.map((f) => ({
...f,
_key: `cf-${++customFieldKeyCounter.current}`,
}))
: [],
);
setErrors({}); setErrors({});
setShowModal(true); setShowModal(true);
}; };
@@ -207,7 +270,16 @@ export default function WarehouseSuppliers() {
if (Object.keys(newErrors).length > 0) return; if (Object.keys(newErrors).length > 0) return;
try { try {
await submitMutation.mutateAsync(form); await submitMutation.mutateAsync({
...form,
custom_fields: customFields
.filter((f) => f.name.trim() || f.value.trim())
.map((f) => ({
name: f.name,
value: f.value,
showLabel: f.showLabel,
})),
});
} catch (e) { } catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení"); alert.error(e instanceof Error ? e.message : "Chyba připojení");
} }
@@ -275,23 +347,16 @@ export default function WarehouseSuppliers() {
render: (s) => s.dic || "—", render: (s) => s.dic || "—",
}, },
{ {
key: "contact_person", key: "street",
header: "Kontaktní osoba", header: "Ulice",
width: "16%", width: "18%",
render: (s) => s.contact_person || "—", render: (s) => s.street || "—",
}, },
{ {
key: "email", key: "city",
header: "E-mail", header: "Město",
width: "16%", width: "14%",
render: (s) => s.email || "—", render: (s) => s.city || "—",
},
{
key: "phone",
header: "Telefon",
width: "12%",
mono: true,
render: (s) => s.phone || "—",
}, },
{ {
key: "status", key: "status",
@@ -388,13 +453,15 @@ export default function WarehouseSuppliers() {
/> />
</Card> </Card>
{/* Add/Edit Modal */} {/* Add/Edit Modal — mirror of the customers modal (minus the PDF
field-order picker) */}
<Modal <Modal
isOpen={showModal} isOpen={showModal}
onClose={() => setShowModal(false)} onClose={() => setShowModal(false)}
onSubmit={handleSubmit} onSubmit={handleSubmit}
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"} title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
loading={submitMutation.isPending} loading={submitMutation.isPending}
maxWidth="md"
> >
<Field label="Název" required error={errors.name}> <Field label="Název" required error={errors.name}>
<TextField <TextField
@@ -404,7 +471,53 @@ export default function WarehouseSuppliers() {
setForm({ ...form, name: e.target.value }); setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" })); setErrors((prev) => ({ ...prev, name: "" }));
}} }}
placeholder="Název dodavatele" placeholder="Název firmy / jméno"
InputProps={{
endAdornment: (
<AresAdornment
mode="name"
query={form.name}
onFill={fillFromAres}
/>
),
}}
/>
</Field>
<Field label="Ulice">
<TextField
value={form.street}
onChange={(e) => setForm({ ...form, street: e.target.value })}
/>
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Město">
<TextField
value={form.city}
onChange={(e) => setForm({ ...form, city: e.target.value })}
/>
</Field>
<Field label="PSČ">
<TextField
value={form.postal_code}
onChange={(e) =>
setForm({ ...form, postal_code: e.target.value })
}
/>
</Field>
</Box>
<Field label="Země">
<TextField
value={form.country}
onChange={(e) => setForm({ ...form, country: e.target.value })}
/> />
</Field> </Field>
@@ -419,72 +532,129 @@ export default function WarehouseSuppliers() {
<TextField <TextField
value={form.ico} value={form.ico}
onChange={(e) => setForm({ ...form, ico: e.target.value })} onChange={(e) => setForm({ ...form, ico: e.target.value })}
placeholder="12345678" InputProps={{
endAdornment: (
<AresAdornment
mode="ico"
query={form.ico}
onFill={fillFromAres}
/>
),
}}
/> />
</Field> </Field>
<Field label="DIČ"> <Field label="DIČ">
<TextField <TextField
value={form.dic} value={form.dic}
onChange={(e) => setForm({ ...form, dic: e.target.value })} onChange={(e) => setForm({ ...form, dic: e.target.value })}
placeholder="CZ12345678"
/> />
</Field> </Field>
</Box> </Box>
<Box {/* Dynamic custom fields — same editor as the customers modal */}
sx={{ <Box sx={{ mt: 0.5 }}>
display: "grid", <Typography
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" }, variant="body2"
gap: 2, sx={{
}} display: "block",
> mb: 0.5,
<Field label="Kontaktní osoba"> fontWeight: 600,
<TextField color: "text.secondary",
value={form.contact_person} }}
onChange={(e) => >
setForm({ ...form, contact_person: e.target.value }) Vlastní pole
} </Typography>
placeholder="Jan Novák" {customFields.map((field, idx) => (
/> <Box key={field._key} sx={{ mb: 1 }}>
</Field> <Box
<Field label="E-mail"> sx={{
<TextField display: "grid",
type="email" gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
value={form.email} gap: 2,
onChange={(e) => setForm({ ...form, email: e.target.value })} alignItems: "flex-start",
placeholder="info@firma.cz" }}
/> >
</Field> <TextField
label={idx === 0 ? "Název" : undefined}
value={field.name}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = { ...updated[idx], name: e.target.value };
setCustomFields(updated);
}}
placeholder="Např. Kontakt"
/>
<Box
sx={{
display: "flex",
gap: 0.5,
alignItems: idx === 0 ? "flex-end" : "center",
}}
>
<TextField
label={idx === 0 ? "Hodnota" : undefined}
value={field.value}
onChange={(e) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
value: e.target.value,
};
setCustomFields(updated);
}}
sx={{ flex: 1 }}
/>
<IconButton
size="small"
color="error"
onClick={() =>
setCustomFields(customFields.filter((_, i) => i !== idx))
}
title="Odebrat pole"
aria-label="Odebrat pole"
>
{RemoveIcon}
</IconButton>
</Box>
</Box>
<Box sx={{ mt: 0.5 }}>
<CheckboxField
label={
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
Zobrazit název v PDF
</Typography>
}
checked={field.showLabel !== false}
onChange={(checked) => {
const updated = [...customFields];
updated[idx] = { ...updated[idx], showLabel: checked };
setCustomFields(updated);
}}
/>
</Box>
</Box>
))}
<Button
variant="outlined"
color="inherit"
size="small"
startIcon={SmallPlusIcon}
onClick={() =>
setCustomFields([
...customFields,
{
name: "",
value: "",
showLabel: true,
_key: `cf-${++customFieldKeyCounter.current}`,
},
])
}
sx={{ mt: 0.5 }}
>
Přidat pole
</Button>
</Box> </Box>
<Field label="Telefon">
<TextField
type="tel"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
placeholder="+420 123 456 789"
/>
</Field>
<Field label="Adresa">
<TextField
multiline
minRows={3}
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
placeholder="Ulice, město, PSČ"
/>
</Field>
<Field label="Poznámky">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Volitelné poznámky"
/>
</Field>
</Modal> </Modal>
{/* Deactivate/Delete Confirmation */} {/* Deactivate/Delete Confirmation */}

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 { default as Select } from "./Select";
export type { SelectOption } from "./Select"; export type { SelectOption } from "./Select";
export { default as CustomerPicker } from "./CustomerPicker"; export { default as CustomerPicker } from "./CustomerPicker";
export { default as SupplierPicker } from "./SupplierPicker";
export { default as StatusChip } from "./StatusChip"; export { default as StatusChip } from "./StatusChip";
export { CheckboxField } from "./Checkbox"; export { CheckboxField } from "./Checkbox";
export { default as Alert } from "./Alert"; export { default as Alert } from "./Alert";

View File

@@ -110,6 +110,16 @@ export const getTimePart = (datetime: string | null | undefined): string => {
return extractTime(datetime); 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 = ( export const calcProjectMinutesTotal = (
logs: Array<{ logs: Array<{
project_id?: number; project_id?: number;

View File

@@ -50,6 +50,18 @@ export function todayLocalStr(): string {
return `${d.getFullYear()}-${m}-${day}`; 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 { export function formatKm(km: number | string): string {
return new Intl.NumberFormat("cs-CZ").format(Number(km) || 0); return new Intl.NumberFormat("cs-CZ").format(Number(km) || 0);
} }

63
src/routes/admin/ares.ts Normal file
View File

@@ -0,0 +1,63 @@
import { FastifyInstance } from "fastify";
import { requireAnyPermission } from "../../middleware/auth";
import { success, error } from "../../utils/response";
import { aresLookupByIco, aresSearchByName } from "../../services/ares.service";
// Czech messages for the service's token errors.
const ARES_ERRORS: Record<string, { message: string; status: number }> = {
invalid_ico: { message: "IČO musí být 8 číslic", status: 400 },
not_found: {
message: "Subjekt s tímto IČO nebyl v ARES nalezen",
status: 404,
},
ares_unavailable: {
message: "Registr ARES je momentálně nedostupný, zkuste to později",
status: 502,
},
};
/**
* ARES proxy — the browser cannot call ares.gov.cz directly (CORS + CSP), so
* the customer/supplier modals go through these endpoints. Guarded by the
* permissions of the two modals that use the prefill (customer editing and
* warehouse supplier management); ARES data itself is public.
*/
export default async function aresRoutes(fastify: FastifyInstance) {
const guard = requireAnyPermission(
"customers.create",
"customers.edit",
"warehouse.manage",
);
// GET /ico/:ico — single subject by IČO
fastify.get<{ Params: { ico: string } }>(
"/ico/:ico",
{ preHandler: guard },
async (request, reply) => {
const result = await aresLookupByIco(request.params.ico);
if ("error" in result) {
const e = ARES_ERRORS[result.error];
return error(reply, e.message, e.status);
}
return success(reply, result);
},
);
// GET /search?q=… — subjects by (part of) business name, max 10
fastify.get<{ Querystring: { q?: string } }>(
"/search",
{ preHandler: guard },
async (request, reply) => {
const q = String(request.query.q ?? "").trim();
if (q.length < 2) {
return error(reply, "Zadejte alespoň 2 znaky názvu", 400);
}
const result = await aresSearchByName(q);
if ("error" in result && !Array.isArray(result)) {
const e = ARES_ERRORS[result.error];
return error(reply, e.message, e.status);
}
return success(reply, result);
},
);
}

View File

@@ -219,7 +219,14 @@ export default async function attendanceRoutes(
} }
// --- Default: paginated records list --- // --- 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 isAdmin = authData.permissions.includes("attendance.manage");
const parsedUserId = query.user_id ? Number(query.user_id) : undefined; const parsedUserId = query.user_id ? Number(query.user_id) : undefined;
const userId = const userId =
@@ -418,6 +425,8 @@ export default async function attendanceRoutes(
departure_lng: body.departure_lng, departure_lng: body.departure_lng,
departure_accuracy: body.departure_accuracy, departure_accuracy: body.departure_accuracy,
departure_address: body.departure_address, departure_address: body.departure_address,
break_start: body.break_start,
break_end: body.break_end,
notes: body.notes, notes: body.notes,
project_id: body.project_id, project_id: body.project_id,
leave_type: body.leave_type, leave_type: body.leave_type,

View File

@@ -9,48 +9,20 @@ import {
CreateCustomerSchema, CreateCustomerSchema,
UpdateCustomerSchema, UpdateCustomerSchema,
} from "../../schemas/customers.schema"; } from "../../schemas/customers.schema";
import {
encodeCustomFields,
decodeCustomFields as decodeCustomFieldsShared,
} from "../../utils/custom-fields";
const ALLOWED_SORT_FIELDS = ["id", "name", "company_id", "city", "country"]; const ALLOWED_SORT_FIELDS = ["id", "name", "company_id", "city", "country"];
/** Encode custom_fields + customer_field_order into a single JSON blob (matching PHP format) */ /** Customer-shaped wrapper over the shared codec (field_order key naming). */
function encodeCustomFields(
fields: unknown,
fieldOrder: unknown,
): string | null {
const f = Array.isArray(fields) ? fields : [];
const o = Array.isArray(fieldOrder) ? fieldOrder : [];
if (f.length === 0 && o.length === 0) return null;
return JSON.stringify({ fields: f, field_order: o });
}
/** Decode custom_fields JSON blob into separate fields + field_order for frontend */
function decodeCustomFields(raw: string | null): { function decodeCustomFields(raw: string | null): {
custom_fields: unknown[]; custom_fields: unknown[];
customer_field_order: string[]; customer_field_order: string[];
} { } {
if (!raw) return { custom_fields: [], customer_field_order: [] }; const { custom_fields, field_order } = decodeCustomFieldsShared(raw);
try { return { custom_fields, customer_field_order: field_order };
const parsed = JSON.parse(raw);
// PHP format: { fields: [...], field_order: [...] }
if (
parsed &&
typeof parsed === "object" &&
!Array.isArray(parsed) &&
"fields" in parsed
) {
return {
custom_fields: parsed.fields || [],
customer_field_order: parsed.field_order || [],
};
}
// Legacy TS format: raw array
if (Array.isArray(parsed)) {
return { custom_fields: parsed, customer_field_order: [] };
}
return { custom_fields: [], customer_field_order: [] };
} catch {
return { custom_fields: [], customer_field_order: [] };
}
} }
export default async function customersRoutes( export default async function customersRoutes(

View File

@@ -11,15 +11,16 @@ export default async function dashboardRoutes(
): Promise<void> { ): Promise<void> {
fastify.get("/", { preHandler: requireAuth }, async (request, reply) => { fastify.get("/", { preHandler: requireAuth }, async (request, reply) => {
const now = new Date(); 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( const todayStart = new Date(
now.getFullYear(), Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()),
now.getMonth(),
now.getDate(),
); );
const todayEnd = new Date( const todayEnd = new Date(
now.getFullYear(), Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() + 1),
now.getMonth(),
now.getDate() + 1,
); );
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1); const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);

View File

@@ -2,12 +2,20 @@ import { FastifyInstance } from "fastify";
import QRCode from "qrcode"; import QRCode from "qrcode";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { nasInvoicesManager } from "../../services/nas-financials-manager"; import { nasInvoicesManager } from "../../services/nas-financials-manager";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { getRate } from "../../services/exchange-rates"; import { getRate } from "../../services/exchange-rates";
import { localDateStr } from "../../utils/date"; import { localDateStr } from "../../utils/date";
import { parseId, success } from "../../utils/response"; import { parseId, success } from "../../utils/response";
import {
formatDate,
formatNum,
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
logoDataUriFromSettings,
buildPdfHeaderTemplate,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify"; import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
@@ -16,62 +24,6 @@ const DOMPurify = createDOMPurify(window);
/* ── Helpers ─────────────────────────────────────────────────────── */ /* ── 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 { interface AddressResult {
name: string; name: string;
lines: string[]; lines: string[];
@@ -282,6 +234,47 @@ const translations: Record<string, Record<string, string>> = {
}, },
}; };
/** 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 invoice PDF, rendered by Puppeteer in the
* bottom page margin (mirrors buildIssuedOrderPdfFooter): "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 buildInvoicePdfFooter(
lang: string,
issuerLine: string,
): string {
const t = translations[lang] || translations.cs;
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 12mm; 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(issuerLine)}</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>`;
}
/**
* Repeating per-page header for the invoice PDF — the unified red-accent
* family header (shared with issued orders): logo left, red
* "FAKTURA - DAŇOVÝ DOKLAD č. X" right, red rule underneath, on EVERY page.
* The body's own header is print-hidden so page 1 doesn't show it twice.
*/
export function buildInvoicePdfHeader(
lang: string,
logoDataUri: string,
invoiceNumber: string,
): string {
const t = translations[lang] || translations.cs;
return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${invoiceNumber}`);
}
/* ── Route ───────────────────────────────────────────────────────── */ /* ── Route ───────────────────────────────────────────────────────── */
export default async function invoicesPdfRoutes( export default async function invoicesPdfRoutes(
@@ -314,6 +307,11 @@ export default async function invoicesPdfRoutes(
where: { invoice_id: id }, where: { invoice_id: id },
orderBy: { position: "asc" }, orderBy: { position: "asc" },
}); });
// id tiebreak: position can repeat, keep the order deterministic.
const sections = await prisma.invoice_sections.findMany({
where: { invoice_id: id },
orderBy: [{ position: "asc" }, { id: "asc" }],
});
let customer: Record<string, unknown> | null = null; let customer: Record<string, unknown> | null = null;
if (invoice.customer_id) { if (invoice.customer_id) {
@@ -350,16 +348,12 @@ export default async function invoicesPdfRoutes(
} }
} }
let logoImg = ""; // Logo as a data URI — used by the body header (screen/HTML view) AND
if (settings?.logo_data) { // the Puppeteer headerTemplate (templates can only load data: URLs).
const buf = Buffer.from(settings.logo_data as Buffer); const logoDataUri = logoDataUriFromSettings(settings);
let mime = "image/png"; const logoImg = logoDataUri
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; ? `<img src="${logoDataUri}" class="logo" />`
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 currency = invoice.currency || "CZK"; const currency = invoice.currency || "CZK";
const applyVat = !!invoice.apply_vat; const applyVat = !!invoice.apply_vat;
@@ -522,26 +516,44 @@ export default async function invoicesPdfRoutes(
} }
} }
const notesRaw = invoice.notes ?? ""; // ── Sections ("Obsah") — flow INLINE right after the items/totals
const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim(); // block (mirrors issued orders): no separate page, long content
const notesHtml = notesStripped // paginates naturally and the Puppeteer footer template stamps every
? ` // page. Suppressed entirely when every section is empty (tag-stripped
<!-- Poznamky --> // check, so an empty-Quill "<p><br></p>" doesn't count). cs prefers
<div class="invoice-notes"> // the Czech title when present; en (or a missing Czech title) falls
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div> // back to the EN title.
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div> const resolveSectionTitle = (s: {
</div> title: string | null;
` title_cz: string | null;
: ""; }): 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),
);
let sectionsHtml = "";
if (visibleSections.length > 0) {
sectionsHtml += '<div class="scope-sections">';
for (const section of visibleSections) {
const title = resolveSectionTitle(section);
const content = (section.content || "").trim();
sectionsHtml += '<div class="scope-section">';
if (title.trim())
sectionsHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (content)
sectionsHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
sectionsHtml += "</div>";
}
sectionsHtml += "</div>";
}
// Quill indent CSS // Quill indent CSS
let indentCSS = ""; const indentCSS = buildQuillIndentCss();
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 html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}"> <html lang="${escapeHtml(lang)}">
@@ -552,7 +564,11 @@ export default async function invoicesPdfRoutes(
<style> <style>
@page { @page {
size: A4; size: A4;
margin: 8mm 12mm 10mm 12mm; /* Chromium uses THESE margins for layout (they override Puppeteer's
margin option) — they must match the header/footer template space:
32mm top (22mm logo + red heading + rule), 18mm bottom (Vystavil +
Strana X z Y). Same geometry as the issued-order PDF. */
margin: 32mm 12mm 18mm 12mm;
} }
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { html, body {
@@ -565,11 +581,19 @@ export default async function invoicesPdfRoutes(
.invoice-page { .invoice-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: calc(297mm - 27mm); /* Printable area with the Puppeteer header (32mm top) + footer (18mm
bottom) margins, minus 1mm slack so a one-page invoice can't spill. */
min-height: calc(297mm - 51mm);
} }
.invoice-content { flex: 1 1 auto; } .invoice-content { flex: 1 1 auto; }
/* The bottom block (notice + QR/VAT recap + Převzal/Razítko) must never be
SPLIT by a page break — on a one-page invoice the flex pinning keeps it
at the page bottom; when the content overflows, break-inside moves the
whole block to the next page as one unit instead of stranding the notice
on the previous page. */
.invoice-footer { .invoice-footer {
flex-shrink: 0; flex-shrink: 0;
break-inside: avoid;
} }
.accent { color: #de3a3a; } .accent { color: #de3a3a; }
@@ -781,13 +805,8 @@ export default async function invoicesPdfRoutes(
margin-top: 2mm; margin-top: 2mm;
} }
/* Vystavil */ /* Vystavil lives in Puppeteer's footerTemplate (bottom page margin) on
.issued-by { every page — same as the issued-order PDF; no body block. */
font-size: 9pt;
margin: 2mm 0;
line-height: 1.4;
}
.issued-by .lbl { font-weight: 600; }
/* Upozorneni */ /* Upozorneni */
.notice { .notice {
@@ -853,32 +872,42 @@ export default async function invoicesPdfRoutes(
color: #555; color: #555;
} }
/* Poznamky */ /* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
.invoice-notes { dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
margin-top: 4mm; stranu (mirrors issued-orders-pdf). */
font-size: 10pt; .scope-sections {
line-height: 1.5; margin-top: 6mm;
color: #1a1a1a;
} }
.invoice-notes-label { .scope-section {
font-weight: 600; width: 100%;
font-size: 9pt; max-width: 100%;
text-transform: uppercase; margin-bottom: 3mm;
color: #555; break-inside: avoid;
}
.scope-section-title {
font-size: 11pt;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 1mm; margin-bottom: 1mm;
} }
.invoice-notes-content p { margin: 0 0 0.4em 0; } .section-content {
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; } color: #1a1a1a;
.invoice-notes-content li { margin-bottom: 0.2em; } line-height: 1.5;
.invoice-notes-content, word-break: normal;
.invoice-notes-content * { overflow-wrap: anywhere;
}
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important; font-family: Tahoma, sans-serif !important;
} }
.invoice-notes-content { font-size: 14px; } .section-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; } .section-content h1 { font-size: 20px; }
.invoice-notes-content h2 { font-size: 18px; } .section-content h2 { font-size: 18px; }
.invoice-notes-content h3 { font-size: 16px; } .section-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; } .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 */ /* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; } [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
@@ -889,6 +918,10 @@ ${indentCSS}
@media print { @media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* The logo + red heading print via Puppeteer's headerTemplate on EVERY
page — hide the body copy so page 1 doesn't show it twice. The screen
(HTML preview) keeps the body header. */
.invoice-header { display: none; }
} }
@media screen { @media screen {
html { background: #525659; } html { background: #525659; }
@@ -1000,16 +1033,12 @@ ${indentCSS}
</div> </div>
</div> </div>
${notesHtml} ${sectionsHtml}
</div><!-- /.invoice-content --> </div><!-- /.invoice-content -->
<div class="invoice-footer"> <div class="invoice-footer">
<!-- Vystavil --> <!-- Vystavil tiskne Puppeteer footerTemplate na kazde strance -->
<div class="issued-by">
<span class="lbl">${escapeHtml(t.issued_by)}</span> ${escapeHtml(invoice.issued_by || "")}
${suppEmail ? `<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${escapeHtml(suppEmail)}` : ""}
</div>
<!-- Upozorneni --> <!-- Upozorneni -->
<div class="notice"> <div class="notice">
@@ -1074,7 +1103,20 @@ ${indentCSS}
? new Date(invoice.issue_date) ? new Date(invoice.issue_date)
: new Date(); : new Date();
nasInvoicesManager.cleanIssued(invoice.invoice_number!); nasInvoicesManager.cleanIssued(invoice.invoice_number!);
const pdfBuffer = await htmlToPdf(html); // Per-page "Vystavil + Strana X z Y" footer (same as issued
// orders); the supplier e-mail rides along on the left (it used to
// print under the body Vystavil block).
const issuerLine = `${invoice.issued_by || ""}${
suppEmail ? `${invoice.issued_by ? " · " : ""}${suppEmail}` : ""
}`;
const pdfBuffer = await htmlToPdf(html, {
headerTemplate: buildInvoicePdfHeader(
lang,
logoDataUri,
invoice.invoice_number || "",
),
footerTemplate: buildInvoicePdfFooter(lang, issuerLine),
});
nasInvoicesManager.saveIssuedPdf( nasInvoicesManager.saveIssuedPdf(
invoice.invoice_number!, invoice.invoice_number!,
issueDate.getFullYear(), issueDate.getFullYear(),

View File

@@ -2,65 +2,25 @@ import { FastifyInstance } from "fastify";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { parseId, success } from "../../utils/response"; import { parseId, success } from "../../utils/response";
import { localDateCzStr } from "../../utils/date";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { nasOrdersManager } from "../../services/nas-financials-manager"; import { nasOrdersManager } from "../../services/nas-financials-manager";
import {
formatDate,
formatNum,
formatCurrency,
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
logoDataUriFromSettings,
buildPdfHeaderTemplate,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify"; import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
const window = new JSDOM("").window; const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(window); const DOMPurify = createDOMPurify(window);
/* ── Helpers (copied from orders-pdf.ts — the shared confirmation template) ── */ /* ── Helpers — shared with the other PDF routes via utils/pdf-shared ── */
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 { interface AddressResult {
name: string; name: string;
@@ -156,6 +116,70 @@ function buildAddressLines(
return { name, lines }; return { name, lines };
} }
/**
* Address block for the sklad_suppliers counterparty — full customer model
* (structured street/city/postal_code/country + custom_fields JSON; the old
* dedicated address/contact columns were dropped 2026-06). IČO/DIČ come from
* the supplier's ico/dic columns, prefixed with the same translated labels
* the customer block uses; custom fields render as appended lines in array
* order (suppliers have no PDF field-order picker).
*/
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.street) lines.push(String(supplier.street));
const cityLine = [supplier.postal_code, supplier.city]
.filter(Boolean)
.map(String)
.join(" ")
.trim();
if (cityLine) lines.push(cityLine);
if (supplier.country) lines.push(String(supplier.country));
if (supplier.ico) lines.push(`${tObj.ico}${supplier.ico}`);
if (supplier.dic) lines.push(`${tObj.dic}${supplier.dic}`);
// Custom fields ("Vlastní pole") — same blob format as customers; malformed
// JSON degrades to no extra lines rather than failing the PDF.
if (supplier.custom_fields) {
let parsed: unknown;
try {
parsed =
typeof supplier.custom_fields === "string"
? JSON.parse(supplier.custom_fields)
: supplier.custom_fields;
} catch {
parsed = null;
}
const fields =
parsed && typeof parsed === "object" && !Array.isArray(parsed)
? ((parsed as Record<string, unknown>).fields as Array<{
name?: string;
value?: string;
showLabel?: boolean;
}>)
: Array.isArray(parsed)
? (parsed as Array<{
name?: string;
value?: string;
showLabel?: boolean;
}>)
: [];
for (const f of fields ?? []) {
const value = (f?.value || "").trim();
if (!value) continue;
const label = (f?.name || "").trim();
lines.push(
f?.showLabel !== false && label ? `${label}: ${value}` : value,
);
}
}
return { name, lines };
}
/* ── Translations ────────────────────────────────────────────────── */ /* ── Translations ────────────────────────────────────────────────── */
type Lang = "cs" | "en"; type Lang = "cs" | "en";
@@ -165,27 +189,18 @@ const translations: Record<Lang, Record<string, string>> = {
title: "OBJEDNÁVKA", title: "OBJEDNÁVKA",
heading: "OBJEDNÁVKA č.", heading: "OBJEDNÁVKA č.",
// PO direction: WE (company) are the buyer (Odběratel), the // 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", supplier: "Dodavatel",
buyer: "Odběratel", buyer: "Odběratel",
issue_date: "Datum vystavení:", issue_date: "Datum vystavení:",
delivery_date: "Požadované dodání:", delivery_date: "Požadované dodání:",
billing: "Objednáváme u Vás:", billing: "Objednáváme si u Vás:",
col_no: "Č.", col_no: "Č.",
col_desc: "Popis", col_desc: "Popis",
col_qty: "Množství", col_qty: "Množství",
col_unit_price: "Jedn. cena", col_unit_price: "Jedn. cena",
col_price: "Cena",
col_vat_pct: "%DPH",
col_vat: "DPH",
col_total: "Celkem", col_total: "Celkem",
subtotal: "Mezisoučet:", total_no_vat: "Celkem bez DPH",
vat_label: "DPH",
total: "Celkem",
amounts_in: "Částky jsou uvedeny v",
notes: "Poznámky",
delivery_terms: "Dodací podmínky:",
payment_terms: "Platební podmínky:",
issued_by: "Vystavil:", issued_by: "Vystavil:",
ico: "IČ: ", ico: "IČ: ",
dic: "DIČ: ", dic: "DIČ: ",
@@ -202,17 +217,8 @@ const translations: Record<Lang, Record<string, string>> = {
col_desc: "Description", col_desc: "Description",
col_qty: "Quantity", col_qty: "Quantity",
col_unit_price: "Unit price", col_unit_price: "Unit price",
col_price: "Price",
col_vat_pct: "VAT%",
col_vat: "VAT",
col_total: "Total", col_total: "Total",
subtotal: "Subtotal:", total_no_vat: "Total excl. VAT",
vat_label: "VAT",
total: "Total",
amounts_in: "Amounts are in",
notes: "Notes",
delivery_terms: "Delivery terms:",
payment_terms: "Payment terms:",
issued_by: "Issued by:", issued_by: "Issued by:",
ico: "Reg. No.: ", ico: "Reg. No.: ",
dic: "Tax ID: ", dic: "Tax ID: ",
@@ -224,12 +230,8 @@ interface IssuedOrderPdfData {
order_date: Date | null; order_date: Date | null;
delivery_date: Date | null; delivery_date: Date | null;
currency: string | null; currency: string | null;
apply_vat: boolean | null; // Editable heading above the items table; null → t.billing default.
vat_rate: unknown; order_text?: string | null;
notes: string | null;
delivery_terms: string | null;
payment_terms: string | null;
issued_by: string | null;
} }
interface IssuedOrderPdfItem { interface IssuedOrderPdfItem {
@@ -238,72 +240,98 @@ interface IssuedOrderPdfItem {
quantity: unknown; quantity: unknown;
unit: string | null; unit: string | null;
unit_price: unknown; 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 12mm; 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>`;
}
/**
* Repeating per-page header for the issued-order PDF — the unified
* red-accent family header (shared with invoices): logo left, red
* "OBJEDNÁVKA č. X" right, red rule underneath, on EVERY page. The body's
* own header is print-hidden so page 1 doesn't show it twice.
*/
export function buildIssuedOrderPdfHeader(
lang: Lang,
logoDataUri: string,
poNumber: string,
): string {
const t = translations[lang];
return buildPdfHeaderTemplate(logoDataUri, `${t.heading} ${poNumber}`);
} }
export function renderIssuedOrderHtml( export function renderIssuedOrderHtml(
order: IssuedOrderPdfData, order: IssuedOrderPdfData,
items: IssuedOrderPdfItem[], items: IssuedOrderPdfItem[],
customer: Record<string, unknown> | null, supplier: Record<string, unknown> | null,
settings: Record<string, unknown> | null, settings: Record<string, unknown> | null,
lang: Lang, 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 { ): string {
const t = translations[lang]; const t = translations[lang];
const applyVat = order.apply_vat !== false;
const currency = order.currency || "CZK"; const currency = order.currency || "CZK";
const docRate = order.vat_rate != null ? Number(order.vat_rate) : 21;
const poNumber = escapeHtml(order.po_number || ""); const poNumber = escapeHtml(order.po_number || "");
// Logo embedding (same logic as the confirmation template). // Logo embedding — shared helper (also feeds the Puppeteer headerTemplate).
let logoImg = ""; const logoDataUri = logoDataUriFromSettings(settings);
if (settings?.logo_data) { const logoImg = logoDataUri
const buf = Buffer.from(settings.logo_data as Buffer); ? `<img src="${logoDataUri}" class="logo" />`
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" />`;
}
// PO direction: our company (settings) = Odběratel (buyer); // 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 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 const buyerLinesHtml = buyer.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`) .map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join(""); .join("");
const supplierLinesHtml = supplier.lines const supplierLinesHtml = supplierAddr.lines
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`) .map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
.join(""); .join("");
// Items — NET + per-line VAT-on-top, rounded per line (same math the // Items — NET only. A purchase order is not a tax document: no VAT columns,
// service computeIssuedOrderTotals uses; mirrors the confirmation loop). // no VAT math (same as the service computeIssuedOrderTotals).
let subtotal = 0; let total = 0;
let totalVat = 0;
const vatSummary: Record<string, { base: number; vat: number }> = {};
const itemsHtml = items const itemsHtml = items
.map((it, i) => { .map((it, i) => {
const qty = Number(it.quantity) || 0; const qty = Number(it.quantity) || 0;
const unitPrice = Number(it.unit_price) || 0; const unitPrice = Number(it.unit_price) || 0;
const rate = const lineTotal = qty * unitPrice;
it.vat_rate != null && it.vat_rate !== "" total += lineTotal;
? 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 qtyDecimals = Math.floor(qty) === qty ? 0 : 2; const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
const descHtml = `${escapeHtml(it.description)}${ const descHtml = `${escapeHtml(it.description)}${
@@ -315,63 +343,53 @@ export function renderIssuedOrderHtml(
<td class="row-num">${i + 1}</td> <td class="row-num">${i + 1}</td>
<td class="desc">${descHtml}</td> <td class="desc">${descHtml}</td>
<td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td> <td class="center">${formatNum(qty, qtyDecimals)}${it.unit ? ` / ${escapeHtml(it.unit)}` : ""}</td>
<td class="right">${formatNum(unitPrice)}</td> <td class="right">${formatCurrency(unitPrice, currency)}</td>
<td class="right">${formatNum(lineSubtotal)}</td> <td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
<td class="center">${applyVat ? Math.floor(rate) : 0}%</td>
<td class="right">${formatNum(lineVat)}</td>
<td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`; </tr>`;
}) })
.join(""); .join("");
subtotal = Math.round(subtotal * 100) / 100; total = Math.round(total * 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>
`
: "";
const issueDateStr = formatDate(order.order_date); const issueDateStr = formatDate(order.order_date);
const deliveryDateStr = formatDate(order.delivery_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 // Quill indent CSS
let indentCSS = ""; const indentCSS = buildQuillIndentCss();
for (let n = 1; n <= 9; n++) {
const pad = n * 3; // ── Sections ("Obsah") — flow INLINE after the items/totals block (user
const liPad = n * 3 + 1.5; // decision: no separate page, no repeated header). Each section keeps
indentCSS += ` .ql-indent-${n} { padding-left: ${pad}em; }\n`; // break-inside: avoid; long content paginates naturally and the Puppeteer
indentCSS += ` li.ql-indent-${n} { padding-left: ${liPad}em; }\n`; // 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> return `<!DOCTYPE html>
@@ -382,7 +400,11 @@ export function renderIssuedOrderHtml(
<style> <style>
@page { @page {
size: A4; size: A4;
margin: 8mm 12mm 10mm 12mm; /* Chromium uses THESE margins for layout (they override Puppeteer's
margin option) — they must match the header/footer template space:
32mm top (22mm logo + red heading + rule), 18mm bottom (Vystavil +
Strana X z Y). Same geometry as the invoice PDF. */
margin: 32mm 12mm 18mm 12mm;
} }
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { html, body {
@@ -392,15 +414,9 @@ export function renderIssuedOrderHtml(
width: 186mm; width: 186mm;
} }
.invoice-page { /* The repeating "Vystavil / Strana X z Y" footer lives in Puppeteer's
display: flex; footerTemplate (bottom page margin), NOT in the body — the old flex
flex-direction: column; min-height pinning is gone with it. */
min-height: calc(297mm - 27mm);
}
.invoice-content { flex: 1 1 auto; }
.invoice-footer {
flex-shrink: 0;
}
.accent { color: #de3a3a; } .accent { color: #de3a3a; }
@@ -450,7 +466,7 @@ export function renderIssuedOrderHtml(
vertical-align: top; vertical-align: top;
width: 50%; width: 50%;
} }
.header-grid td.addr-customer { .header-grid td.addr-supplier {
background: #f5f5f5; background: #f5f5f5;
} }
.header-grid td.details-bank { .header-grid td.details-bank {
@@ -605,34 +621,6 @@ export function renderIssuedOrderHtml(
border-bottom: 2.5pt solid #de3a3a; border-bottom: 2.5pt solid #de3a3a;
padding-bottom: 1mm; 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 */ /* Upozorneni */
.notice { .notice {
font-size: 8pt; font-size: 8pt;
@@ -641,32 +629,42 @@ export function renderIssuedOrderHtml(
line-height: 1.3; line-height: 1.3;
} }
/* Poznamky */ /* Sekce ("Obsah") — tecou inline za tabulkou polozek; zadna nova stranka,
.invoice-notes { dlouhy obsah strankuje prirozene a paticku tiskne Puppeteer na kazdou
margin-top: 4mm; stranu. */
font-size: 10pt; .scope-sections {
line-height: 1.5; margin-top: 6mm;
color: #1a1a1a;
} }
.invoice-notes-label { .scope-section {
font-weight: 600; width: 100%;
font-size: 9pt; max-width: 100%;
text-transform: uppercase; margin-bottom: 3mm;
color: #555; break-inside: avoid;
}
.scope-section-title {
font-size: 11pt;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 1mm; margin-bottom: 1mm;
} }
.invoice-notes-content p { margin: 0 0 0.4em 0; } .section-content {
.invoice-notes-content ul, .invoice-notes-content ol { margin: 0 0 0.4em 1.5em; } color: #1a1a1a;
.invoice-notes-content li { margin-bottom: 0.2em; } line-height: 1.5;
.invoice-notes-content, word-break: normal;
.invoice-notes-content * { overflow-wrap: anywhere;
}
.section-content,
.section-content * {
font-family: Tahoma, sans-serif !important; font-family: Tahoma, sans-serif !important;
} }
.invoice-notes-content { font-size: 14px; } .section-content { font-size: 14px; }
.invoice-notes-content h1 { font-size: 20px; } .section-content h1 { font-size: 20px; }
.invoice-notes-content h2 { font-size: 18px; } .section-content h2 { font-size: 18px; }
.invoice-notes-content h3 { font-size: 16px; } .section-content h3 { font-size: 16px; }
.invoice-notes-content h4 { font-size: 15px; } .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 */ /* Quill fonty v PDF vynuceno Tahoma */
[class*="ql-font-"] { font-family: Tahoma, sans-serif !important; } [class*="ql-font-"] { font-family: Tahoma, sans-serif !important; }
@@ -677,6 +675,10 @@ ${indentCSS}
@media print { @media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* The logo + red heading print via Puppeteer's headerTemplate on EVERY
page — hide the body copy so page 1 doesn't show it twice. The screen
(HTML preview) keeps the body header. */
.invoice-header { display: none; }
} }
@media screen { @media screen {
html { background: #525659; } html { background: #525659; }
@@ -688,6 +690,7 @@ ${indentCSS}
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 30px;
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
} }
@@ -715,7 +718,7 @@ ${indentCSS}
<div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div> <div class="invoice-title">${escapeHtml(t.heading)} ${poNumber}</div>
</div> </div>
<!-- Odberatel (nase firma) / Dodavatel (zakaznik) + Detaily --> <!-- Odberatel (nase firma) / Dodavatel (sklad_suppliers) + Detaily -->
<table class="header-grid" cellspacing="0"> <table class="header-grid" cellspacing="0">
<tr> <tr>
<td> <td>
@@ -723,9 +726,9 @@ ${indentCSS}
<div class="address-name">${escapeHtml(buyer.name)}</div> <div class="address-name">${escapeHtml(buyer.name)}</div>
${buyerLinesHtml} ${buyerLinesHtml}
</td> </td>
<td class="addr-customer"> <td class="addr-supplier">
<div class="address-label">${escapeHtml(t.supplier)}</div> <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} ${supplierLinesHtml}
</td> </td>
</tr> </tr>
@@ -739,18 +742,15 @@ ${indentCSS}
</table> </table>
<!-- Polozky --> <!-- Polozky -->
<div class="billing-label">${escapeHtml(t.billing)}</div> <div class="billing-label">${escapeHtml(order.order_text || t.billing)}</div>
<table class="items"> <table class="items">
<thead> <thead>
<tr> <tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th> <th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:36%">${escapeHtml(t.col_desc)}</th> <th style="width:56%">${escapeHtml(t.col_desc)}</th>
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th> <th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th> <th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th> <th class="right" style="width:21%">${escapeHtml(t.col_total)}</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>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -758,34 +758,21 @@ ${indentCSS}
</tbody> </tbody>
</table> </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-wrapper">
<div class="totals"> <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"> <div class="grand">
<span class="label">${escapeHtml(t.total)}</span> <span class="label">${escapeHtml(t.total_no_vat)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span> <span class="value">${formatCurrency(total, currency)}</span>
</div> </div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
</div> </div>
</div> </div>
${notesHtml} ${scopeHtml}
${termsBlock}
</div><!-- /.invoice-content --> </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 --> </div><!-- /.invoice-page -->
</body> </body>
@@ -804,7 +791,6 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const query = request.query as Record<string, string>; const query = request.query as Record<string, string>;
const lang: Lang = query.lang === "en" ? "en" : "cs";
try { try {
const order = await prisma.issued_orders.findUnique({ where: { id } }); const order = await prisma.issued_orders.findUnique({ where: { id } });
@@ -814,13 +800,26 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
.type("text/html") .type("text/html")
.send("<html><body><h1>Objednávka nenalezena</h1></body></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({ const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: id }, where: { issued_order_id: id },
orderBy: { position: "asc" }, orderBy: { position: "asc" },
}); });
const customer = order.customer_id // id tiebreak: position can repeat, keep the order deterministic.
? ((await prisma.customers.findUnique({ const sections = await prisma.issued_order_sections.findMany({
where: { id: order.customer_id }, 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) })) as Record<string, unknown> | null)
: null; : null;
const settings = (await prisma.company_settings.findFirst()) as Record< const settings = (await prisma.company_settings.findFirst()) as Record<
@@ -838,10 +837,11 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
const html = renderIssuedOrderHtml( const html = renderIssuedOrderHtml(
order, order,
items, items,
customer, supplier,
settings, settings,
lang, lang,
issuer, issuer,
sections,
); );
// ?save=1 → archive the PDF to NAS instead of returning it (mirrors // ?save=1 → archive the PDF to NAS instead of returning it (mirrors
@@ -852,7 +852,14 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
? new Date(order.order_date) ? new Date(order.order_date)
: new Date(); : new Date();
nasOrdersManager.cleanIssued(order.po_number); nasOrdersManager.cleanIssued(order.po_number);
const pdfBuffer = await htmlToPdf(html); const pdfBuffer = await htmlToPdf(html, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
});
nasOrdersManager.saveIssuedPdf( nasOrdersManager.saveIssuedPdf(
order.po_number, order.po_number,
baseDate.getFullYear(), baseDate.getFullYear(),
@@ -862,7 +869,14 @@ export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
return success(reply, null, 200, "PDF uloženo"); return success(reply, null, 200, "PDF uloženo");
} }
const pdf = await htmlToPdf(html); const pdf = await htmlToPdf(html, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
footerTemplate: buildIssuedOrderPdfFooter(lang, issuer.name),
});
const filename = `Objednavka-${order.po_number || String(id)}.pdf`; const filename = `Objednavka-${order.po_number || String(id)}.pdf`;
return reply return reply
.type("application/pdf") .type("application/pdf")

View File

@@ -1,5 +1,6 @@
import { FastifyInstance } from "fastify"; 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 { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response"; import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
@@ -17,6 +18,20 @@ import {
deleteIssuedOrder, deleteIssuedOrder,
getNextIssuedOrderNumberPreview, getNextIssuedOrderNumberPreview,
} from "../../services/issued-orders.service"; } from "../../services/issued-orders.service";
import {
renderIssuedOrderHtml,
buildIssuedOrderPdfFooter,
buildIssuedOrderPdfHeader,
} from "./issued-orders-pdf";
import { htmlToPdf } from "../../utils/html-to-pdf";
import { logoDataUriFromSettings } from "../../utils/pdf-shared";
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) { export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
// GET /api/admin/issued-orders // GET /api/admin/issued-orders
@@ -25,16 +40,16 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
{ preHandler: requirePermission("orders.view") }, { preHandler: requirePermission("orders.view") },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, unknown>; 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({ const result = await listIssuedOrders({
page, page,
limit, limit,
skip, skip,
sort: String(query.sort || ""), sort,
order, order,
search, search,
status: query.status ? String(query.status) : 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, month: query.month ? Number(query.month) : undefined,
year: query.year ? Number(query.year) : undefined, year: query.year ? Number(query.year) : undefined,
}); });
@@ -55,9 +70,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
}, },
); );
// GET /api/admin/issued-orders/stats — per-currency total (incl. VAT) summed // GET /api/admin/issued-orders/stats — per-currency NET total summed over
// over the WHOLE filtered set (not one page). Registered BEFORE "/:id" so the // the WHOLE filtered set (not one page). Issued orders are not tax documents
// literal "stats" path isn't captured as an order id. // — no VAT anywhere. Registered BEFORE "/:id" so the literal "stats" path
// isn't captured as an order id.
fastify.get( fastify.get(
"/stats", "/stats",
{ preHandler: requirePermission("orders.view") }, { preHandler: requirePermission("orders.view") },
@@ -66,7 +82,7 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
const result = await getIssuedOrderTotals({ const result = await getIssuedOrderTotals({
search: query.search ? String(query.search) : undefined, search: query.search ? String(query.search) : undefined,
status: query.status ? String(query.status) : 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, month: query.month ? Number(query.month) : undefined,
year: query.year ? Number(query.year) : undefined, year: query.year ? Number(query.year) : undefined,
}); });
@@ -74,6 +90,41 @@ 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,
street: true,
city: true,
postal_code: true,
country: true,
},
// id tiebreak so same-name suppliers sort deterministically.
orderBy: [{ name: "asc" }, { id: "asc" }],
});
return success(reply, suppliers);
},
);
// GET /api/admin/issued-orders/:id // GET /api/admin/issued-orders/:id
fastify.get<{ Params: { id: string } }>( fastify.get<{ Params: { id: string } }>(
"/:id", "/:id",
@@ -83,7 +134,222 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
if (id === null) return; if (id === null) return;
const order = await getIssuedOrder(id); const order = await getIssuedOrder(id);
if (!order) return error(reply, "Objednávka nenalezena", 404); 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, {
headerTemplate: buildIssuedOrderPdfHeader(
lang,
logoDataUriFromSettings(settings),
order.po_number || "",
),
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 +361,13 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
const parsed = parseBody(CreateIssuedOrderSchema, request.body); const parsed = parseBody(CreateIssuedOrderSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400); if ("error" in parsed) return error(reply, parsed.error, 400);
const order = await createIssuedOrder(parsed.data); 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({ await logAudit({
request, request,
authData: request.authData, authData: request.authData,
@@ -125,6 +398,10 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
if ("error" in result) { if ("error" in result) {
if (result.error === "not_found") if (result.error === "not_found")
return error(reply, "Objednávka nenalezena", 404); 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") if (result.error === "invalid_transition")
return error( return error(
reply, reply,
@@ -139,9 +416,18 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
action: "update", action: "update",
entityType: "issued_order", entityType: "issued_order",
entityId: id, 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 +446,8 @@ export default async function issuedOrdersRoutes(fastify: FastifyInstance) {
action: "delete", action: "delete",
entityType: "issued_order", entityType: "issued_order",
entityId: id, 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"); return success(reply, null, 200, "Objednávka smazána");
}, },

View File

@@ -1,102 +1,24 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import type { Prisma, company_settings } from "@prisma/client";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { nasOffersManager } from "../../services/nas-offers-manager"; import { nasOffersManager } from "../../services/nas-offers-manager";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId, success } from "../../utils/response"; import { parseId, success } from "../../utils/response";
import {
formatDate,
formatNum,
formatCurrency,
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
} from "../../utils/pdf-shared";
import createDOMPurify from "dompurify"; import createDOMPurify from "dompurify";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
const window = new JSDOM("").window; const window = new JSDOM("").window;
const DOMPurify = createDOMPurify(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 { interface AddressResult {
name: string; name: string;
lines: string[]; lines: string[];
@@ -203,144 +125,124 @@ const TRANSLATIONS: Record<string, Record<string, string>> = {
unit_price: { EN: "Unit Price", CZ: "Jedn. cena" }, unit_price: { EN: "Unit Price", CZ: "Jedn. cena" },
included: { EN: "Included", CZ: "Zahrnuto" }, included: { EN: "Included", CZ: "Zahrnuto" },
total: { EN: "Total", CZ: "Celkem" }, total: { EN: "Total", CZ: "Celkem" },
subtotal: { EN: "Subtotal", CZ: "Mezisou\u010Det" }, total_no_vat: { EN: "Total excl. VAT", CZ: "Celkem bez DPH" },
vat: { EN: "VAT", CZ: "DPH" },
total_to_pay: { EN: "Total to pay", CZ: "Celkem k \u00FAhrad\u011B" },
ico: { EN: "ID", CZ: "I\u010CO" }, ico: { EN: "ID", CZ: "I\u010CO" },
dic: { EN: "VAT ID", CZ: "DI\u010C" }, dic: { EN: "VAT ID", CZ: "DI\u010C" },
page: { EN: "Page", CZ: "Strana" }, page: { EN: "Page", CZ: "Strana" },
of: { EN: "of", CZ: "z" }, of: { EN: "of", CZ: "z" },
}; };
export default async function offersPdfRoutes( /** Quotation row with the relations the PDF template renders. */
fastify: FastifyInstance, export type OfferForPdf = Prisma.quotationsGetPayload<{
): Promise<void> { include: {
fastify.get<{ Params: { id: string } }>( customers: true;
"/:id", quotation_items: true;
{ preHandler: requirePermission("offers.view") }, scope_sections: true;
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({ * Build the full offer PDF/preview HTML. Extracted from the route handler so
where: { id }, * the offers /:id/file live-render fallback (quotations.ts) can reuse the
include: { * exact same template without going through HTTP.
customers: true, */
quotation_items: { orderBy: { position: "asc" } }, export function renderOfferHtml(
scope_sections: { orderBy: { position: "asc" } }, 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) { let logoImg = "";
return reply if (settings?.logo_data) {
.status(404) const buf = Buffer.from(settings.logo_data);
.type("text/html") let mime = "image/png";
.send("<html><body><h1>Nab\u00EDdka nenalezena</h1></body></html>"); 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(); // Offers are NOT tax documents — the total is NET only (no VAT math).
const isCzech = (quotation.language ?? "EN") !== "EN"; const items = quotation.quotation_items;
const langKey = isCzech ? "CZ" : "EN"; let total = 0;
const currency = quotation.currency || "EUR"; for (const item of items) {
const t = (key: string): string => TRANSLATIONS[key]?.[langKey] || key; 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 = ""; const cust = buildAddressLines(
if (settings?.logo_data) { quotation.customers as unknown as Record<string, unknown>,
const buf = Buffer.from(settings.logo_data); false,
let mime = "image/png"; t,
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; );
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif"; const supp = buildAddressLines(
else if (buf[0] === 0x52 && buf[1] === 0x49) mime = "image/webp"; settings as unknown as Record<string, unknown>,
logoImg = `<img src="data:${escapeHtml(mime)};base64,${buf.toString("base64")}" class="logo" />`; true,
} t,
);
const items = quotation.quotation_items; const custLinesHtml = cust.lines
let subtotal = 0; .map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
for (const item of items) { .join("");
if (item.is_included_in_total !== false) { const suppLinesHtml = supp.lines
subtotal += .map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0); .join("");
}
}
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 cust = buildAddressLines( // Indentation CSS for Quill
quotation.customers as unknown as Record<string, unknown>, const indentCSS = buildQuillIndentCss();
false,
t,
);
const supp = buildAddressLines(
settings as unknown as Record<string, unknown>,
true,
t,
);
const custLinesHtml = cust.lines let itemsHtml = "";
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`) items.forEach((item, i) => {
.join(""); const qty = Number(item.quantity) || 0;
const suppLinesHtml = supp.lines const lineTotal = qty * (Number(item.unit_price) || 0);
.map((l) => `<div class="address-line">${escapeHtml(l)}</div>`) // Integers render without decimals; fractional quantities keep 2 decimals
.join(""); // (the old toFixed(0) ROUNDED 1.5 → 2 on the printed document).
const qtyDecimals = Math.floor(qty) === qty ? 0 : 2;
// Indentation CSS for Quill const subDesc = item.item_description || "";
let indentCSS = ""; const evenClass = i % 2 === 1 ? ' class="even"' : "";
for (let n = 1; n <= 9; n++) { itemsHtml += `<tr${evenClass}>
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}>
<td class="row-num">${i + 1}</td> <td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td> <td class="desc">${escapeHtml(item.description)}${subDesc ? `<div class="item-subdesc">${escapeHtml(subDesc)}</div>` : ""}</td>
<td class="center">${formatNum(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">${formatCurrency(Number(item.unit_price) || 0, currency)}</td>
<td class="right total-cell">${formatCurrency(lineTotal, currency)}</td> <td class="right total-cell">${formatCurrency(lineTotal, currency)}</td>
</tr>`; </tr>`;
}); });
let totalsHtml = ""; // No Mezisoučet/VAT rows — they would only duplicate the net total.
if (applyVat) { const totalsHtml = `<div class="grand">
totalsHtml += `<div class="detail-rows"> <span class="label">${escapeHtml(t("total_no_vat"))}</span>
<div class="row"> <span class="value">${formatCurrency(total, currency)}</span>
<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>
</div>`; </div>`;
const quotationNumber = escapeHtml(quotation.quotation_number); const quotationNumber = escapeHtml(quotation.quotation_number);
let scopeHtml = ""; let scopeHtml = "";
if (hasScopeContent) { if (hasScopeContent) {
scopeHtml += '<div class="scope-page">'; scopeHtml += '<div class="scope-page">';
scopeHtml += `<div class="page-header"> scopeHtml += `<div class="page-header">
<div class="left"> <div class="left">
<div class="page-title">${escapeHtml(t("title"))}</div> <div class="page-title">${escapeHtml(t("title"))}</div>
<div class="quotation-number">${quotationNumber}</div> <div class="quotation-number">${quotationNumber}</div>
@@ -351,27 +253,23 @@ export default async function offersPdfRoutes(
</div> </div>
<hr class="separator" />`; <hr class="separator" />`;
for (const section of quotation.scope_sections) { for (const section of visibleSections) {
const title = const title = resolveSectionTitle(section);
isCzech && (section.title_cz || "").trim() const content = (section.content || "").trim();
? section.title_cz scopeHtml += '<div class="scope-section">';
: section.title || ""; if (title.trim())
const content = (section.content || "").trim(); scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`;
if (!title && !content) continue; if (content)
scopeHtml += '<div class="scope-section">'; scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`;
if (title) scopeHtml += "</div>";
scopeHtml += `<div class="scope-section-title">${escapeHtml(title)}</div>`; }
if (content) scopeHtml += "</div>";
scopeHtml += `<div class="section-content">${cleanQuillHtml(DOMPurify.sanitize(content))}</div>`; }
scopeHtml += "</div>";
}
scopeHtml += "</div>";
}
const pageLabel = escapeHtml(t("page")); const pageLabel = escapeHtml(t("page"));
const ofLabel = escapeHtml(t("of")); const ofLabel = escapeHtml(t("of"));
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html lang="${isCzech ? "cs" : "en"}"> <html lang="${isCzech ? "cs" : "en"}">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@@ -749,6 +647,40 @@ ${indentCSS}
</body> </body>
</html>`; </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"; const saveMode = query.save === "1";
// Save PDF to NAS // Save PDF to NAS

View File

@@ -1,9 +1,15 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import prisma from "../../config/database"; import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth"; import { requirePermission } from "../../middleware/auth";
import { localDateCzStr } from "../../utils/date";
import { htmlToPdf } from "../../utils/html-to-pdf"; import { htmlToPdf } from "../../utils/html-to-pdf";
import { parseId, error } from "../../utils/response"; import { parseId, error } from "../../utils/response";
import {
formatDate,
formatNum,
escapeHtml,
cleanQuillHtml,
buildQuillIndentCss,
} from "../../utils/pdf-shared";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { z } from "zod"; import { z } from "zod";
import createDOMPurify from "dompurify"; import createDOMPurify from "dompurify";
@@ -20,69 +26,18 @@ const OrderPdfItemSchema = z.object({
unit: z.string().max(255), unit: z.string().max(255),
unit_price: z.number().min(0).finite(), unit_price: z.number().min(0).finite(),
is_included_in_total: z.boolean().optional(), 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()`. // `z.looseObject` is the Zod 4 replacement for the deprecated `.passthrough()`.
// `items` is strictly validated; `lang`/`applyVat` are typed explicitly so the // `items` is strictly validated; `lang` is typed explicitly so the handler no
// handler no longer reads them as untyped passthrough keys. // longer reads it as an untyped passthrough key.
const OrderPdfBodySchema = z.looseObject({ const OrderPdfBodySchema = z.looseObject({
items: z.array(OrderPdfItemSchema).optional(), items: z.array(OrderPdfItemSchema).optional(),
lang: z.string().max(10).optional(), lang: z.string().max(10).optional(),
applyVat: z.boolean().optional(),
}); });
/* ── Helpers ─────────────────────────────────────────────────────── */ /* ── 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 { interface AddressResult {
name: string; name: string;
lines: string[]; lines: string[];
@@ -193,13 +148,8 @@ const translations: Record<string, Record<string, string>> = {
col_desc: "Popis", col_desc: "Popis",
col_qty: "Množství", col_qty: "Množství",
col_unit_price: "Jedn. cena", col_unit_price: "Jedn. cena",
col_price: "Cena",
col_vat_pct: "%DPH",
col_vat: "DPH",
col_total: "Celkem", col_total: "Celkem",
subtotal: "Mezisoučet:", total_no_vat: "Celkem bez DPH",
vat_label: "DPH",
total: "Celkem",
amounts_in: "Částky jsou uvedeny v", amounts_in: "Částky jsou uvedeny v",
notes: "Poznámky", notes: "Poznámky",
issued_by: "Vystavil:", issued_by: "Vystavil:",
@@ -221,13 +171,8 @@ const translations: Record<string, Record<string, string>> = {
col_desc: "Description", col_desc: "Description",
col_qty: "Quantity", col_qty: "Quantity",
col_unit_price: "Unit price", col_unit_price: "Unit price",
col_price: "Price",
col_vat_pct: "VAT%",
col_vat: "VAT",
col_total: "Total", col_total: "Total",
subtotal: "Subtotal:", total_no_vat: "Total excl. VAT",
vat_label: "VAT",
total: "Total",
amounts_in: "Amounts are in", amounts_in: "Amounts are in",
notes: "Notes", notes: "Notes",
issued_by: "Issued by:", issued_by: "Issued by:",
@@ -238,204 +183,112 @@ const translations: Record<string, Record<string, string>> = {
}, },
}; };
/* ── Route ───────────────────────────────────────────────────────── */ /* ── Template ────────────────────────────────────────────────────── */
export default async function ordersPdfRoutes( export interface OrderConfirmationPdfItem {
fastify: FastifyInstance, description: string;
): Promise<void> { quantity: number;
fastify.post<{ Params: { id: string }; Body: Record<string, unknown> }>( unit: string;
"/:id/confirmation", unit_price: number;
{ preHandler: requirePermission("orders.view") }, is_included_in_total: boolean;
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 { interface OrderConfirmationPdfData {
const lang = body.lang === "en" ? "en" : "cs"; order_number: string | null;
const t = translations[lang]; 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 }, * Order-confirmation HTML. The confirmation is NOT a tax document — prices
include: { * are NET only: no VAT columns or VAT summary; the grand total is labeled
customers: true, * "Celkem bez DPH". Exported for tests.
order_items: { orderBy: { position: "asc" } }, */
}, export function renderOrderConfirmationHtml(
}); order: OrderConfirmationPdfData,
items: OrderConfirmationPdfItem[],
settings: Record<string, unknown> | null,
lang: "cs" | "en",
userName: string,
): string {
const t = translations[lang];
if (!order) { let logoImg = "";
return reply if (settings?.logo_data) {
.status(404) const buf = Buffer.from(settings.logo_data as Buffer);
.type("text/html") let mime = "image/png";
.send("<html><body><h1>Objednávka nenalezena</h1></body></html>"); 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< const currency = order.currency || "CZK";
string,
unknown
> | null;
let logoImg = ""; // NET-only total over the included lines — no VAT math anywhere.
if (settings?.logo_data) { let total = 0;
const buf = Buffer.from(settings.logo_data as Buffer); for (const item of items) {
let mime = "image/png"; if (item.is_included_in_total) total += item.quantity * item.unit_price;
if (buf[0] === 0xff && buf[1] === 0xd8) mime = "image/jpeg"; }
else if (buf[0] === 0x47 && buf[1] === 0x49) mime = "image/gif"; total = Math.round(total * 100) / 100;
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 currency = order.currency || "CZK"; const supp = buildAddressLines(settings, true, t);
const applyVat = const cust = buildAddressLines(
body.applyVat !== undefined ? !!body.applyVat : !!order.apply_vat; (order.customers as Record<string, unknown>) || null,
const orderVatRate = Number(order.vat_rate) || 21; false,
t,
);
// The confirmation PDF can be rendered from client-supplied items (e.g. const suppLinesHtml = supp.lines
// a live preview of unsaved edits on the detail page) OR from the .map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
// stored order. Fabricating descriptions/prices that don't reflect the .join("");
// stored order is an editing action, so the custom-items path requires const custLinesHtml = cust.lines
// `orders.edit` (admins bypass). A view-only caller is silently served .map((l) => `<div class="address-line">${escapeHtml(l)}</div>`)
// the STORED order items instead — preventing a `orders.view` holder .join("");
// 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: Array<{ const orderNumber = escapeHtml(order.order_number || "");
description: string; const poNumber = escapeHtml(order.customer_order_number || "");
quantity: number; const orderDateStr = formatDate(order.created_at);
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}> = [];
if (customItemsRaw && customItemsRaw.length > 0) { const itemsHtml = items
items = customItemsRaw.map((it) => ({ .map((item, i) => {
description: it.description, const lineTotal = item.quantity * item.unit_price;
quantity: it.quantity, const qtyDecimals = Math.floor(item.quantity) === item.quantity ? 0 : 2;
unit: it.unit, return `<tr>
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>
<td class="row-num">${i + 1}</td> <td class="row-num">${i + 1}</td>
<td class="desc">${escapeHtml(item.description)}</td> <td class="desc">${escapeHtml(item.description)}</td>
<td class="center">${formatNum(item.quantity, qtyDecimals)}${item.unit ? ` / ${escapeHtml(item.unit)}` : ""}</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(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> <td class="right total-cell">${formatNum(lineTotal)}</td>
</tr>`; </tr>`;
}) })
.join(""); .join("");
const paymentMethod = const paymentMethod =
String((order as Record<string, unknown>).payment_method || "") || String(order.payment_method || "") ||
(lang === "cs" ? "převodem" : "Bank transfer"); (lang === "cs" ? "převodem" : "Bank transfer");
let vatDetailHtml = ""; const notesRaw = order.notes ?? "";
if (applyVat) { const notesStripped = notesRaw.replace(/<[^>]*>/g, "").trim();
for (const [rate, data] of Object.entries(vatSummary)) { const notesHtml = notesStripped
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">
<div class="invoice-notes-label">${escapeHtml(t.notes)}</div> <div class="invoice-notes-label">${escapeHtml(t.notes)}</div>
<div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div> <div class="invoice-notes-content">${cleanQuillHtml(DOMPurify.sanitize(notesRaw))}</div>
</div> </div>
` `
: ""; : "";
// Quill indent CSS // Quill indent CSS
let indentCSS = ""; const indentCSS = buildQuillIndentCss();
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 html = `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="${escapeHtml(lang)}"> <html lang="${escapeHtml(lang)}">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
@@ -683,47 +536,6 @@ export default async function ordersPdfRoutes(
line-height: 1.3; 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 */ /* Prevzal / razitko */
.footer-row { .footer-row {
display: flex; display: flex;
@@ -845,13 +657,10 @@ ${indentCSS}
<thead> <thead>
<tr> <tr>
<th class="center" style="width:3%">${escapeHtml(t.col_no)}</th> <th class="center" style="width:3%">${escapeHtml(t.col_no)}</th>
<th style="width:36%">${escapeHtml(t.col_desc)}</th> <th style="width:56%">${escapeHtml(t.col_desc)}</th>
<th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th> <th class="center" style="width:10%">${escapeHtml(t.col_qty)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th> <th class="right" style="width:10%">${escapeHtml(t.col_unit_price)}</th>
<th class="right" style="width:10%">${escapeHtml(t.col_price)}</th> <th class="right" style="width:21%">${escapeHtml(t.col_total)}</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>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -859,18 +668,12 @@ ${indentCSS}
</tbody> </tbody>
</table> </table>
<!-- Soucty --> <!-- Soucty (jen souhrnny radek bez DPH - mezisoucet by ho jen opakoval) -->
<div class="totals-wrapper"> <div class="totals-wrapper">
<div class="totals"> <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"> <div class="grand">
<span class="label">${escapeHtml(t.total)}</span> <span class="label">${escapeHtml(t.total_no_vat)}</span>
<span class="value">${formatNum(totalToPay)} ${escapeHtml(currency)}</span> <span class="value">${formatNum(total)} ${escapeHtml(currency)}</span>
</div> </div>
<div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div> <div class="currency-note">${escapeHtml(t.amounts_in)} ${escapeHtml(currency)}</div>
</div> </div>
@@ -897,9 +700,96 @@ ${indentCSS}
</body> </body>
</html>`; </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 pdfBuffer = await htmlToPdf(html);
const filename = `Potvrzeni-${orderNumber || String(id)}.pdf`; const filename = `Potvrzeni-${order.order_number || String(id)}.pdf`;
return reply return reply
.type("application/pdf") .type("application/pdf")

View File

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

View File

@@ -3,6 +3,7 @@ import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response"; import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { contentDisposition } from "../../utils/content-disposition";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { import {
CreateQuotationSchema, CreateQuotationSchema,
@@ -21,8 +22,13 @@ import {
getNextOfferNumber, getNextOfferNumber,
} from "../../services/offers.service"; } from "../../services/offers.service";
import { nasOffersManager } from "../../services/nas-offers-manager"; 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( export default async function quotationsRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
@@ -53,9 +59,10 @@ export default async function quotationsRoutes(
}, },
); );
// GET /api/admin/offers/stats — per-currency total (incl. VAT) summed over the // GET /api/admin/offers/stats — per-currency NET total summed over the WHOLE
// WHOLE filtered set (not one page). Offers have NO month/year filter; the // filtered set (not one page). Offers are not tax documents — no VAT
// list filters by search + status + customer_id, so the totals use the same. // 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. // Registered BEFORE "/:id" so the literal "stats" path isn't captured as an id.
fastify.get( fastify.get(
"/stats", "/stats",
@@ -117,8 +124,19 @@ export default async function quotationsRoutes(
const id = parseId(request.params.id, reply); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const existing = await invalidateOffer(id); const result = await invalidateOffer(id);
if (!existing) return error(reply, "Nabídka nenalezena", 404); 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({ await logAudit({
request, request,
@@ -126,7 +144,9 @@ export default async function quotationsRoutes(
action: "update", action: "update",
entityType: "quotation", entityType: "quotation",
entityId: id, 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"); 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); if ("error" in parsed) return error(reply, parsed.error, 400);
const quotation = await createOffer(parsed.data); const quotation = await createOffer(parsed.data);
if ("error" in quotation) if ("error" in quotation) {
return error(reply, quotation.error!, quotation.status!); 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({ await logAudit({
request, request,
@@ -295,13 +320,17 @@ export default async function quotationsRoutes(
if ("error" in result) { if ("error" in result) {
if (result.error === "not_found") if (result.error === "not_found")
return error(reply, "Nabídka nenalezena", 404); return error(reply, "Nabídka nenalezena", 404);
if (result.error === "invalidated") if (result.error === "not_editable")
return error(reply, "Nelze upravit zneplatněnou nabídku", 400); return error(reply, "Nabídku v tomto stavu nelze upravovat", 400);
return error( if (result.error === "invalid_transition")
reply, return error(
result.error || "Neznámá chyba", reply,
"status" in result ? result.status : 400, `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 // Keep lock — user stays on the page after save
@@ -312,7 +341,9 @@ export default async function quotationsRoutes(
action: "update", action: "update",
entityType: "quotation", entityType: "quotation",
entityId: id, 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"); 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); const id = parseId(request.params.id, reply);
if (id === null) return; if (id === null) return;
const existing = await deleteOffer(id); const result = await deleteOffer(id);
if (!existing) return error(reply, "Nabídka nenalezena", 404); if ("error" in result) {
if (result.error === "not_found")
// Delete PDF from NAS return error(reply, "Nabídka nenalezena", 404);
if (existing.quotation_number && existing.created_at) { if (result.error === "linked")
const yr = new Date(existing.created_at).getFullYear(); return error(
try { reply,
await nasOffersManager.deleteOfferPdf( "Nabídku nelze smazat, je navázána na objednávku nebo projekt",
nasOffersManager.buildRelativePath(existing.quotation_number, yr), 409,
); );
} catch (e) { return error(reply, "Neznámá chyba", 500);
// 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`,
);
}
} }
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({ await logAudit({
request, request,
@@ -351,7 +380,8 @@ export default async function quotationsRoutes(
action: "delete", action: "delete",
entityType: "quotation", entityType: "quotation",
entityId: id, 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"); return success(reply, null, 200, "Nabídka smazána");
}, },
@@ -379,15 +409,71 @@ export default async function quotationsRoutes(
year, year,
); );
const file = nasOffersManager.readOfferPdf(relPath); 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 // Archive miss → live-render fallback (same model as issued orders'
.type("application/pdf") // /:id/file): rebuild the PDF from current data with the exact PDF-route
.header( // template, re-archive it so the next request is a plain hit, and serve
"Content-Disposition", // the fresh bytes with identical response semantics.
`inline; filename="${offer.quotation_number}.pdf"`, try {
) const quotation = await prisma.quotations.findUnique({
.send(file.data); 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 { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response"; import { success, error, parseId, paginated } from "../../utils/response";
import { contentDisposition } from "../../utils/content-disposition";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { import {
@@ -14,6 +15,7 @@ import {
} from "../../schemas/received-invoices.schema"; } from "../../schemas/received-invoices.schema";
import { nasInvoicesManager } from "../../services/nas-financials-manager"; import { nasInvoicesManager } from "../../services/nas-financials-manager";
import { toCzk } from "../../services/exchange-rates"; import { toCzk } from "../../services/exchange-rates";
import { utcMidnightOfLocalDay } from "../../utils/date";
import path from "path"; import path from "path";
const VALID_STATUSES = ["unpaid", "paid"] as const; 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); if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
const mime = invoice.file_mime || "application/pdf"; 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 return reply
.type(mime) .type(mime)
.header("Content-Disposition", `inline; filename="${safeFileName}"`) .header(
"Content-Disposition",
contentDisposition("inline", invoice.file_name),
)
.send(nasFile.data); .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. // `amount` is the GROSS total (VAT included); VAT is the portion within it.
const computedVat = vatFromGross(finalAmount, finalVatRate); 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 = const newStatus =
body.status !== undefined body.status !== undefined
? String(body.status) ? String(body.status)
: String(existing.status); : String(existing.status);
const paidDate = const paidDate =
newStatus === "paid" && String(existing.status) !== "paid" newStatus === "paid" && String(existing.status) !== "paid"
? new Date() ? utcMidnightOfLocalDay()
: body.paid_date !== undefined : body.paid_date !== undefined
? body.paid_date ? body.paid_date
? new Date(String(body.paid_date)) ? new Date(String(body.paid_date))

View File

@@ -264,7 +264,8 @@ export default async function totpRoutes(
async (request, reply) => { async (request, reply) => {
const parsed = parseBody(TotpBackupSchema, request.body); const parsed = parseBody(TotpBackupSchema, request.body);
if ("error" in parsed) return error(reply, parsed.error, 400); 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 const tokenHash = crypto
.createHash("sha256") .createHash("sha256")
@@ -393,14 +394,18 @@ export default async function totpRoutes(
.update(refreshTokenRaw) .update(refreshTokenRaw)
.digest("hex"); .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({ await prisma.refresh_tokens.create({
data: { data: {
user_id: user.id, user_id: user.id,
token_hash: refreshTokenHash, token_hash: refreshTokenHash,
expires_at: new Date( expires_at: new Date(Date.now() + refreshExpiresIn * 1000),
Date.now() + config.jwt.refreshTokenSessionExpiry * 1000, remember_me: rememberMe,
),
remember_me: false,
ip_address: request.ip, ip_address: request.ip,
user_agent: request.headers["user-agent"] ?? null, user_agent: request.headers["user-agent"] ?? null,
}, },
@@ -411,7 +416,7 @@ export default async function totpRoutes(
secure: config.isProduction, secure: config.isProduction,
sameSite: "strict", sameSite: "strict",
path: "/api/admin", path: "/api/admin",
maxAge: config.jwt.refreshTokenSessionExpiry, maxAge: refreshExpiresIn,
}); });
await logAudit({ await logAudit({

View File

@@ -6,6 +6,99 @@ import { success, paginated, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { CreateTripSchema, UpdateTripSchema } from "../../schemas/trips.schema"; 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( export default async function tripsRoutes(
fastify: FastifyInstance, fastify: FastifyInstance,
@@ -22,55 +115,16 @@ export default async function tripsRoutes(
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, unknown>; const query = request.query as Record<string, unknown>;
const { page, limit, skip, order } = parsePagination(query); const { page, limit, skip, order } = parsePagination(query);
const authData = request.authData!; const where = buildTripsWhere(query, 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 [trips, total] = await Promise.all([ const [trips, total] = await Promise.all([
prisma.trips.findMany({ prisma.trips.findMany({
where, where,
skip, skip,
take: limit, 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: { include: {
users: { select: { id: true, first_name: true, last_name: true } }, users: { select: { id: true, first_name: true, last_name: true } },
vehicles: { select: { id: true, name: true, spz: 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 // GET /api/admin/trips/users — users with trips.record permission
fastify.get( fastify.get(
"/users", "/users",
@@ -121,28 +229,11 @@ export default async function tripsRoutes(
{ preHandler: requirePermission("trips.manage") }, { preHandler: requirePermission("trips.manage") },
async (request, reply) => { async (request, reply) => {
const query = request.query as Record<string, unknown>; const query = request.query as Record<string, unknown>;
const filterUserId = query.user_id ? Number(query.user_id) : null; // Same shared filter source as the list + stats endpoints (incl. the
const filterVehicleId = query.vehicle_id // month 1-12 validation). /print is manager-only (trips.manage guard),
? Number(query.vehicle_id) // so buildTripsWhere always takes its manager branch here — user_id /
: null; // vehicle_id / month filters behave exactly as before.
const where = buildTripsWhere(query, request.authData!);
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),
};
}
const trips = await prisma.trips.findMany({ const trips = await prisma.trips.findMany({
where, where,
@@ -150,7 +241,8 @@ export default async function tripsRoutes(
users: { select: { id: true, first_name: true, last_name: true } }, users: { select: { id: true, first_name: true, last_name: true } },
vehicles: { select: { id: true, name: true, spz: 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({ const vehicles = await prisma.vehicles.findMany({
@@ -166,7 +258,13 @@ export default async function tripsRoutes(
let businessKm = 0; let businessKm = 0;
let privateKm = 0; let privateKm = 0;
for (const t of trips) { 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; totalKm += dist;
if (t.is_business) businessKm += dist; if (t.is_business) businessKm += dist;
else privateKm += 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 // Recompute actual_km from MAX(end_km) across all trips (same helper
// PUT/DELETE). Setting it unconditionally to this trip's end_km would // as PUT/DELETE). Setting it unconditionally to this trip's end_km
// regress the odometer when a back-dated trip with a lower end_km is // would regress the odometer when a back-dated trip with a lower
// entered after a later trip with a higher reading. // end_km is entered after a later trip with a higher reading. (The
const maxTrip = await prisma.trips.findFirst({ // helper's initial_km fallback is unreachable here — the just-created
where: { vehicle_id: vehicleId }, // trip guarantees at least one row.)
orderBy: { end_km: "desc" }, await recomputeVehicleActualKm(vehicleId);
select: { end_km: true },
});
if (maxTrip) {
await prisma.vehicles.update({
where: { id: vehicleId },
data: { actual_km: Number(maxTrip.end_km) },
});
}
await logAudit({ await logAudit({
request, request,
@@ -312,6 +402,19 @@ export default async function tripsRoutes(
} }
const data: Record<string, unknown> = {}; 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) if (body.trip_date !== undefined)
data.trip_date = new Date(String(body.trip_date)); data.trip_date = new Date(String(body.trip_date));
if (body.start_km !== undefined) data.start_km = Number(body.start_km); 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 }); await prisma.trips.update({ where: { id }, data });
// Update vehicle actual_km if end_km changed // Recompute odometers when the km reading or the vehicle changed: the
if (body.end_km !== undefined) { // trip's (possibly new) vehicle, and — when the trip moved between
const vehicleId = existing.vehicle_id; // vehicles — the old one too, which may have lost its MAX(end_km) row.
const maxTrip = await prisma.trips.findFirst({ const newVehicleId =
where: { vehicle_id: vehicleId }, body.vehicle_id !== undefined
orderBy: { end_km: "desc" }, ? Number(body.vehicle_id)
select: { end_km: true }, : existing.vehicle_id;
}); const vehicleChanged = newVehicleId !== existing.vehicle_id;
if (maxTrip) { if (body.end_km !== undefined || vehicleChanged) {
await prisma.vehicles.update({ await recomputeVehicleActualKm(newVehicleId);
where: { id: vehicleId }, }
data: { actual_km: Number(maxTrip.end_km) }, if (vehicleChanged) {
}); await recomputeVehicleActualKm(existing.vehicle_id);
}
} }
await logAudit({ await logAudit({
@@ -348,6 +450,8 @@ export default async function tripsRoutes(
entityType: "trip", entityType: "trip",
entityId: id, entityId: id,
description: `Upravena jízda`, description: `Upravena jízda`,
oldValues: existing,
newValues: data,
}); });
return success(reply, { id }, 200, "Záznam byl aktualizován"); return success(reply, { id }, 200, "Záznam byl aktualizován");
}, },
@@ -372,24 +476,9 @@ export default async function tripsRoutes(
const vehicleId = existing.vehicle_id; const vehicleId = existing.vehicle_id;
await prisma.trips.delete({ where: { id } }); await prisma.trips.delete({ where: { id } });
// Recalculate vehicle actual_km after deletion // Recalculate vehicle actual_km after deletion (falls back to
const maxTrip = await prisma.trips.findFirst({ // initial_km when this was the vehicle's last trip).
where: { vehicle_id: vehicleId }, await recomputeVehicleActualKm(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),
},
});
await logAudit({ await logAudit({
request, request,

View File

@@ -5,7 +5,12 @@ import { config } from "../../config/env";
import { requireAuth, requirePermission } from "../../middleware/auth"; import { requireAuth, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit"; import { logAudit } from "../../services/audit";
import { success, error, parseId, paginated } from "../../utils/response"; import { success, error, parseId, paginated } from "../../utils/response";
import { contentDisposition } from "../../utils/content-disposition";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination"; import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import {
encodeCustomFields,
decodeCustomFields,
} from "../../utils/custom-fields";
import { parseBody } from "../../schemas/common"; import { parseBody } from "../../schemas/common";
import { nasInvoicesManager } from "../../services/nas-financials-manager"; import { nasInvoicesManager } from "../../services/nas-financials-manager";
import { import {
@@ -225,7 +230,7 @@ export default async function warehouseRoutes(
// SUPPLIERS // SUPPLIERS
// ============================================================= // =============================================================
// GET /suppliers — paginated list, search by name/ico/contact_person // GET /suppliers — paginated list, search by name/ico/city
fastify.get( fastify.get(
"/suppliers", "/suppliers",
{ preHandler: requirePermission("warehouse.manage") }, { preHandler: requirePermission("warehouse.manage") },
@@ -239,7 +244,7 @@ export default async function warehouseRoutes(
where.OR = [ where.OR = [
{ name: { contains: search } }, { name: { contains: search } },
{ ico: { contains: search } }, { ico: { contains: search } },
{ contact_person: { contains: search } }, { city: { contains: search } },
]; ];
} }
@@ -253,9 +258,16 @@ export default async function warehouseRoutes(
prisma.sklad_suppliers.count({ where }), prisma.sklad_suppliers.count({ where }),
]); ]);
// Decode the custom_fields blob into the array shape the modal edits
// (same model as customers).
const enriched = suppliers.map((s) => ({
...s,
custom_fields: decodeCustomFields(s.custom_fields).custom_fields,
}));
return paginated( return paginated(
reply, reply,
suppliers, enriched,
buildPaginationMeta(total, page, limit), buildPaginationMeta(total, page, limit),
); );
}, },
@@ -274,7 +286,10 @@ export default async function warehouseRoutes(
}); });
if (!supplier) return error(reply, "Dodavatel nenalezen", 404); if (!supplier) return error(reply, "Dodavatel nenalezen", 404);
return success(reply, supplier); return success(reply, {
...supplier,
custom_fields: decodeCustomFields(supplier.custom_fields).custom_fields,
});
}, },
); );
@@ -292,11 +307,12 @@ export default async function warehouseRoutes(
name: body.name, name: body.name,
ico: body.ico ?? null, ico: body.ico ?? null,
dic: body.dic ?? null, dic: body.dic ?? null,
contact_person: body.contact_person ?? null, street: body.street ?? null,
email: body.email ?? null, city: body.city ?? null,
phone: body.phone ?? null, postal_code: body.postal_code ?? null,
address: body.address ?? null, country: body.country ?? null,
notes: body.notes ?? null, // Suppliers have no PDF field-order picker — order is always [].
custom_fields: encodeCustomFields(body.custom_fields, []),
is_active: body.is_active ?? true, is_active: body.is_active ?? true,
}, },
}); });
@@ -311,7 +327,7 @@ export default async function warehouseRoutes(
newValues: { newValues: {
name: supplier.name, name: supplier.name,
ico: supplier.ico, ico: supplier.ico,
contact_person: supplier.contact_person, city: supplier.city,
is_active: supplier.is_active, is_active: supplier.is_active,
}, },
}); });
@@ -341,12 +357,13 @@ export default async function warehouseRoutes(
if (body.name !== undefined) updateData.name = body.name; if (body.name !== undefined) updateData.name = body.name;
if (body.ico !== undefined) updateData.ico = body.ico; if (body.ico !== undefined) updateData.ico = body.ico;
if (body.dic !== undefined) updateData.dic = body.dic; if (body.dic !== undefined) updateData.dic = body.dic;
if (body.contact_person !== undefined) if (body.street !== undefined) updateData.street = body.street;
updateData.contact_person = body.contact_person; if (body.city !== undefined) updateData.city = body.city;
if (body.email !== undefined) updateData.email = body.email; if (body.postal_code !== undefined)
if (body.phone !== undefined) updateData.phone = body.phone; updateData.postal_code = body.postal_code;
if (body.address !== undefined) updateData.address = body.address; if (body.country !== undefined) updateData.country = body.country;
if (body.notes !== undefined) updateData.notes = body.notes; if (body.custom_fields !== undefined)
updateData.custom_fields = encodeCustomFields(body.custom_fields, []);
if (body.is_active !== undefined) updateData.is_active = body.is_active; if (body.is_active !== undefined) updateData.is_active = body.is_active;
const updated = await prisma.sklad_suppliers.update({ const updated = await prisma.sklad_suppliers.update({
@@ -364,13 +381,13 @@ export default async function warehouseRoutes(
oldValues: { oldValues: {
name: existing.name, name: existing.name,
ico: existing.ico, ico: existing.ico,
contact_person: existing.contact_person, city: existing.city,
is_active: existing.is_active, is_active: existing.is_active,
}, },
newValues: { newValues: {
name: updated.name, name: updated.name,
ico: updated.ico, ico: updated.ico,
contact_person: updated.contact_person, city: updated.city,
is_active: updated.is_active, is_active: updated.is_active,
}, },
}); });
@@ -1294,6 +1311,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 // POST /receipts/:id/attachments — upload attachment
fastify.post<{ Params: { id: string } }>( fastify.post<{ Params: { id: string } }>(
"/receipts/:id/attachments", "/receipts/:id/attachments",

View File

@@ -1,6 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { import {
intIdFromForm, intIdFromForm,
isoDateString,
nullableDateTimeString,
nullableIntIdFromForm, nullableIntIdFromForm,
numberFromForm, numberFromForm,
numberInRange, numberInRange,
@@ -66,19 +68,28 @@ export const AttendancePunchSchema = z.object({
address: z.string().max(500).nullish(), 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({ export const CreateAttendanceSchema = z.object({
user_id: intIdFromForm.optional(), user_id: intIdFromForm.optional(),
shift_date: z.string(), // Must be a bare "YYYY-MM-DD" (parses to UTC midnight) — the service's
arrival_time: timeString.nullish(), // 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_lat: numberFromForm.nullish(),
arrival_lng: numberFromForm.nullish(), arrival_lng: numberFromForm.nullish(),
arrival_accuracy: numberFromForm.nullish(), arrival_accuracy: numberFromForm.nullish(),
arrival_address: z.string().nullish(), arrival_address: z.string().nullish(),
departure_time: timeString.nullish(), departure_time: nullableDateTimeString.optional(),
departure_lat: numberFromForm.nullish(), departure_lat: numberFromForm.nullish(),
departure_lng: numberFromForm.nullish(), departure_lng: numberFromForm.nullish(),
departure_accuracy: numberFromForm.nullish(), departure_accuracy: numberFromForm.nullish(),
departure_address: z.string().nullish(), departure_address: z.string().nullish(),
break_start: nullableDateTimeString.optional(),
break_end: nullableDateTimeString.optional(),
notes: z.string().nullish(), notes: z.string().nullish(),
project_id: nullableIntIdFromForm.nullish(), project_id: nullableIntIdFromForm.nullish(),
leave_type: z leave_type: z
@@ -90,10 +101,10 @@ export const CreateAttendanceSchema = z.object({
}); });
export const UpdateAttendanceSchema = z.object({ export const UpdateAttendanceSchema = z.object({
arrival_time: timeString.nullish(), arrival_time: nullableDateTimeString.optional(),
departure_time: timeString.nullish(), departure_time: nullableDateTimeString.optional(),
break_start: timeString.nullish(), break_start: nullableDateTimeString.optional(),
break_end: timeString.nullish(), break_end: nullableDateTimeString.optional(),
notes: z.string().nullish(), notes: z.string().nullish(),
project_id: nullableIntIdFromForm.optional(), project_id: nullableIntIdFromForm.optional(),
leave_type: z.enum(["work", "vacation", "sick", "unpaid"]).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 // 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. // 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ý"), .max(64, "Záložní kód je příliš dlouhý"),
remember_me: z.boolean().optional().default(false),
}); });
export const TotpEnableSchema = z.object({ export const TotpEnableSchema = z.object({

View File

@@ -61,6 +61,14 @@ export const positiveNumberFromForm = z
message: "Číslo musí být kladné", 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. */ /** number|string → positive integer id; rejects NaN/≤0/non-integer. */
export const intIdFromForm = z export const intIdFromForm = z
.union([z.number(), z.string()]) .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"), .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 * An email field that also accepts an empty string (meaning "not set"). Many
* settings/supplier forms submit "" for un-filled optional emails; a bare * settings/supplier forms submit "" for un-filled optional emails; a bare
@@ -120,3 +162,17 @@ export const emailOrEmpty = z.union([
z.literal(""), z.literal(""),
z.string().max(255).email("Neplatný email"), 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

@@ -6,6 +6,7 @@ import {
positiveNumberFromForm, positiveNumberFromForm,
nullableIntIdFromForm, nullableIntIdFromForm,
booleanFromForm, booleanFromForm,
DocumentSectionSchema,
} from "./common"; } from "./common";
const InvoiceItemSchema = z.object({ const InvoiceItemSchema = z.object({
@@ -38,9 +39,14 @@ export const CreateInvoiceSchema = z.object({
issued_by: z.string().max(255).nullish(), issued_by: z.string().max(255).nullish(),
billing_text: z.string().max(8000).nullish(), billing_text: z.string().max(8000).nullish(),
language: z.string().max(20).optional().default("cs"), language: z.string().max(20).optional().default("cs"),
notes: z.string().max(8000).nullish(), // `notes` was dropped (printed-notes block removed — free-form PDF content
// lives in the sections); legacy clients still sending it are silently
// stripped by z.object. internal_notes is never printed.
internal_notes: z.string().max(8000).nullish(), internal_notes: z.string().max(8000).nullish(),
items: z.array(InvoiceItemSchema).optional(), items: z.array(InvoiceItemSchema).optional(),
// Rich-text CZ/EN "Obsah" sections rendered after the items on the PDF
// (mirrors issued orders — shared DocumentSectionSchema, DB-aligned limits).
sections: z.array(DocumentSectionSchema).optional(),
}); });
export const UpdateInvoiceSchema = z.object({ export const UpdateInvoiceSchema = z.object({
@@ -54,16 +60,17 @@ export const UpdateInvoiceSchema = z.object({
bank_iban: z.string().max(255).nullish(), bank_iban: z.string().max(255).nullish(),
bank_account: z.string().max(255).nullish(), bank_account: z.string().max(255).nullish(),
issued_by: z.string().max(255).nullish(), issued_by: z.string().max(255).nullish(),
billing_text: z.string().max(8000).nullish(),
customer_id: nullableIntIdFromForm.optional(), customer_id: nullableIntIdFromForm.optional(),
vat_rate: numberInRange(0, 100).optional(), vat_rate: numberInRange(0, 100).optional(),
apply_vat: booleanFromForm.optional(), apply_vat: booleanFromForm.optional(),
issue_date: z.union([z.string().max(255), z.null()]).optional(), issue_date: z.union([z.string().max(255), z.null()]).optional(),
due_date: z.union([z.string().max(255), z.null()]).optional(), due_date: z.union([z.string().max(255), z.null()]).optional(),
tax_date: z.union([z.string().max(255), z.null()]).optional(), tax_date: z.union([z.string().max(255), z.null()]).optional(),
notes: z.string().max(8000).nullish(),
internal_notes: z.string().max(8000).nullish(), internal_notes: z.string().max(8000).nullish(),
paid_date: z.union([z.string().max(255), z.null()]).optional(), paid_date: z.union([z.string().max(255), z.null()]).optional(),
items: z.array(InvoiceItemSchema).optional(), items: z.array(InvoiceItemSchema).optional(),
sections: z.array(DocumentSectionSchema).optional(),
}); });
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>; export type CreateInvoiceInput = z.infer<typeof CreateInvoiceSchema>;

View File

@@ -1,11 +1,10 @@
import { z } from "zod"; import { z } from "zod";
import { import {
numberInRange,
nonNegativeNumberFromForm, nonNegativeNumberFromForm,
positiveNumberFromForm, positiveNumberFromForm,
nullableIntIdFromForm, nullableIntIdFromForm,
booleanFromForm,
isoDateString, isoDateString,
DocumentSectionSchema,
} from "./common"; } from "./common";
export const IssuedOrderItemSchema = z.object({ export const IssuedOrderItemSchema = z.object({
@@ -14,7 +13,6 @@ export const IssuedOrderItemSchema = z.object({
quantity: positiveNumberFromForm.optional(), quantity: positiveNumberFromForm.optional(),
unit: z.string().max(20).nullish(), unit: z.string().max(20).nullish(),
unit_price: nonNegativeNumberFromForm.optional(), unit_price: nonNegativeNumberFromForm.optional(),
vat_rate: numberInRange(0, 100).optional(),
position: z.number().int().nonnegative().optional(), position: z.number().int().nonnegative().optional(),
}); });
@@ -28,21 +26,26 @@ const ISSUED_ORDER_STATUSES = [
export const CreateIssuedOrderSchema = z.object({ export const CreateIssuedOrderSchema = z.object({
po_number: z.string().max(50).nullish(), po_number: z.string().max(50).nullish(),
customer_id: nullableIntIdFromForm.nullish(), supplier_id: nullableIntIdFromForm.nullish(),
status: z.enum(ISSUED_ORDER_STATUSES).optional(), status: z.enum(ISSUED_ORDER_STATUSES).optional(),
currency: z.string().max(10).optional(), currency: z.string().max(10).optional(),
vat_rate: numberInRange(0, 100).optional(),
apply_vat: booleanFromForm.optional(),
exchange_rate: nonNegativeNumberFromForm.optional(), exchange_rate: nonNegativeNumberFromForm.optional(),
order_date: isoDateString.nullish(), order_date: isoDateString.nullish(),
delivery_date: isoDateString.nullish(), delivery_date: isoDateString.nullish(),
language: z.string().max(5).optional(), language: z.string().max(5).optional(),
delivery_terms: z.string().max(500).nullish(), // Editable heading above the PDF items table; empty/null falls back to the
payment_terms: z.string().max(500).nullish(), // default "Objednáváme si u Vás:" (issued-orders-pdf t.billing).
issued_by: z.string().max(255).nullish(), order_text: z.string().max(500).nullish(),
notes: z.string().nullish(),
internal_notes: z.string().nullish(), internal_notes: z.string().nullish(),
items: z.array(IssuedOrderItemSchema).optional(), 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 { z } from "zod";
import { import {
numberFromForm, nonNegativeIntFromForm,
numberInRange,
nonNegativeNumberFromForm, nonNegativeNumberFromForm,
positiveNumberFromForm, positiveNumberFromForm,
nullableIntIdFromForm, nullableIntIdFromForm,
booleanFromForm, booleanFromForm,
isoDateString,
DocumentSectionSchema,
} from "./common"; } 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({ const QuotationItemSchema = z.object({
description: z.string().max(8000).nullish(), description: z.string().max(500).nullish(),
item_description: z.string().max(8000).nullish(), item_description: z.string().max(8000).nullish(),
quantity: positiveNumberFromForm.default(1), quantity: positiveNumberFromForm.default(1),
unit: z.string().max(255).nullish(), unit: z.string().max(20).nullish(),
unit_price: numberFromForm.default(0), unit_price: nonNegativeNumberFromForm.default(0),
is_included_in_total: booleanFromForm.optional().default(true), 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({ // Shared with issued orders (scope_sections ≡ issued_order_sections).
title: z.string().max(255).nullish(), const ScopeSectionSchema = DocumentSectionSchema;
title_cz: z.string().max(255).nullish(),
content: z.string().max(8000).nullish(),
position: nonNegativeNumberFromForm.optional(),
});
// 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({ export const CreateQuotationSchema = z.object({
quotation_number: z.string().max(255).nullish(), // DB: quotations.quotation_number VarChar(50).
project_code: z.string().max(255).nullish(), 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(), customer_id: nullableIntIdFromForm.nullish(),
created_at: z.string().max(255).nullish(), created_at: isoDateString.nullish(),
valid_until: z.string().max(255).nullish(), valid_until: isoDateString.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(),
currency: z.string().max(20).optional(), currency: z.string().max(20).optional(),
language: 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(), status: z.enum(["draft", "active", "ordered", "invalidated"]).optional(),
scope_title: z.string().max(255).nullish(), scope_title: z.string().max(255).nullish(),
scope_description: z.string().max(8000).nullish(), scope_description: z.string().max(8000).nullish(),
@@ -61,5 +47,12 @@ export const UpdateQuotationSchema = z.object({
sections: z.array(ScopeSectionSchema).optional(), 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 CreateQuotationInput = z.infer<typeof CreateQuotationSchema>;
export type UpdateQuotationInput = z.infer<typeof UpdateQuotationSchema>; export type UpdateQuotationInput = z.infer<typeof UpdateQuotationSchema>;

View File

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

View File

@@ -21,6 +21,8 @@ export const CreateTripSchema = z.object({
}); });
export const UpdateTripSchema = 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(), trip_date: isoDateString.optional(),
start_km: nonNegativeNumberFromForm.optional(), start_km: nonNegativeNumberFromForm.optional(),
end_km: nonNegativeNumberFromForm.optional(), end_km: nonNegativeNumberFromForm.optional(),

View File

@@ -8,7 +8,6 @@ import {
nullableIntIdFromForm, nullableIntIdFromForm,
booleanFromForm, booleanFromForm,
isoDateString, isoDateString,
emailOrEmpty,
} from "./common"; } from "./common";
// === Categories === // === Categories ===
@@ -26,26 +25,30 @@ export type CreateCategoryInput = z.infer<typeof CreateCategorySchema>;
export type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>; export type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>;
// === Suppliers === // === Suppliers ===
// Full mirror of the customers model: structured address + custom_fields
// (the dedicated contact_person/email/phone/notes columns and the free-text
// `address` blob were dropped; legacy clients sending them are silently
// stripped by z.object). Limits are DB-aligned.
export const CreateSupplierSchema = z.object({ export const CreateSupplierSchema = z.object({
name: z.string().min(1, "Název je povinný").max(255), name: z.string().min(1, "Název je povinný").max(255),
ico: z.string().max(255).nullish(), ico: z.string().max(255).nullish(),
dic: z.string().max(255).nullish(), dic: z.string().max(255).nullish(),
contact_person: z.string().max(255).nullish(), street: z.string().max(255).nullish(),
email: emailOrEmpty.nullish(), city: z.string().max(255).nullish(),
phone: z.string().max(50).nullish(), postal_code: z.string().max(20).nullish(),
address: z.string().max(5000).nullish(), country: z.string().max(100).nullish(),
notes: z.string().max(5000).nullish(), custom_fields: z.array(z.unknown()).max(100).optional(),
is_active: booleanFromForm.optional().default(true), is_active: booleanFromForm.optional().default(true),
}); });
export const UpdateSupplierSchema = z.object({ export const UpdateSupplierSchema = z.object({
name: z.string().min(1, "Název je povinný").max(255).optional(), name: z.string().min(1, "Název je povinný").max(255).optional(),
ico: z.string().max(255).nullish(), ico: z.string().max(255).nullish(),
dic: z.string().max(255).nullish(), dic: z.string().max(255).nullish(),
contact_person: z.string().max(255).nullish(), street: z.string().max(255).nullish(),
email: emailOrEmpty.nullish(), city: z.string().max(255).nullish(),
phone: z.string().max(50).nullish(), postal_code: z.string().max(20).nullish(),
address: z.string().max(5000).nullish(), country: z.string().max(100).nullish(),
notes: z.string().max(5000).nullish(), custom_fields: z.array(z.unknown()).max(100).optional(),
is_active: booleanFromForm.optional(), is_active: booleanFromForm.optional(),
}); });
export type CreateSupplierInput = z.infer<typeof CreateSupplierSchema>; export type CreateSupplierInput = z.infer<typeof CreateSupplierSchema>;

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