Compare commits

..

117 Commits

Author SHA1 Message Date
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
BOHA
d08e55a41a chore(release): v2.2.0 — issued orders, DB drafts, project numbering, NAS split, per-currency totals, perms cleanup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:40:12 +02:00
BOHA
97101c4db7 feat(offers,invoices): per-currency 'Celkem' totals beneath the lists (consistent with orders)
Mirrors the orders totals exactly: /stats (offers) + /list-totals (issued +
received invoices) endpoints sum each doc's total per currency across the active
filter (reusing extracted where-builders so they stay in sync with the lists),
rendered as the identical 'Celkem: …' block via the shared formatMultiCurrency.
Existing invoice/received KPI stats untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:12:24 +02:00
BOHA
1e4dd1fbcc revert(orders-pdf): remove the E-mail line from the issued-order footer
Keeps the 'Vystavil: <name>' line (logged-in user) and the removed Schválil
signature row; drops the e-mail line + its param field/translation keys per
request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:56:54 +02:00
BOHA
5462fcf944 feat(orders): PDF footer = issuer name+email, 'Zobrazit objednávku' button, per-currency list totals
- Issued-order PDF: drop the Vystavil/Schválil signature row; footer now shows
  'Vystavil: <name>' + 'E-mail: <email>' from the logged-in user (authData).
- IssuedOrderDetail PDF button 'Export PDF' -> 'Zobrazit objednávku' (already
  opens inline like invoices' 'Zobrazit fakturu').
- New /orders/stats + /issued-orders/stats endpoints sum order total (incl VAT)
  per currency across the active filter; rendered as 'Celkem: …' beneath both
  the received and issued lists. formatMultiCurrency lifted to a shared util.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:50:05 +02:00
BOHA
78cd7cf2d8 fix(shell): use 100dvh instead of 100vh so mobile has no phantom scroll
AppShell sized the shell with minHeight:100vh while #root (GlobalStyles) and the
Odin pane (OdinChat) use 100dvh. On mobile 100vh = the large viewport (address
bar hidden), a chrome-bar taller than the visible 100dvh, so the shell floor
pushed the document a few px over and html{overflow-x:hidden} (the page scroller)
turned it into a small downward scroll. Most visible on /odin (a fixed 100dvh
pane that otherwise never scrolls the page). Fixes every page on mobile; desktop
unchanged (100vh==100dvh there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:27:10 +02:00
BOHA
cb82349c86 refactor(orders): rename OrdersReceived to ReceivedOrders; add warehouse module label
Renames the received-orders page for naming consistency, updates the Orders tab
import and the issued-order create button label, and adds the Sklad module
label to role management.
2026-06-09 21:20:50 +02:00
BOHA
d445384408 feat(perms): drop dead document export permissions; seed the 12 survivors via migration
offers.export/orders.export/invoices.export gated nothing on the backend (PDF
routes enforce *.view); they only toggled frontend buttons and showed dead
role checkboxes. Migration deletes them (+ role grants) and idempotently seeds
the 12 enforced doc perms (offers/orders/invoices x view/create/edit/delete) +
admin grant, so a fresh prod DB has them (closes the seed-only drift gap).
Route reverts to orders.view; the 8 frontend *.export checks now use *.view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:19:38 +02:00
BOHA
3268cf2100 fix(nas): order PDF archive uses orders.view (parity w/ invoices) + document NAS_INVOICES/NAS_ORDERS
Review follow-ups: the ?save=1 archive route required orders.export while the
save that triggers it only needs orders.edit, so an editor's fire-and-forget
archive 403'd silently. Use requireAnyPermission(orders.view, orders.export) to
match invoices-pdf (invoices.view). Also refresh .env.example for the split vars.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:59:06 +02:00
BOHA
841ab676ec feat(nas): archive issued-order PDFs to NAS_ORDERS; split NAS_FINANCIALS_PATH into NAS_INVOICES + NAS_ORDERS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:52:06 +02:00
BOHA
313d9218c1 fix(offers,invoices): per-button save loading state (only the clicked button spins)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:41:25 +02:00
BOHA
c270cb22e9 fix(issued-orders): correct status display after create-redirect + per-button loading state
Bug 1: creating an issued order as 'sent' redirected to the detail which showed
'Koncept' until a refresh — the component is reused across create→edit, so the
one-shot hydration (gated by dataReady) never reloaded the real status. Now the
just-saved status is reflected into local form state on every save with a target.

Bug 2: both edit-draft buttons shared one 'saving' flag, so pressing 'Uložit
koncept' made 'Odeslat' show 'Ukládání...'. Track which action is in flight so
only the clicked button spins (all stay disabled to block double-submit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:36:55 +02:00
BOHA
113edd9a0d fix(numbering): release project number on order delete + harden split tests + accurate settings preview
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:44:54 +02:00
BOHA
753c16423c fix(settings): bank accounts saved blank — flatten payload + harden schema to strictObject
The bank form posted {id, bank:{...}} (nested) but the route/schema read fields
at the top level; lenient z.object silently stripped the wrapper, so every field
saved as null while returning success. Flatten the payload to top level and switch
the create/update schemas to strictObject so a future nesting 400s loudly instead
of saving a blank record. Affects both create and edit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:39:43 +02:00
BOHA
11b71501ee feat(numbering): add project_number_pattern + project_type_code to company_settings
Migration backing the dedicated project number sequence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:32:20 +02:00
BOHA
5c03570f07 feat(settings): separate numbering config for received orders, issued orders, and projects
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:31:36 +02:00
BOHA
ed9ea52a44 feat(numbering): projects get their own dedicated number sequence (decoupled from orders)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:24:51 +02:00
BOHA
9e9aacdf0b fix(drafts): exclude drafts from VAT/revenue/created aggregations + koncept audit labels
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:04:10 +02:00
BOHA
52ff0a6ffa feat(drafts): show Koncept for null numbers in lists + fix filtered empty-state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:52:34 +02:00
BOHA
a15fa68c3b fix(drafts): resync invoice number field after in-place draft→issued finalize
Mirrors the issued-order po_number resync so all three detail pages
surface the freshly-assigned number after finalize without a reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:50:18 +02:00
BOHA
e86c2e9a80 feat(drafts): two-button create (Vytvořit / Uložit koncept) + draft finalize UX across offers/invoices/issued-orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:44:10 +02:00
BOHA
ce636215dc feat(drafts): remove localStorage draft banner/hook; add Koncept status + filter (drafts are DB rows)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:25:24 +02:00
BOHA
473f7c4a8c feat(drafts): DB drafts with deferred numbering — create-as-draft (no number), assign number on finalize (offers/invoices/issued-orders)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:04:11 +02:00
BOHA
28186397a0 fix(ui): line-item number inputs — allow clearing the 0 (store raw string, coerce at totals/save), unify value alignment, widen Množství column
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:38:28 +02:00
BOHA
a3948c7da1 fix(ui): hide invoice list delete button for paid invoices (consistent with detail guard)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:12:44 +02:00
BOHA
53c8359acc feat(ui): unify VAT rate+toggle layout (offers/invoices/orders); move offers secondary row actions into an overflow menu
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:04:44 +02:00
BOHA
c2d7a96718 feat(ui): invoice notes use RichEditor (sanitized render + PDF), matching offers/orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:59:43 +02:00
BOHA
61673247bf feat(ui): list parity — row tinting, received-orders status filter, more sortable cols, search-aware empties, skeleton suppression
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:55:50 +02:00
BOHA
9fe5113c5b fix(ui): cross-module mechanical unifications (line-item labels/align, back-tab targets, status-button colors, apply_vat checkbox, currency-from-settings, read-only issued_by, Celkem header, centered offer tabs)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:49:31 +02:00
BOHA
e868ecf769 refactor(ui): shared searchable CustomerPicker across offers/invoices/issued-orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:42:24 +02:00
BOHA
20177e8f87 fix(ui): guard delete on paid invoices / ordered-or-locked offers; add guarded delete to issued orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:36:32 +02:00
BOHA
39565767ef fix(ui): wire invoice draft restore (read-back) so the banner actually restores and autosave can't destroy the draft
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:32:20 +02:00
BOHA
c4668357aa feat(ui): shared record-scoped useDocumentDraft hook; fix half-wired invoice draft banner; dedupe draft keys
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:24:22 +02:00
BOHA
b4f1b6ce2b refactor(ui): OrderDetail uses shared ORDER_STATUS (removes the last duplicate status map)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:17:52 +02:00
BOHA
66235ff567 refactor(ui): single shared documentStatus map across offers/invoices/orders (fix issued color drift, add offers status chip)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:12:44 +02:00
BOHA
d341db2e51 docs: cross-module UI + state/draft consistency audit (Offers/Invoices/Orders)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:05:00 +02:00
BOHA
430ec3e76b fix(ui): make item description a drag-resizable textarea (rows + resize:vertical) across invoice/order/offer
Replaces MUI autosize (no handle, mis-wraps in table cells) with a fixed 2-row textarea + resize:vertical so the box wraps and the user can drag it taller. Restores the dead '& input' sx as '& textarea'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:37:34 +02:00
BOHA
f295af4a14 fix(ui): auto-grow item detail-description box + offer money columns to 8rem (consistency across invoice/order/offer)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:28:17 +02:00
BOHA
db11999c85 fix(ui): widen 'Jedn. cena' column in invoice + issued-order line-item tables (5.5rem -> 8rem)
The editable unit-price number input was cramped (5.5rem, same as qty/unit); widened to 8rem to match the Celkem column and the read-only detail view, so real prices fit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:23:16 +02:00
BOHA
afef0e6759 docs(orders): sync spec/plan/CLAUDE.md to as-built (Vydané-first tabs, month filter, shared PDF template, Prisma 7 / React 19)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:50:41 +02:00
BOHA
62f50d31af Merge feat/issued-orders: issued purchase orders (objednávky vydané)
New issued-purchase-order document type (schema, numbering, service, routes, PDF, frontend Vydané tab) plus the orders/invoices consistency pass (Vydané-first tabs, shared month filter, shared red-accent PDF template). Dependency upgrades (React 19, Prisma 7, file-type 22) were landed separately on master first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:41:46 +02:00
BOHA
3d3df6db85 feat(orders): invoices-style landing — month selector + Vydane-first tabs, content-only tab bodies
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:33:36 +02:00
BOHA
162f9b9fdf feat(orders): issued-order PDF on the shared invoice/confirmation template
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:27:12 +02:00
BOHA
3b48bd0523 feat(orders): month/year filter on both order lists + fix received-orders search
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:22:39 +02:00
BOHA
2b7b480144 fix(orders): back button links to Vydané tab, not the nonexistent /orders/issued
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:53:23 +02:00
BOHA
e26fce092a fix(orders): authenticated PDF open in issued-orders list
The list opened the PDF route via raw window.open, an unauthenticated top-level navigation that 401s on the token-guarded endpoint. Now uses the apiFetch -> blob -> window pattern from InvoiceDetail/IssuedOrderDetail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:46:50 +02:00
BOHA
d7e0ba933d feat(orders): issued-order create/edit form + PDF export button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:44:02 +02:00
BOHA
8b90cfed1f feat(orders): Orders page Přijaté|Vydané tabs + issued list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:37:03 +02:00
BOHA
8476ffebd6 feat(orders): issued-order query options + types
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:32:51 +02:00
BOHA
63ccf8fda7 feat(orders): issued-order PDF export route
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:31:05 +02:00
BOHA
9f4b8a897a feat(orders): issued-orders CRUD routes + audit entity type
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:26:32 +02:00
BOHA
2a3df15565 fix(orders): release PO number against creation year; tighten sort-dir + enum typing
Addresses review of the issued-orders service: deleteIssuedOrder now derives the release year from created_at (cross-year deletes reclaim the right sequence); list sort direction is normalized to asc/desc; status cast uses $Enums instead of as-never. Adds a cross-year delete test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:23:45 +02:00
BOHA
b1f48df30c feat(orders): issued-orders service (CRUD + totals + transitions)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:14:48 +02:00
BOHA
f00fcf95fd feat(orders): issued-order Zod schemas
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:09:50 +02:00
BOHA
4093664936 feat(orders): issued-order number sequence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:04:48 +02:00
BOHA
ecb83d4654 feat(orders): issued_orders schema + migration
Pre-existing quotations.project_code + ai_chat_messages FK drift is captured in its own reconcile_quotations_aichat_fk migration; the issued_orders migration is purely additive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:52:42 +02:00
BOHA
a06ea2e1cd docs(orders): plan fix — no manual migration SQL edits (code-level numbering defaults)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:37:40 +02:00
BOHA
15a2da38cc docs(orders): implementation plan for issued purchase orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:25:36 +02:00
BOHA
4072197f4a docs(orders): design spec for issued purchase orders (objednávky vydané)
Brainstormed design for a new 'objednávky vydané' document type — purchase
orders you author with line items and export to PDF, alongside the existing
customer orders (which become the 'přijaté' tab).

Key decisions: dedicated issued_orders/issued_order_items models mirroring
invoices; customers reused as supplier/partner; NET+VAT-on-top; dedicated
numbering sequence; reuse of orders.* permissions; standalone v1 (no
warehouse/received-invoice links).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:35:02 +02:00
BOHA
1914fddb4d chore(deps): upgrade file-type 16.5.4 -> 22.0.1 (clears the ASF-parser DoS)
file-type 16 -> 22 fixes GHSA-5v7r-6r5c-r473 (infinite loop in the ASF parser on
malformed input). v22 is ESM-only with a strict `exports` map, so the CommonJS
server can no longer `require()` it (and tsc would down-level a normal dynamic
import() back to require()). Converted the single call site in
nas-file-manager.ts to a cached runtime dynamic ESM import via the
Function('return import("file-type")') indirection (the same pattern server.ts
already uses for Vite), and updated the API to the v22 named export
`fileTypeFromBuffer` (was the v16 default `FileType.fromBuffer`).

Verified at runtime that the dynamic import loads v22 from CJS and detects
PNG/JPEG/PDF correctly. `npm audit` no longer lists file-type (6 -> 5 vulns; the
rest are quill [Phase 4] and Prisma 7's dev-tooling @hono/node-server).

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:12:23 +02:00
BOHA
63192dc781 chore(deps): upgrade Prisma 6.19 -> 7.8.0 (Rust-free client + mariadb adapter)
prisma / @prisma/client 6.19.2 -> 7.8.0; added @prisma/adapter-mariadb 7.8.0 and
mariadb 3.5.2 (the runtime driver).

Prisma 7 breaking changes handled (verified via Prisma's own tooling — the
official upgrade-guide summary had hallucinated package names, so I confirmed
everything against `prisma validate`/`generate` and the installed type defs):

- The Rust query engine is removed; the runtime connection is now made through a
  driver adapter. src/config/database.ts wires PrismaMariaDb, parsing
  DATABASE_URL into an explicit mariadb pool config so the literal `@` in our
  password (which a naive connection-string parser mishandles) is correct.
- datasource `url` is no longer allowed in schema.prisma -> moved to a new
  prisma.config.ts (schema + datasource.url + migrations.seed). Prisma 7 no
  longer auto-loads .env, so the config does `import "dotenv/config"`.
- The deprecated package.json#prisma seed config was removed (now in the config).
- seed.ts and the two maintenance scripts now use the shared adapter-wired client.

The legacy `prisma-client-js` generator STILL works under v7 (generates to
@prisma/client as before), so NO import-path changes were needed across the 16
@prisma/client import sites — switching to the new prisma-client/output generator
is not required for us. No prisma.$use() middleware exists. Node 24 / TS 5.9
exceed the v7 minimums (20.19 / 5.4).

No migrations were run (your DBs are untouched); removing datasource.url is a
config change, not DDL, so this phase needs no new migration.

Gates: tsc 0 | build 0 | vitest 247/247 against app_test (proves the mariadb
adapter works at runtime, incl. raw SELECT...FOR UPDATE locking and Decimal/VAT)
| eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:06:02 +02:00
BOHA
6147131aad chore(deps): upgrade React 18.3 -> 19.2.7
react / react-dom 18.3.1 -> 19.2.7; @types/react 18.3.31 -> 19.2.17;
@types/react-dom 18.3.7 -> 19.2.3 (latest stable 19.2.x).

Only breaking change hitting our code: React 19 removed the no-arg useRef<T>()
overload. AppShell's logoutTimer -> useRef<T | undefined>(undefined) — the same
transform the official `types-react-codemod preset-19` (useRef-required-initial)
applies. No other preset-19 transforms were relevant (already on createRoot; no
ReactDOM.render, string refs, propTypes/defaultProps on function components).

No Next.js / RSC in this project (it's a Vite SPA served by Fastify), so those
React 19 concerns don't apply here. MUI v7, framer-motion 12, TanStack Query 5,
react-router-dom 6 and react-quill-new all installed cleanly against React 19
(no peer-dependency conflicts).

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:51:43 +02:00
BOHA
b4f859ea6e docs: codify audit rules in CLAUDE.md + README + audit records
CLAUDE.md: new "2026-06-09 audit" enforced-rules section (mandatory coercion
helpers, deterministic id-tiebreak ordering, $queryRaw FOR-UPDATE locking
discipline, uniqueness-in-transaction, guard reads, Rules-of-Hooks, seed prod
guard), lint/typecheck/format commands, and the app_test/.env.test testing
section with the hard-throw guard.

README rewritten from the placeholder stub (setup/run/build/test/gates/migrations).

REVIEW.md / REVIEW_FINDINGS.md / REVIEW_FIXES.md: the full audit record — 277
files reviewed, ~362 findings, every one fixed and verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:37 +02:00
BOHA
519edce373 fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:26 +02:00
BOHA
c454d1a3fc feat(db): audit schema hardening (onDelete, unique doc-numbers, indexes)
From the 2026-06-09 full-codebase audit. Warehouse line->header relations
CASCADE on delete; line->master-data and the financial FKs are explicit
RESTRICT; UNIQUE on receipt/issue/session numbers; new indexes on hot FK
columns (sklad_issues/receipts, bank_accounts); dropped a duplicate
audit_logs created_at index. Applied to dev `app` and `app_test`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:04 +02:00
BOHA
b1f21a1f48 docs(claude): document Odin AI assistant + correct stale counts
- Add "AI Assistant — Odin" section (v2.1.5): scope (invoice-only guard),
  SDK/model, backend/data/frontend layout, gross-VAT received-invoices,
  Phase 2 pointer.
- Add AI row to the tech stack.
- Correct counts: 49→52 Prisma models, ~14→17 test files / 152→195 tests,
  ~105→114 admin .tsx; note odin/ components.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:36:58 +02:00
BOHA
b6cd5046d5 chore(release): v2.1.5 — Odin multi-conversation chat (sidebar, invoice import)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:28:43 +02:00
BOHA
25c8c39ba6 feat(odin): scope to invoice processing only (guard general chat)
Odin currently advertises general Q&A via the /chat path, which burns API
credits on open-ended questions. Guard it: a text-only message makes NO AI
call — it gets a canned reply telling the user to attach an invoice. The
invoice path (attachments → extract) is unchanged. Removing the guard
re-enables the general /chat path for a later phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:26:54 +02:00
BOHA
4b2770a000 fix(odin): show conversation auto-title immediately (no refresh needed)
ensureActive() created the conversation and invalidated the list before the
server-side auto-title was applied (that happens on the message append), so
the sidebar showed "Nová konverzace" until a refresh. Move the
["ai","conversations"] invalidation to persist()'s success path — after the
append (and thus the auto-title) lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:22:31 +02:00
BOHA
434a12b4a8 feat(odin): mobile composer + responsive sidebar + message-entrance animation
- Composer rebuilt claude-style: a rounded pill with a multiline auto-growing
  textarea and attach/send ICON buttons (vertically centered), so there's room
  to type on mobile (was squeezed between two text buttons).
- Conversation list collapses into a slide-in Drawer below md, with a menu
  button in the chat header; chat is full-width on mobile.
- Each new message bubble animates in (fade + slide-up; fade-only under
  prefers-reduced-motion) via framer-motion, so sent messages and Odin's
  replies appear smoothly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:19:54 +02:00
BOHA
28fb399217 feat(odin): refine hero — Newsreader serif, red name, staggered entrance
- Swap Fraunces → Newsreader (literary serif, full Czech, optical sizing).
- Split the greeting so the user's first name renders in brand-red italic.
- Add a subtle staggered fade-in (framer-motion), skipped under
  prefers-reduced-motion.
- New description that names Odin and covers chat + invoice import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:03:16 +02:00
BOHA
c0ff7b4549 feat(odin): editorial serif hero — Fraunces greeting + italic subtitle
Drop the gradient "Odin" wordmark from the new-conversation hero and make the
personalized greeting the centerpiece in Fraunces (optical-sized serif), with
an italic Fraunces description — a refined, literary, claude-like treatment.
Loads Fraunces (upright + italic) via the existing Google Fonts link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:57:15 +02:00
BOHA
1e21c12f33 feat(odin): personalized greeting hero on the new-conversation screen
Time-based Czech greeting + the user's first name (e.g. "Dobrý večer, Admin")
above the big gradient "Odin" wordmark, with a stylish description line. Drop
the redundant mark from the hero (it lives on the nav item + thinking state).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:51:00 +02:00
BOHA
ab12f9d791 feat(odin): sidebar brand is a big gradient "Odin" wordmark (drop the icon)
The mark already lives on the nav menu item; the conversation sidebar's brand
no longer duplicates it — just a larger gradient "Odin" wordmark above the new
chat button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:47:39 +02:00
BOHA
cf442b9a99 feat(odin): big claude.ai-style hero on the new-conversation screen
Replace the small empty-state with a large centred hero — the animated Odin
mark + a big gradient "Odin" wordmark (responsive clamp font) and the helper
line beneath.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:44:38 +02:00
BOHA
2e7caea5a6 feat(odin): use the Odin mark as the sidebar nav icon
Replace the chat-bubble glyph on the "Odin" nav item with the OdinMark, and
make the mark's SVG sizing !important so the nav's `& svg { width:18 }` rule
can't shrink it (its `size` prop stays authoritative everywhere).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:41:20 +02:00
BOHA
702a4984ee feat(odin): animated Odin brand mark (AI core orbiting business data)
A self-contained animated SVG mark in BOHA red: a 4-point AI spark (Odin) that
slow-rotates and breathes, a data node orbiting a faint ring (the assistant at
the centre of the business system). An "idle" and a "thinking" state (faster
orbit + red glow); honours prefers-reduced-motion. Used as the sidebar brand,
the empty-state hero, and the busy/thinking indicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:31:48 +02:00
BOHA
769767c33d feat(odin): left sidebar + lazy conversation creation (claude.ai-style)
- Replace top tabs with a left sidebar (New chat + scrollable conversation
  list, per-row rename/delete menu, active highlight), chat on the right.
- Fix duplicate-empty-conversations bug: "Nová konverzace" now just opens an
  empty composer; a DB row is created only on the first successful message
  (and a failed AI call no longer leaves an orphan conversation). Repeated
  clicks never spawn empties, and nothing empty survives a refresh.
- Land on a fresh new chat on mount; deleting the open conversation returns
  to new chat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:07:07 +02:00
BOHA
f18e2e0f2c docs(odin): forward-looking notes for a Phase 2 general assistant
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:52:30 +02:00
BOHA
d62abe81fd feat(odin): use the full page (full width + taller shell)
Drop the 1100px max-width cap and size the chat shell to fill the content
area (100dvh - 100px = top bar + main bottom padding), so Odin uses the whole
page now that it owns the route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:51:23 +02:00
BOHA
ac7c220bb1 fix(odin): gate Uložit on invoices.create + correct invoice invalidation key
Final review caught two minor issues:
- saveInvoice invalidated ["received-invoices"], which matches no query (the
  received list/stats are keyed ["invoices","received",…]); use ["invoices"]
  so the list/stats actually refresh after an import.
- The Save button hit /received-invoices (requires invoices.create) while Odin
  is gated only on ai.use; disable Uložit (with a hint) when the user lacks
  invoices.create so the UI never offers a 403 action. Dormant today (ai.use is
  admin-only and admin bypasses checks) but correct going forward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:46:49 +02:00
BOHA
b7ebf04001 feat(odin): OdinChat orchestrator; render on /odin; remove DashAssistant
Wire OdinTabs/OdinThread/InvoiceReviewCard/OdinComposer into a single
OdinChat orchestrator; mount it on /odin (maxWidth 1100); delete the
old DashAssistant and its aiHistoryOptions query helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:39:15 +02:00
BOHA
441f46a471 feat(odin): tabs, thread, review-card, composer components
Add four presentational components + shared types under
src/admin/components/odin/ as props-driven building blocks for the
multi-conversation Odin page (Task 6 wires state/queries).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:34:22 +02:00
BOHA
f011a67ff3 feat(odin): conversation query options 2026-06-08 19:28:04 +02:00
BOHA
92594f89c3 test(odin): conversation CRUD, isolation, auto-title, cascade
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:27:31 +02:00
BOHA
6664e3bc4d feat(odin): conversation-scoped service + routes (replace /history)
Remove single-thread getChatHistory/appendChatMessages/clearChatHistory and the
three /history routes; add listConversations, createConversation, getConversation-
Messages, appendConversationMessages, renameConversation, deleteConversation with
ownership checks and auto-title; wire six /conversations[/:id] routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:21:13 +02:00
BOHA
c5cfd855ba feat(odin): ai_conversations table + conversation_id on messages 2026-06-08 19:14:01 +02:00
BOHA
2565e0de21 docs(odin): implementation plan for multi-conversation chat
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:11:54 +02:00
BOHA
817f0b2f00 docs(odin): design spec for multi-conversation chat
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:01:50 +02:00
BOHA
e4ec08a5ef feat(ai): move the assistant to its own "Odin" sidebar page
Promote the AI chat from a dashboard widget to a dedicated page:
- new sidebar item "Odin" under the Přehled section, gated on ai.use, → /odin
- new /odin route + Odin page (reuses the assistant component, with a light
  ai.use guard)
- remove the widget from the dashboard
- rebrand the assistant header "Asistent" → "Odin" and give the thread more
  height now that it owns the page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:38:35 +02:00
BOHA
049ee492c0 chore(release): v2.1.0 — AI assistant (chat, PDF invoice import, budget)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:33:19 +02:00
BOHA
f4bc038f8b fix(received-invoices): treat amount as gross (VAT-inclusive), back-calc VAT
The amount on a received invoice is the GROSS total the user pays (VAT
included), and VAT is the portion contained within it — not a net base with
VAT added on top. Switch the formula from amount*rate/100 to the
VAT-inclusive amount*rate/(100+rate) via a shared, tested vatFromGross()
helper used by both the create (manual upload + AI import share this
endpoint) and edit paths. Rate 0 → no VAT.

- AI extraction now asks Claude for the total INCLUDING VAT (was "základ bez
  DPH"), reinforced in the structured-output schema description.
- Label the amount field "Částka s DPH" in the AI review card and the manual
  upload/edit forms so the gross convention is explicit.
- Add unit tests with the user-confirmed figures (22 542,91 @ 21% → 3 912,41,
  base 18 630,50) plus other rates and the no-VAT case.

Existing rows keep their stored (gross) amounts; their vat_amount recomputes
on next edit. A one-shot backfill can correct historical vat_amount if wanted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:27:56 +02:00
BOHA
58e5fd2b1d feat(ai): server-side chat history + assistant UX fixes
Server-side, per-user chat history (the thread now survives reload):
- new ai_chat_messages table + migration; getChatHistory/appendChatMessages/
  clearChatHistory; GET/POST/DELETE /api/admin/ai/history (ai.use-guarded);
  service + HTTP tests (isolation, ordering, clear, perm, validation).
- DashAssistant loads the thread on mount and persists each turn; "Vymazat"
  clears it. gcTime:0 so each mount re-reads the DB (no stale-cache resurrection);
  composer gated until history settles + seed flag flipped on submit (no
  seed/submit race clobbering a freshly-sent turn).

UX + correctness fixes from review/feedback:
- review cards moved OUT of the scrollable thread into a "Faktury k potvrzení"
  section so Uložit is always reachable; chat auto-scroll no longer fires on
  field edits.
- chat bubble text colour set on the Typography (GlobalStyles pins `p` to
  text.secondary, which was overriding the inherited bubble colour → unreadable).
- never clear input/staged PDFs on a failed submit (rollback + keep), so a
  budget error can't wipe staged invoices; roll back the optimistic user turn.
- stable attachment ids (no crypto.randomUUID — undefined over plain HTTP).
- editable Datum splatnosti + Popis fields; client-side extraction summary.

Security: DashProfile clipboard copy now uses an execCommand fallback (works
over plain HTTP) and only shows the success toast when the copy actually
succeeded — previously it falsely claimed to copy 2FA codes / the TOTP secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:02:26 +02:00
BOHA
0226cdd52b fix(ai): cap extract-invoices batch at 20 files
Final review flagged that the multipart limits set only fileSize, so a
single /extract-invoices request could carry unbounded PDF parts. The
per-batch budget re-check already bounds total spend, but this bounds the
work (one vision call per file) of any single request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:05:16 +02:00
BOHA
b665ea1d9b feat(ai): dashboard chat widget with invoice import + budget indicator 2026-06-08 15:01:52 +02:00
BOHA
ec74ded953 feat(ai): chat / extract-invoices / usage / budget routes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:50:23 +02:00
BOHA
bcf63d00d1 feat(ai): spend tracker + AI service (chat, extractInvoice)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:41:03 +02:00
BOHA
096784768f feat(ai): add @anthropic-ai/sdk, ai_usage table, ai.use permission
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:23:06 +02:00
BOHA
3623b441ce docs(ai): implementation plan for AI assistant Phase 1 (v2.1.0)
Five tasks: (1) foundation — SDK dep, env, ai_usage table + ai.use
permission migration; (2) spend tracker + ai.service (chat,
extractInvoice) with cost/budget tests; (3) /ai routes (usage, budget,
chat, extract) with guard tests; (4) DashAssistant dashboard widget;
(5) v2.1.0 release. Anthropic call stubbed in tests; migration apply is
controller-coordinated (dev server stopped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:19:06 +02:00
BOHA
4bf7bf4ed6 docs(ai): spec for AI assistant Phase 1 (chat + invoice import + budget)
Admin-only Claude (Sonnet 4.6) chat embedded on the dashboard under the
welcome text. Attach received-invoice PDFs → extract (vision + structured
output) → confirm/edit → save via the existing received-invoices endpoint.
$50/month admin-editable budget enforced via an ai_usage table + pre-call
guard. New @anthropic-ai/sdk dep, ANTHROPIC_API_KEY, ai.use permission;
one migration. No system-data tools (Phase 2). Ships as v2.1.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:09:47 +02:00
BOHA
92252382ac chore(release): v2.0.8 — show all employees in dashboard attendance
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:07:59 +02:00
BOHA
8bed920de1 feat(dashboard): show every active employee in "Docházka dnes"
The attendance card listed only employees with a record today (present /
break / clocked-out / on-leave). Now every active employee appears;
those with no record today show as "absent" (Nepřítomen), so the card
reflects the whole team. Loads active users (was a bare count) and
appends the unrecorded ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:00:09 +02:00
260 changed files with 32456 additions and 6055 deletions

View File

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

View File

@@ -1,32 +1,63 @@
# Database
# ─────────────────────────────────────────────────────────────────────────
# boha-app-ts environment template. Copy to `.env` (dev) / `.env.test` (tests).
# Required vars throw at boot if missing; optional vars show their defaults.
# ─────────────────────────────────────────────────────────────────────────
# ── Required ──────────────────────────────────────────────────────────────
DATABASE_URL=mysql://user:password@localhost:3306/app
# Server
PORT=3001
HOST=127.0.0.1
APP_ENV=local
# Auth — MUST regenerate for production: openssl rand -hex 32
JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
# TOTP — MUST regenerate for production: openssl rand -hex 32
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
# ── Server ────────────────────────────────────────────────────────────────
PORT=3001 # production port (dev is hardcoded to 3050)
HOST=127.0.0.1
APP_ENV=local # local | production — controls CSP/CORS/HSTS
APP_URL= # base URL used in email links
# TRUST_PROXY=127.0.0.1,::1 # comma-separated trusted proxy IPs
# ── Tokens (seconds) ──────────────────────────────────────────────────────
ACCESS_TOKEN_EXPIRY=900
REFRESH_TOKEN_SESSION_EXPIRY=3600
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
# TOTP — MUST regenerate for production: openssl rand -hex 32
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
# ── TOTP / 2FA (optional, sensible defaults) ──────────────────────────────
# TOTP_ALGORITHM=SHA1
# TOTP_DIGITS=6
# TOTP_PERIOD=30
# LOGIN_TOKEN_EXPIRY_MINUTES=5
# File storage
# ── Rate limiting (optional) ──────────────────────────────────────────────
# RATE_LIMIT_MAX=300
# RATE_LIMIT_WINDOW=1 minute
# RATE_LIMIT_LOGIN=20
# RATE_LIMIT_TOTP=5
# RATE_LIMIT_REFRESH=10
# ── File storage (NAS network shares) ─────────────────────────────────────
NAS_PATH=Z:/02_PROJEKTY
MAX_UPLOAD_SIZE=52428800
# NAS_INVOICES= # invoice PDFs — {base}/Vydané|Přijaté/YYYY/MM/ (falls back to NAS_FINANCIALS_PATH if unset)
# NAS_ORDERS= # issued-order PDF archival — {base}/Vydané/YYYY/MM/ (off until set)
# NAS_OFFERS_PATH=
MAX_UPLOAD_SIZE=52428800 # 50 MB
# Email
# ── Email ─────────────────────────────────────────────────────────────────
CONTACT_EMAIL_TO=
CONTACT_EMAIL_FROM=
SMTP_FROM=
# SMTP_FROM_NAME=
LEAVE_NOTIFY_EMAIL=
# INVOICE_ALERT_EMAIL= # set to enable the daily 08:00 invoice-alert cron
# Optional SMTP transport (defaults to local sendmail when SMTP_HOST is unset):
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=
# SMTP_PASS=
# App URL (for email links)
APP_URL=
# CORS (production only, comma-separated)
# ── CORS (production only, comma-separated) ───────────────────────────────
CORS_ORIGINS=
# ── AI assistant "Odin" (optional; feature self-hides when unset) ─────────
# ANTHROPIC_API_KEY=

7
.gitignore vendored
View File

@@ -7,6 +7,13 @@ dist/
dist-client/
*.css.map
# Build / tooling artifacts
*.tsbuildinfo
*.bak
# Local scratch
.playwright-mcp/
# Release archives
*.tar.gz

7
.prettierignore Normal file
View File

@@ -0,0 +1,7 @@
dist/
dist-client/
node_modules/
prisma/migrations/
src/admin/bones/
*.tsbuildinfo
package-lock.json

7
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 80
}

107
CLAUDE.md
View File

@@ -7,18 +7,19 @@ Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operation
## Tech Stack
| Layer | Technology |
| -------------- | ------------------------------------------------------------- |
| Runtime | Node.js, TypeScript 5.9.3 (strict) |
| HTTP Framework | Fastify 5.8.2 |
| ORM | Prisma 6.19.2 → MySQL |
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
| Validation | Zod 4.3.6 |
| Frontend | React 18.3.1 + Vite 8.0.0 + Material UI v7 (Emotion) |
| Testing | Vitest 4.1.0 + Supertest |
| PDF | Puppeteer 24.x |
| Email | nodemailer 8.x |
| Cron | node-cron 4.x |
| Layer | Technology |
| -------------- | ---------------------------------------------------------------------------------------------------------------- |
| Runtime | Node.js, TypeScript 5.9.3 (strict) |
| HTTP Framework | Fastify 5.8.2 |
| ORM | Prisma 7.8.0 (Rust-free client + @prisma/adapter-mariadb driver adapter; datasource in prisma.config.ts) → MySQL |
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
| Validation | Zod 4.3.6 |
| Frontend | React 19.2.7 + Vite 8.0.0 + Material UI v7 (Emotion) |
| Testing | Vitest 4.1.0 + Supertest |
| PDF | Puppeteer 24.x |
| Email | nodemailer 8.x |
| Cron | node-cron 4.x |
| AI | @anthropic-ai/sdk 0.102 — Claude Sonnet 4.6 ("Odin" assistant) |
---
@@ -35,21 +36,21 @@ src/
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
├── config/ # env.ts (config singleton, Date.toJSON override)
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
├── admin/ # React 18 + Material UI frontend (~105 .tsx files)
├── admin/ # React 18 + Material UI frontend (~114 .tsx files)
│ ├── AdminApp.tsx # Router + lazy-loaded pages
│ ├── theme.ts # MUI theme — light/dark color schemes, tokens, component defaults
│ ├── GlobalStyles.tsx # App-wide global styles via MUI <GlobalStyles> (reset, typography, utilities)
│ ├── ui/ # MUI component kit (AppShell, Button, DataTable, Modal, …) — pages import from here
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
│ ├── components/ # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets
│ ├── components/ # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets, odin/ (AI chat)
│ ├── pages/ # One file per page/feature
│ ├── lib/ # React Query options & mutations (queries/) + shared label maps
│ ├── hooks/ # usePaginatedQuery, useTableSort, useDebounce, useReducedMotion, …
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
└── __tests__/ # Vitest tests (~14 files: auth, numbering, warehouse, plan, invoices, …)
└── __tests__/ # Vitest tests (17 files: auth, numbering, warehouse, plan, invoices, ai/Odin, received-invoices VAT, …)
prisma/
├── schema.prisma # 49 models, MySQL, snake_case columns
├── schema.prisma # 52 models, MySQL, snake_case columns
└── migrations/ # Applied migrations
dist/ # Compiled server (CommonJS, ES2022)
@@ -75,9 +76,14 @@ npm run build:client # vite build → dist-client/
npm start # node dist/server.js
# Tests
npm test # vitest run (single pass)
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 .
# Database
npx prisma migrate dev # Create migration from schema changes + apply to dev
npx prisma migrate dev --name <descriptive_name> # Named migration
@@ -238,11 +244,13 @@ if ("error" in body) return error(reply, body.error, 400);
## Testing
Tests live in `src/__tests__/`. They use Vitest + Supertest against a real test database (`.env.test`).
Tests live in `src/__tests__/`. They use Vitest + Supertest against a **real, throwaway test database** (`app_test`).
- The suite spans ~14 files / 152 tests — auth, numbering, warehouse, plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create. Server-side only (no component tests yet).
- **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`.
- 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).
- Tests are now **type-checked** by `tsc -b` (via `tsconfig.test.json`) — keep them compiling.
- Use `buildApp()` helper to spin up the Fastify instance for tests.
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout.
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout; `fileParallelism: false` (serialized) to avoid `number_sequences` deadlocks.
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
When adding new features, add tests in `src/__tests__/`. Name test files `<feature>.test.ts`.
@@ -301,7 +309,20 @@ The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 line
- **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`.
**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)
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.
- **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`).
- **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/`.
---
@@ -431,6 +452,40 @@ audit found and fixed the deviations. Full report: `docs/codebase-audit-2026-06-
- **Attendance/leave** writes `@db.Date` at **local noon** (`new Date(y, m, d, 12, 0, 0)`).
- **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.
## 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
@@ -465,13 +520,19 @@ audit found and fixed the deviations. Full report: `docs/codebase-audit-2026-06-
2. `npm run build`
3. Commit and tag (`git tag -a vX.Y.Z`)
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma package.json package-lock.json scripts`
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts`
(⚠️ `prisma.config.ts` is REQUIRED — Prisma 7 keeps the datasource URL there;
without it, `prisma generate`/`migrate deploy` on prod have no datasource)
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
- Path: `/var/www/app-ts`
- Remove old files: `rm -rf dist dist-client prisma scripts package.json package-lock.json`
- Remove old files: `rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json`
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
- Install dependencies: `npm install --omit=dev`
- Regenerate the Prisma client: `npx prisma generate`**MANDATORY**.
`npm install` skips regeneration when dependencies didn't change, leaving a
stale client that still selects dropped/renamed columns → P2022 500s in
prod (bit the v2.4.0 supplier release).
- Apply Prisma migrations: `npx prisma migrate deploy`
- Restart: `pm2 restart app-ts --update-env`

73
README.md Normal file
View File

@@ -0,0 +1,73 @@
# boha-app-ts
Internal business-management system for a Czech company (attendance, invoicing,
leave/trips, projects, vehicles, warehouse, HR) — a TypeScript/Node.js rewrite
of the legacy PHP app. User-facing text is Czech by design.
> For full architecture, conventions, and gotchas, see **[CLAUDE.md](./CLAUDE.md)**.
## Stack
- **Backend:** Fastify 5 · Prisma 6 → MySQL · Zod 4 · JWT (HS256) + TOTP 2FA
- **Frontend:** React 18 · Vite · Material UI v7 (Emotion) · TanStack Query — an
SPA in `src/admin/`, served by the same Fastify process (Vite middleware in
dev, static files in prod)
- **Other:** Puppeteer (PDF) · nodemailer · node-cron · `@anthropic-ai/sdk` (the
admins-only "Odin" assistant)
## Prerequisites
- Node.js (LTS) and a MySQL database
- Copy `.env.example``.env` and fill in the **required** vars (`DATABASE_URL`,
`JWT_SECRET`, `TOTP_ENCRYPTION_KEY` — generate the keys with `openssl rand -hex 32`)
## Setup
```bash
npm install
npx prisma generate
npx prisma migrate dev # apply migrations to your dev DB
npx prisma db seed # optional: dev-only sample data (NEVER on prod)
```
## Develop
```bash
npm run dev:server # API (tsx watch) on http://127.0.0.1:3050
npm run dev:client # Vite dev server (manage separately)
```
## Build & run
```bash
npm run build # tsc server → dist/ + vite client → dist-client/
npm start # node dist/server.js
```
## Test
```bash
npm test # vitest run — hits a real test DB (.env.test); no Prisma mocks
```
## Quality gates
Before committing, all of these must pass:
```bash
npx tsc -b --noEmit # typecheck (NOT `tsc -p tsconfig.json`)
npm run build
npx vitest run
npm run lint # ESLint (incl. react-hooks) — see eslint.config.js
```
## Database migrations
Every schema/data change is a tracked Prisma migration — **never** `prisma db push`
or raw SQL on production. Stop the dev server before running `prisma migrate dev`.
See CLAUDE.md → _Database Migrations_ for the full workflow and the production
deploy/hotfix process.
## License
ISC

386
REVIEW.md Normal file
View File

@@ -0,0 +1,386 @@
# Codebase Audit — REVIEW.md
**Date:** 2026-06-08
**Scope:** entire repository (tracked source/config files via `git ls-files`)
**Method:** file-by-file review. Source of truth for progress — re-read before continuing after any compaction.
**Total files to review:** 277
---
## .claude/hooks/
- [x] .claude/hooks/block-env.js
- [x] .claude/hooks/format-on-save.js
## .claude/
- [x] .claude/settings.json
- [x] .claude/settings.local.json
## ./
- [x] .env.example
- [x] CLAUDE.md
- [x] boneyard.config.json
- [x] index.html
- [x] package.json
## prisma/
- [x] prisma/schema.prisma
- [x] prisma/seed.ts
## scripts/
- [x] scripts/migrate-received-invoices-to-nas.ts
- [x] scripts/rotate-totp-key.ts
## src/
- [x] src/App.tsx
## src/__tests__/
- [x] src/__tests__/ai.test.ts
- [x] src/__tests__/auth.service.test.ts
- [x] src/__tests__/auth.test.ts
- [x] src/__tests__/customers.schema.test.ts
- [x] src/__tests__/env.test.ts
- [x] src/__tests__/exchange-rates.test.ts
- [x] src/__tests__/helpers.ts
- [x] src/__tests__/invoices.service.test.ts
- [x] src/__tests__/manual-create.test.ts
- [x] src/__tests__/nas-file-manager.test.ts
- [x] src/__tests__/nas-project-folder.test.ts
- [x] src/__tests__/numbering.test.ts
- [x] src/__tests__/plan.test.ts
- [x] src/__tests__/planAuditDescription.test.ts
- [x] src/__tests__/planCategory.test.ts
- [x] src/__tests__/received-invoices-vat.test.ts
- [x] src/__tests__/schema-nan.test.ts
- [x] src/__tests__/setup.ts
- [x] src/__tests__/warehouse.test.ts
## src/admin/
- [x] src/admin/AdminApp.tsx
- [x] src/admin/GlobalStyles.tsx
## src/admin/components/
- [x] src/admin/components/AlertContainer.tsx
- [x] src/admin/components/AttendanceShiftTable.tsx
- [x] src/admin/components/BulkAttendanceModal.tsx
- [x] src/admin/components/BulkPlanModal.tsx
- [x] src/admin/components/ErrorBoundary.tsx
- [x] src/admin/components/Forbidden.tsx
- [x] src/admin/components/OrderConfirmationModal.tsx
- [x] src/admin/components/PlanCategoriesModal.tsx
- [x] src/admin/components/PlanCellModal.tsx
- [x] src/admin/components/PlanGrid.tsx
- [x] src/admin/components/PlanRangeChips.tsx
- [x] src/admin/components/ProjectFileManager.tsx
- [x] src/admin/components/RichEditor.tsx
- [x] src/admin/components/ShiftFormModal.tsx
- [x] src/admin/components/ShortcutsHelp.tsx
## src/admin/components/dashboard/
- [x] src/admin/components/dashboard/DashActivityFeed.tsx
- [x] src/admin/components/dashboard/DashAttendanceToday.tsx
- [x] src/admin/components/dashboard/DashKpiCards.tsx
- [x] src/admin/components/dashboard/DashProfile.tsx
- [x] src/admin/components/dashboard/DashQuickActions.tsx
- [x] src/admin/components/dashboard/DashSessions.tsx
- [x] src/admin/components/dashboard/DashTodayPlan.tsx
## src/admin/components/odin/
- [x] src/admin/components/odin/InvoiceReviewCard.tsx
- [x] src/admin/components/odin/OdinChat.tsx
- [x] src/admin/components/odin/OdinComposer.tsx
- [x] src/admin/components/odin/OdinMark.tsx
- [x] src/admin/components/odin/OdinSidebar.tsx
- [x] src/admin/components/odin/OdinThread.tsx
- [x] src/admin/components/odin/types.ts
## src/admin/components/warehouse/
- [x] src/admin/components/warehouse/ItemPicker.tsx
- [x] src/admin/components/warehouse/ReservationPicker.tsx
## src/admin/context/
- [x] src/admin/context/AlertContext.tsx
- [x] src/admin/context/AuthContext.tsx
## src/admin/hooks/
- [x] src/admin/hooks/useAttendanceAdmin.ts
- [x] src/admin/hooks/useDebounce.ts
- [x] src/admin/hooks/useModalLock.ts
- [x] src/admin/hooks/usePaginatedQuery.ts
- [x] src/admin/hooks/usePlanWork.ts
- [x] src/admin/hooks/useReducedMotion.ts
- [x] src/admin/hooks/useTableSort.ts
## src/admin/lib/
- [x] src/admin/lib/apiAdapter.ts
- [x] src/admin/lib/entityTypeLabels.ts
## src/admin/lib/queries/
- [x] src/admin/lib/queries/ai.ts
- [x] src/admin/lib/queries/attendance.ts
- [x] src/admin/lib/queries/auditLog.ts
- [x] src/admin/lib/queries/common.ts
- [x] src/admin/lib/queries/dashboard.ts
- [x] src/admin/lib/queries/invoices.ts
- [x] src/admin/lib/queries/leave.ts
- [x] src/admin/lib/queries/mutations.ts
- [x] src/admin/lib/queries/offers.ts
- [x] src/admin/lib/queries/orders.ts
- [x] src/admin/lib/queries/plan.ts
- [x] src/admin/lib/queries/projects.ts
- [x] src/admin/lib/queries/settings.ts
- [x] src/admin/lib/queries/trips.ts
- [x] src/admin/lib/queries/users.ts
- [x] src/admin/lib/queries/vehicles.ts
- [x] src/admin/lib/queries/warehouse.ts
## src/admin/lib/
- [x] src/admin/lib/queryClient.ts
## src/admin/pages/
- [x] src/admin/pages/Attendance.tsx
- [x] src/admin/pages/AttendanceAdmin.tsx
- [x] src/admin/pages/AttendanceBalances.tsx
- [x] src/admin/pages/AttendanceCreate.tsx
- [x] src/admin/pages/AttendanceHistory.tsx
- [x] src/admin/pages/AttendanceLocation.tsx
- [x] src/admin/pages/AuditLog.tsx
- [x] src/admin/pages/CompanySettings.tsx
- [x] src/admin/pages/Dashboard.tsx
- [x] src/admin/pages/InvoiceDetail.tsx
- [x] src/admin/pages/Invoices.tsx
- [x] src/admin/pages/LeaveApproval.tsx
- [x] src/admin/pages/LeaveRequests.tsx
- [x] src/admin/pages/Login.tsx
- [x] src/admin/pages/NotFound.tsx
- [x] src/admin/pages/Odin.tsx
- [x] src/admin/pages/OfferDetail.tsx
- [x] src/admin/pages/Offers.tsx
- [x] src/admin/pages/OffersCustomers.tsx
- [x] src/admin/pages/OffersTemplates.tsx
- [x] src/admin/pages/OrderDetail.tsx
- [x] src/admin/pages/Orders.tsx
- [x] src/admin/pages/PlanWork.tsx
- [x] src/admin/pages/ProjectDetail.tsx
- [x] src/admin/pages/Projects.tsx
- [x] src/admin/pages/ReceivedInvoices.tsx
- [x] src/admin/pages/Settings.tsx
- [x] src/admin/pages/Trips.tsx
- [x] src/admin/pages/TripsAdmin.tsx
- [x] src/admin/pages/TripsHistory.tsx
- [x] src/admin/pages/UiKit.tsx
- [x] src/admin/pages/Users.tsx
- [x] src/admin/pages/Vehicles.tsx
- [x] src/admin/pages/Warehouse.tsx
- [x] src/admin/pages/WarehouseCategories.tsx
- [x] src/admin/pages/WarehouseInventory.tsx
- [x] src/admin/pages/WarehouseInventoryDetail.tsx
- [x] src/admin/pages/WarehouseInventoryForm.tsx
- [x] src/admin/pages/WarehouseIssueDetail.tsx
- [x] src/admin/pages/WarehouseIssueForm.tsx
- [x] src/admin/pages/WarehouseIssues.tsx
- [x] src/admin/pages/WarehouseItemDetail.tsx
- [x] src/admin/pages/WarehouseItems.tsx
- [x] src/admin/pages/WarehouseLocations.tsx
- [x] src/admin/pages/WarehouseReceiptDetail.tsx
- [x] src/admin/pages/WarehouseReceiptForm.tsx
- [x] src/admin/pages/WarehouseReceipts.tsx
- [x] src/admin/pages/WarehouseReports.tsx
- [x] src/admin/pages/WarehouseReservations.tsx
- [x] src/admin/pages/WarehouseSuppliers.tsx
## src/admin/
- [x] src/admin/theme.test.ts
- [x] src/admin/theme.ts
## src/admin/ui/
- [x] src/admin/ui/Alert.tsx
- [x] src/admin/ui/AppShell.tsx
- [x] src/admin/ui/Button.tsx
- [x] src/admin/ui/Card.tsx
- [x] src/admin/ui/Checkbox.tsx
- [x] src/admin/ui/ConfirmDialog.tsx
- [x] src/admin/ui/DataTable.tsx
- [x] src/admin/ui/DateField.tsx
- [x] src/admin/ui/EmptyState.tsx
- [x] src/admin/ui/Field.tsx
- [x] src/admin/ui/FileUpload.tsx
- [x] src/admin/ui/FilterBar.tsx
- [x] src/admin/ui/LoadingState.tsx
- [x] src/admin/ui/Modal.tsx
- [x] src/admin/ui/MonthField.tsx
- [x] src/admin/ui/MuiProvider.tsx
- [x] src/admin/ui/PageEnter.tsx
- [x] src/admin/ui/PageHeader.tsx
- [x] src/admin/ui/Pagination.tsx
- [x] src/admin/ui/ProgressBar.tsx
- [x] src/admin/ui/RichText.tsx
- [x] src/admin/ui/Select.tsx
- [x] src/admin/ui/SidebarNav.tsx
- [x] src/admin/ui/StatCard.tsx
- [x] src/admin/ui/StatusChip.tsx
- [x] src/admin/ui/Tabs.tsx
- [x] src/admin/ui/TextField.tsx
- [x] src/admin/ui/ThemeToggle.tsx
- [x] src/admin/ui/TimeField.tsx
- [x] src/admin/ui/index.ts
- [x] src/admin/ui/navData.tsx
- [x] src/admin/ui/useDialogScrollLock.ts
## src/admin/utils/
- [x] src/admin/utils/api.ts
- [x] src/admin/utils/attendanceHelpers.ts
- [x] src/admin/utils/dashboardHelpers.ts
- [x] src/admin/utils/formatters.ts
## src/config/
- [x] src/config/database.ts
- [x] src/config/env.ts
## src/context/
- [x] src/context/ThemeContext.tsx
## src/
- [x] src/main.tsx
## src/middleware/
- [x] src/middleware/auth.ts
- [x] src/middleware/security.ts
## src/routes/admin/
- [x] src/routes/admin/ai.ts
- [x] src/routes/admin/attendance.ts
- [x] src/routes/admin/audit-log.ts
- [x] src/routes/admin/auth.ts
- [x] src/routes/admin/bank-accounts.ts
- [x] src/routes/admin/company-settings.ts
- [x] src/routes/admin/customers.ts
- [x] src/routes/admin/dashboard.ts
- [x] src/routes/admin/invoices-pdf.ts
- [x] src/routes/admin/invoices.ts
- [x] src/routes/admin/leave-requests.ts
- [x] src/routes/admin/offers-pdf.ts
- [x] src/routes/admin/orders-pdf.ts
- [x] src/routes/admin/orders.ts
- [x] src/routes/admin/plan.ts
- [x] src/routes/admin/profile.ts
- [x] src/routes/admin/project-files.ts
- [x] src/routes/admin/projects.ts
- [x] src/routes/admin/quotations.ts
- [x] src/routes/admin/received-invoices.ts
- [x] src/routes/admin/roles.ts
- [x] src/routes/admin/scope-templates.ts
- [x] src/routes/admin/sessions.ts
- [x] src/routes/admin/totp.ts
- [x] src/routes/admin/trips.ts
- [x] src/routes/admin/users.ts
- [x] src/routes/admin/vehicles.ts
- [x] src/routes/admin/warehouse.ts
## src/schemas/
- [x] src/schemas/ai.schema.ts
- [x] src/schemas/attendance.schema.ts
- [x] src/schemas/auth.schema.ts
- [x] src/schemas/bank-accounts.schema.ts
- [x] src/schemas/common.ts
- [x] src/schemas/customers.schema.ts
- [x] src/schemas/invoices.schema.ts
- [x] src/schemas/leave-requests.schema.ts
- [x] src/schemas/offers.schema.ts
- [x] src/schemas/orders.schema.ts
- [x] src/schemas/plan.schema.ts
- [x] src/schemas/planCategory.schema.ts
- [x] src/schemas/profile.schema.ts
- [x] src/schemas/projects.schema.ts
- [x] src/schemas/received-invoices.schema.ts
- [x] src/schemas/roles.schema.ts
- [x] src/schemas/scope-templates.schema.ts
- [x] src/schemas/settings.schema.ts
- [x] src/schemas/trips.schema.ts
- [x] src/schemas/users.schema.ts
- [x] src/schemas/vehicles.schema.ts
- [x] src/schemas/warehouse.schema.ts
## src/
- [x] src/server.ts
## src/services/
- [x] src/services/ai.service.ts
- [x] src/services/attendance.service.ts
- [x] src/services/audit.ts
- [x] src/services/auth.ts
- [x] src/services/exchange-rates.ts
- [x] src/services/invoice-alerts.ts
- [x] src/services/invoices.service.ts
- [x] src/services/leave-notification.ts
- [x] src/services/mailer.ts
- [x] src/services/nas-file-manager.ts
- [x] src/services/nas-financials-manager.ts
- [x] src/services/nas-offers-manager.ts
- [x] src/services/numbering.service.ts
- [x] src/services/offers.service.ts
- [x] src/services/orders.service.ts
- [x] src/services/plan.service.ts
- [x] src/services/planCategory.service.ts
- [x] src/services/projects.service.ts
- [x] src/services/system-settings.ts
- [x] src/services/users.service.ts
- [x] src/services/warehouse.service.ts
## src/types/
- [x] src/types/fastify.d.ts
- [x] src/types/index.ts
## src/utils/
- [x] src/utils/czech-holidays.ts
- [x] src/utils/date.ts
- [x] src/utils/encryption.ts
- [x] src/utils/html-to-pdf.ts
- [x] src/utils/pagination.ts
- [x] src/utils/planAuditDescription.ts
- [x] src/utils/response.ts
- [x] src/utils/totp.ts
## src/
- [x] src/vite-env.d.ts
## ./
- [x] tsconfig.app.json
- [x] tsconfig.json
- [x] tsconfig.server.json
- [x] vite.config.ts
- [x] vitest.config.ts

1330
REVIEW_FINDINGS.md Normal file

File diff suppressed because it is too large Load Diff

1385
REVIEW_FIXES.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
# Cross-Module Consistency Report: Offers, Invoices, Orders
> Generated 2026-06-09 by a multi-agent UI/UX audit (4 by-dimension examiners + synthesis)
> across the Offers (nabídky), Invoices (faktury), and Orders (objednávky) modules.
> Examination only — no code was changed. File:line refs are at the time of the audit.
## 1. Executive Summary
The three document modules (Offers, Invoices, Orders) share a strong common foundation — `PageEnter`/`PageHeader`/`Card`/`FilterBar`/`DataTable`/`StatusChip`/`Pagination`, a near-identical line-item editor (dnd-kit drag, mobile cards, monospace per-row totals, per-rate VAT breakdown), and a uniform totals layout — so the divergences are concentrated in a handful of presentational and behavioral details rather than the architecture. **No single module is canonical across the board:** Invoices is the most mature on list-level intelligence (KPI StatCards, overdue row tinting) and is the only one with a polished load pattern (ReceivedInvoices' `hasLoadedOnce` skeleton suppression); Issued Orders is the cleanest detail-page reference (single editable form with a `readOnly` prop, semantically correct status-transition button colors); and Offers is the best on list-level state richness (three-tier `rowSx` tinting, search-aware empty states) but the worst on detail-form fidelity (raw HTML checkboxes, action-icon overload). The highest-value work clusters into four buckets: bring Orders up to Invoices' list intelligence (KPIs, filters, row tinting), converge the three detail forms on shared MUI kit components (Autocomplete customer picker, MUI Checkbox, RichEditor notes), fix two genuine safety/feature bugs (delete-button guards and Orders' missing delete), and a long tail of cheap label/width/alignment unifications in the line-item tables.
## 2. Recommended Canonical Patterns
| Dimension | Standardize on | Why |
| ------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| List KPI cards | **Invoices** (`Invoices.tsx:420-505`) | Only module with a 4-card metrics grid; gives business context Orders/Offers lack |
| List load/skeleton | **ReceivedInvoices** (`hasLoadedOnce` ref, lines 325/688/784) | Skeleton only on first mount, suppressed on refetch — no full-page blocking jank |
| List row state tinting | **Offers** (`Offers.tsx:650-684`) three-tier `rowSx` | Richest at-a-glance lifecycle signal; Invoices' single overdue tint is the minimal version |
| List status filter | **Tabs** for ≤4 states (Offers/Invoices), **Select** for ≥5 (IssuedOrders) | Tabs show all options inline; Select scales — choose by option count |
| Search-aware empty state | **Offers** (`Offers.tsx:840-855`) | Distinguishes search-empty (no CTA) from list-empty (with CTA) |
| Detail read-only mode | **IssuedOrderDetail** (single form + `readOnly` prop, `:638`) | DRY; avoids InvoiceDetail's duplicate read-only JSX branch that must be hand-synced |
| Status-transition button colors | **IssuedOrderDetail** (`:903-917`) | Destructive = `outlined`+`error`, positive = `contained`+`primary` (semantically correct) |
| Save-button loading | **Invoice/Order** (CircularProgress + text) | Clearer visual signal than Offers' text-only swap |
| Detail guard placement | **OfferDetail** (all guards top-level, `:1087-1091`) | Flattest; no `if (!dataReady)` buried inside render branches |
| Customer/supplier picker | **MUI Autocomplete freeSolo** (as in `ReceivedInvoices.tsx:914-929`) | Keyboard-accessible, searchable, Material-standard |
| Boolean fields | **MUI `CheckboxField` kit** (as in OrdersReceived modal) | Themed, accessible; replaces raw `<input type=checkbox>` |
| Notes fields | **RichEditor** (Offers/Orders already) | Uniform editing; future-proof for formatting |
| `issued_by` | **Read-only, auto-filled** (Invoices, `:1881-1885`) | Immutable provenance tied to the issuing user |
| Currency options | **Company settings** (Offers/Invoices) | Admin-configurable without code change |
| Line-item labels/widths | **Invoices+Orders majority** ("Jedn. cena", qty `5.5rem`, remove `2.5rem`, `align="center"`) | 2-of-3 already agree; Offers is the lone outlier |
| Amount column header | **"Celkem"** (all but ReceivedInvoices' "Částka") | Single label for the same concept |
## 3. Inconsistencies by Dimension
### A. Tables & Lists
**HIGH — Orders lack KPI StatCards.** Invoices renders a 4-card metrics grid (`Invoices.tsx:420-505`; ReceivedInvoices `:687-761`); IssuedOrders, OrdersReceived, and Offers render none. Orders users lose pipeline visibility (count by status, total value). Fix: add a 4-StatCard grid to IssuedOrders/OrdersReceived mirroring the Invoices layout. Decide whether Offers warrants its own. Effort: **M**.
**HIGH — Offers row-action overload (6-7 icons vs 3).** `Offers.tsx:540-641` renders View/Edit, Duplicate, Show/Create Order, Invalidate, PDF, Delete; Invoices/IssuedOrders/OrdersReceived keep 3. Fix: reduce Offers to 4 (View/Edit, Order toggle, PDF, Delete); move Duplicate/Invalidate into a row overflow menu or the detail page. Effort: **M**.
**MED — Orders missing list features that Invoices/Offers have.**
- _Row state tinting:_ Offers 3-tier (`:650-684`), Invoices 1-tier overdue (`:510-526`), all three Orders modules + ReceivedInvoices = none. Add `rowSx` (completed→success wash, overdue/expired→warning, voided→opacity) using the channel-alpha convention. Effort: **S** each.
- _Status filter:_ OrdersReceived has none; add a Select like IssuedOrders (`:305-313`, `STATUS_OPTIONS` `:53`). Effort: **S**.
- _Sortable columns:_ IssuedOrders/OrdersReceived expose only 3 `sortKey`s vs 5-6 in Invoices/Offers; add customer_name, total, status. Effort: **S**.
**MED — Load pattern blocks the page on refetch.** Offers (`:453`), Invoices (`:402`), IssuedOrders (`:189`), OrdersReceived (`:328`) return a full `<LoadingState/>` whenever `isPending`; only ReceivedInvoices suppresses the skeleton after first load (`hasLoadedOnce`, `:325/688/784`). Adopt the `hasLoadedOnce` ref pattern everywhere. Effort: **S-M**.
**MED — Amount column label split.** ReceivedInvoices uses "Částka"; everyone else "Celkem". Rename "Částka"→"Celkem". (supplier-vs-customer terminology "Dodavatel"/"Zákazník" is correctly domain-specific — leave it.) Effort: **S**.
**LOW — Offers tabs not centered.** Invoices/Orders wrap Tabs in a centered Box (`Invoices.tsx:712-721`, `Orders.tsx:161-170`); Offers (`:717-726`) left-aligns. Effort: **S**.
**LOW — Empty-state copy not search-aware.** Offers distinguishes search-empty vs list-empty (`:840-855`); the others show one message (+ stale CTA when filtered to empty). Effort: **S**.
_Already fine: PageHeader, FilterBar layout, Pagination placement, StatusChip colors, search-placeholder copy, draft-banner presence (Offers/Invoices by design)._
### B. Detail Buttons & Layout
**HIGH — Delete-button safety bugs + Orders feature gap.**
- OfferDetail (`:1191-1199`) allows delete even when invalidated/locked/completed.
- InvoiceDetail (`:1167-1175`, `:1734-1742`) allows delete even when `paid`.
- IssuedOrderDetail has **no delete at all** (no button/mutation/handler).
Fix: add `!readOnly`/status guards to Offers and Invoices; add a guarded delete to IssuedOrderDetail for parity. Effort: **M**.
**HIGH — Detail read-only handling diverges.** IssuedOrderDetail uses one form with a `readOnly`/`editable` flag (`:638`); InvoiceDetail forks a whole separate read-only JSX branch for paid invoices (`:1104-1615`) that must be hand-synced; OfferDetail is always editable. Fix: refactor InvoiceDetail's paid view to the single-form-`readOnly`-prop model. Effort: **L**.
**MED — Status-transition button styling inverted.** InvoiceDetail emphasizes positive ("paid"→`contained`+`primary`, `:1719-1733`); IssuedOrderDetail emphasizes danger ("cancelled"→`outlined`+`error`, `:903-917`). Standardize on the Order rule. Effort: **S**.
**MED — Back-button target inconsistent.** OfferDetail→`/offers` (`:1108`), InvoiceDetail→`/invoices` (`:1124`/`:1639`), IssuedOrderDetail→`/orders?tab=vydane` (`:836`). Make tabbed-landing modules carry the tab param: Invoices→`/invoices?tab=issued`. Effort: **S**.
**LOW — Save-button loading style.** Offers text-only (`:1187`); Invoice/Order spinner+text. Switch Offers to spinner+text. Effort: **S**.
**LOW — Create-success invalidation scope.** OfferDetail invalidates `["offers","orders","projects","invoices"]` (`:927`); Invoice/Order invalidate only their own domain. Verify Invoice/Order touch no foreign domains, else widen. Effort: **S**.
**LOW — Guard placement.** Hoist Invoice/Order `if (!dataReady)` guards to OfferDetail's top-level pattern when touching those files. Effort: **S**.
_Already fine: PageEnter wrapper, header layout + `headerActionsSx`, StatusChip-in-header, mobile breakpoints, Card sectioning, items-table structure, totals block, permission guards._
### C. Forms & Fields
**HIGH — Customer/supplier picker: 3 different widgets.** Offers (`OfferDetail.tsx:1307-1373`) and Invoices (`InvoiceDetail.tsx:1816-1876`) use a hand-rolled searchable dropdown; IssuedOrders (`:935-948`) uses a plain non-searchable `<Select>`; ReceivedInvoices (`:914-929`) uses MUI `Autocomplete freeSolo`. Standardize all on `Autocomplete freeSolo`. Effort: **M**.
**HIGH — `apply_vat` boolean: MUI Checkbox vs raw `<input>`.** Offers uses MUI `Checkbox` (`:1482-1488`); InvoiceDetail (`:2008-2017`) and IssuedOrderDetail (`:1023-1035`) use raw `<input type="checkbox">`. Standardize on the kit `CheckboxField`. Effort: **S**.
**HIGH — Notes field: TextField vs RichEditor.** Offers (`:1953-1966`) and Orders (`:1257-1272`) use RichEditor; Invoices (`:2232-2240`) and ReceivedInvoices modal (`:1214-1225`) use plain multiline TextField. Move Invoices notes to RichEditor — and per CLAUDE.md gotcha #5, apply DOMPurify + `cleanQuillHtml` sanitization on both the PDF and render paths. Effort: **M**.
**MED — `issued_by` editable in Orders, read-only in Invoices.** Make Orders read-only + auto-filled from auth. Effort: **S**.
**MED — VAT rate/toggle layout differs.** Adopt Offers' dual-field (rate Select + toggle) where a per-document rate applies. Effort: **M**.
**LOW — Currency options hard-coded in Orders.** IssuedOrders (`:96-99,985`) and OrdersReceived modal (`:575-580`) hard-code; point at company settings. Effort: **S**.
_Already fine: `<Field>` wrapper + error display, DateField usage, totals footer, Card grouping, basic-info grid, delivery/payment terms being Orders-only (domain-justified)._
### D. Line-Item Editors
Offers is the consistent outlier; Invoices+Orders already agree — canonicalize on them. Cheap, mostly-mechanical edits in `OfferDetail.tsx` (plus one internal Invoices fix).
**MED — Unit-price label.** Offers "Cena/ks" (`:336`, `:1625`) → "Jedn. cena". Effort: **S**.
**MED — Unit-price + quantity header alignment/width.** Offers default-left, qty `5rem``align="center"`, qty `5.5rem`. Also InvoiceDetail's paid read-only header uses `align="right"` (`:1429`) — switch to `center`. Effort: **S**.
**MED — Grand-total label.** Invoices "Celkem k úhradě:" is domain-appropriate (payment context) — **keep, intentional.**
**MED — Editable VAT-column header.** Unify the two editable forms on "DPH"; align Invoices' paid-view header. Effort: **S**.
**LOW — Remove-button cell width.** Offers `3rem``2.5rem`. Effort: **S**.
_Already fine: description/detail multiline fields (rows=2, vertical resize), unit-price column width `8rem`, per-row monospace total, mobile card metrics, dnd-kit drag config, totals area layout, per-rate VAT breakdown._
## 4. Suggested Fix Order
**Batch 1 — Mechanical quick wins (low judgment, low risk).**
1. Line-item unifications in `OfferDetail.tsx`: "Cena/ks"→"Jedn. cena", qty width `5rem``5.5rem`, `align="center"` on qty/unit-price headers, remove-button cell `3rem``2.5rem`. Plus in-Invoices fixes (paid-view unit-price `right``center`; "%DPH"→"DPH").
2. List: "Částka"→"Celkem" (ReceivedInvoices); center Offers tabs.
3. Detail: Offers save button → spinner+text; standardize status-transition button colors (Order rule); back-button tab params (Invoices→`?tab=issued`).
4. Forms: `apply_vat` raw `<input>` → kit `CheckboxField` in Invoice/Order; Orders currency Select → company settings; Orders `issued_by` read-only+auto-filled.
**Batch 2 — Small behavioral additions (per-file logic, light judgment).** 5. Add `rowSx` state tinting to the three Orders modules (+ optionally ReceivedInvoices). 6. Add OrdersReceived status-filter Select; widen IssuedOrders/OrdersReceived `sortKey` coverage. 7. Adopt `hasLoadedOnce` skeleton-suppression in Offers/Invoices/IssuedOrders/OrdersReceived. 8. Search-aware empty states (Offers pattern) in Invoices/IssuedOrders/OrdersReceived.
**Batch 3 — Safety/feature bug fixes (need judgment, preserve data logic).** 9. Delete-button guards: add `!readOnly`/status checks to Offers + Invoices; add a guarded delete to IssuedOrderDetail for parity. 10. Verify create-success invalidation scope for Invoice/Order; widen only if they touch foreign domains.
**Batch 4 — Shared-component & layout refactors (largest, most judgment).** 11. Replace the three customer/supplier pickers with one `Autocomplete freeSolo` (consider a shared `CustomerPicker` kit component). 12. Move Invoices notes to RichEditor + wire DOMPurify/`cleanQuillHtml` on PDF + render paths. 13. Unify VAT rate/toggle layout on Offers' dual-field pattern. 14. Add KPI StatCard grids to IssuedOrders/OrdersReceived (decide metrics; consider an Offers variant). 15. Refactor InvoiceDetail's separate paid read-only branch into the single-form-`readOnly`-prop model; hoist guards to top-level. 16. Reduce Offers row actions to 4 (move Duplicate/Invalidate to overflow menu/detail).
## 5. Already Consistent — Do Not Touch
- **Shell & layout:** `PageEnter`, `PageHeader`, `Card` sectioning, `FilterBar`, header layout + `headerActionsSx`, StatusChip-in-header, mobile breakpoints.
- **Lists:** DataTable rendering, Pagination placement, StatusChip semantic colors, search-placeholder copy, draft-banner presence (by design).
- **Forms:** `<Field>` wrapper + error display, DateField, totals footer, basic-info grid, delivery/payment terms being Orders-only.
- **Line items:** description/detail multiline (rows=2, vertical resize), unit-price width `8rem`, per-row monospace total, mobile card metrics, dnd-kit drag, totals layout, per-rate VAT breakdown.
- **Cross-cutting:** success/error handling, `formatCurrency`, permission guards.
**Intentional differences to preserve:** Invoices' "Celkem k úhradě:" grand-total wording (payment context) and the supplier-vs-customer ("Dodavatel"/"Zákazník") terminology.
---
# Part 2 — Status/State Models + Draft Persistence (follow-up audit)
> Generated 2026-06-09 by a second multi-agent audit (status models + draft persistence).
## Summary
The five document types share the same conceptual lifecycle (draft → active → terminal/paid) but agree on almost nothing in implementation. State is stored two ways (raw `String(30)` vs Prisma enum), keyed in two languages (Czech for customer-orders, English for everything else), enforced with three transition styles, and rendered through **six separately-maintained label/color maps that have already drifted** (the same `issued` status is amber in the list, blue in the detail). Draft persistence works end-to-end only for Offers, is **half-wired for Issued Invoices (a banner promises a draft that is never written)**, and is absent everywhere else. The theme is **copy-paste divergence with no shared source of truth**.
## State-model comparison
| Document | Storage | Key language | #States | Transitions | Auto-transitions | Edit-lock | Color consistency |
| ------------------------------------ | -------------------------- | ------------------------------------------------------------ | ------- | -------------------------------- | ---------------------------------------- | -------------------------------------- | ------------------------------------------------------ |
| Offer (quotations) | `String(30)` def `active` | mixed (`active`/`ordered`/`invalidated`) | 3 | none (manual; `invalidateOffer`) | none | locked only when `invalidated` | **no label/color map** (tabs only) |
| Customer Order (orders) | `String(30)` def `prijata` | **Czech** (`prijata`/`v_realizaci`/`dokoncena`/`stornovana`) | 4 | `VALID_TRANSITIONS` | → project status sync | items lock in `dokoncena`/`stornovana` | consistent list↔detail |
| Issued Order (issued_orders) | **enum** def `draft` | English (`draft`/`sent`/`confirmed`/`completed`/`cancelled`) | 5 | `VALID_TRANSITIONS` | none | full lock outside `draft`/`sent` | consistent |
| Issued Invoice (invoices) | `String(30)` def `issued` | English (`issued`/`overdue`/`paid`) | 3 | `VALID_TRANSITIONS` | `markOverdueInvoices()` + auto paid_date | full lock in `paid` | **DRIFTED**: `issued`=warning in list, =info in detail |
| Received Invoice (received_invoices) | **enum** def `unpaid` | English (`unpaid`/`paid`) | 2 | implicit (no revert) | auto paid_date, month/year | locks in `paid` | consistent (list only) |
## Intentional (KEEP) vs Accidental (FIX)
**Domain-justified — keep:** different state counts per document (orders need 5, received invoices need 2); Offers' transition-free lifecycle (document it); the customer-order→project sync and invoice auto-overdue business rules; stricter edit-lock on terminal states.
**Accidental — fix:**
- Czech vs English status keys (customer-orders are the lone Czech holdout — legacy-PHP carryover).
- String vs enum storage (orders/quotations/invoices loose `String`; issued_orders/received_invoices typed enums).
- Divergent semantic color for the same status (`issued` = warning vs info).
- Six duplicated, drifting `STATUS_LABELS`/`STATUS_COLORS` maps; Offers has none.
- Duplicated draft-key constants (`boha_offer_draft` defined in two files).
- **Half-wired issued-invoice draft** (banner reads a key that's never written; `clearDraft` is dead code) — a live user-facing bug.
- Cross-record draft leakage (`boha_offer_draft` not record-scoped).
- Auto-transition logic placed in three different homes (service vs scheduled fn vs route handler).
- Arbitrary draft-banner visibility heuristics.
## Recommendations
- **Status keys → English** (4/5 already English; matches CLAUDE.md identifier convention). Rename customer-order DB values (`prijata``received`, `v_realizaci``in_progress`, `dokoncena``completed`, `stornovana``cancelled`) + the `ORDER_TO_PROJECT_STATUS` keys. **Needs DB migration + backfill.**
- **Storage → Prisma enums** for invoices/orders/quotations (match the other two). **Migration**, mechanical; do in the same migration as the rename.
- **One shared `src/admin/lib/documentStatus.ts`** exporting per-document `STATUS_LABELS` + `STATUS_COLORS` (Czech labels, MUI colors), consumed everywhere — kills all six duplicates, fixes the `issued` color drift in one place (→ `warning`), gives Offers a chip. Fold draft-key constants into a shared `DRAFT_KEYS`. **Frontend-only.**
- **Drafts → uniform or removed; never half-wired.** Recommended: a reusable `useDocumentDraft(key, { recordId })` hook (record-scoped, debounced autosave, restore-on-init, unified banner) applied to all create/edit forms; finish the stubbed invoice banner. Acceptable cheaper alternative: delete drafts entirely. Either way, fix the half-wired invoice banner. **Frontend-only.**
- **Transition placement → one home** (service layer, never the route). **Backend-only, no migration.**
## Effort + risk
- **Frontend-only (cheap, safe, do now):** shared `documentStatus.ts` (+ fix `issued` color drift, + Offers chip), fix the half-wired invoice draft, record-scoped `useDocumentDraft` hook + leakage guard + banner unification.
- **Backend, no migration (low risk, opportunistic):** centralize auto-transition logic into services; document Offers' transition-free lifecycle.
- **Backend + DB migration (higher risk, deferrable, do as ONE planned tested change):** Czech→English customer-order status rename (DB values + transition map + project-sync keys + frontend maps in lockstep, with `UPDATE` backfill); String→enum conversion for invoices/orders/quotations. **Not correctness blockers** (the String columns work today) — schedule deliberately.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,792 @@
# Odin Multi-Conversation Chat — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the single-thread Odin assistant into a multi-conversation chat (top-tabs, rename/delete, auto-title) on the `/odin` page.
**Architecture:** New `ai_conversations` table; `ai_chat_messages` gains `conversation_id` (FK, cascade). Conversation-scoped service + routes replace the single `/history` endpoints. A new `OdinChat` component (+ focused sub-components) replaces `DashAssistant`, keeping all proven chat/extract/save logic but scoped to the active conversation.
**Tech Stack:** Fastify 5, Prisma 6 (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (real DB).
**Spec:** `docs/superpowers/specs/2026-06-08-odin-conversations-design.md`.
---
## File map
| File | Responsibility |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `prisma/schema.prisma` | + `ai_conversations`; `ai_chat_messages.conversation_id` + relation + index swap |
| `prisma/migrations/20260608140000_add_ai_conversations/migration.sql` | create table, add column, backfill, enforce FK |
| `src/services/ai.service.ts` | replace `getChatHistory/appendChatMessages/clearChatHistory` with conversation-scoped fns |
| `src/schemas/ai.schema.ts` | + `AiCreateConversationSchema`, `AiRenameConversationSchema`; keep `AiHistoryAppendSchema` (reused for append) |
| `src/routes/admin/ai.ts` | replace `/history` routes with `/conversations*` routes |
| `src/__tests__/ai.test.ts` | replace history tests with conversation tests |
| `src/admin/lib/queries/ai.ts` | + conversation query options/types; remove `aiHistoryOptions` |
| `src/admin/components/odin/OdinTabs.tsx` | conversation tabs + new + rename/delete menu |
| `src/admin/components/odin/OdinThread.tsx` | message list + avatar + empty/busy states |
| `src/admin/components/odin/InvoiceReviewCard.tsx` | editable extracted-invoice card |
| `src/admin/components/odin/OdinComposer.tsx` | staged chips + attach + input + send |
| `src/admin/components/odin/OdinChat.tsx` | orchestrator: queries, state, the ported logic |
| `src/admin/pages/Odin.tsx` | render `OdinChat` (was `DashAssistant`) |
| `src/admin/components/dashboard/DashAssistant.tsx` | **deleted** |
**Controller note (migration):** Tasks that touch the DB require the dev server stopped. After Task 1's files are written, the **controller** runs: ask user to stop dev server → `npx prisma generate``npx prisma migrate deploy` (applies to dev `app` DB) → resume. Backend tsc is red between Task 1 and Task 2 (data model changes before the service is updated) — that is expected; Task 2 restores green.
---
## Task 1: Data model + migration
**Files:**
- Modify: `prisma/schema.prisma`
- Create: `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`
- [ ] **Step 1: Edit `prisma/schema.prisma`** — replace the existing `ai_chat_messages` model and add `ai_conversations` above it:
```prisma
model ai_conversations {
id Int @id @default(autoincrement())
user_id Int
title String @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
messages ai_chat_messages[]
@@index([user_id, id], map: "idx_ai_conv_user")
}
model ai_chat_messages {
id Int @id @default(autoincrement())
user_id Int
conversation_id Int
role String @db.VarChar(20)
content String @db.Text
created_at DateTime @default(now()) @db.Timestamp(0)
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
@@index([conversation_id, id], map: "idx_ai_chat_conv")
}
```
- [ ] **Step 2: Write the migration SQL** at `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`:
```sql
-- Multi-conversation Odin: conversations table + conversation_id on messages.
CREATE TABLE `ai_conversations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`user_id` INTEGER NOT NULL,
`title` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`),
INDEX `idx_ai_conv_user` (`user_id`, `id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `ai_chat_messages`
ADD COLUMN `conversation_id` INTEGER NULL AFTER `user_id`;
-- Backfill: one conversation per user who has messages ("keep as first conversation").
INSERT INTO `ai_conversations` (`user_id`, `title`, `created_at`, `updated_at`)
SELECT `user_id`, 'Konverzace', NOW(), NOW()
FROM `ai_chat_messages`
GROUP BY `user_id`;
UPDATE `ai_chat_messages` m
JOIN `ai_conversations` c ON c.`user_id` = m.`user_id`
SET m.`conversation_id` = c.`id`;
-- Enforce: drop old per-user index, NOT NULL, FK (cascade), per-conversation index.
ALTER TABLE `ai_chat_messages`
DROP INDEX `idx_ai_chat_user`,
MODIFY `conversation_id` INTEGER NOT NULL,
ADD CONSTRAINT `fk_ai_chat_conv` FOREIGN KEY (`conversation_id`)
REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE,
ADD INDEX `idx_ai_chat_conv` (`conversation_id`, `id`);
```
- [ ] **Step 3: Controller applies the migration** (not the implementer):
- Ask user to stop the dev server (Prisma engine DLL is locked on Windows otherwise).
- `npx prisma generate` — expect "Generated Prisma Client".
- `npx prisma migrate deploy` — expect "Applying migration `20260608140000_add_ai_conversations`" → "successfully applied".
- [ ] **Step 4: Commit**
```bash
git add prisma/schema.prisma prisma/migrations/20260608140000_add_ai_conversations
git commit -m "feat(odin): ai_conversations table + conversation_id on messages"
```
---
## Task 2: Backend — conversation service, schemas, routes
**Files:**
- Modify: `src/services/ai.service.ts`
- Modify: `src/schemas/ai.schema.ts`
- Modify: `src/routes/admin/ai.ts`
- [ ] **Step 1: Replace the chat-history block in `ai.service.ts`.** Delete `getChatHistory`, `appendChatMessages`, `clearChatHistory` and the `HISTORY_LIMIT` const, and put this in their place (keep the `StoredChatMessage` interface):
```ts
// ── Conversations (server-side, per user) ─────────────────────────────────
export interface StoredChatMessage {
role: string;
content: string;
created_at: Date;
}
export interface ConversationSummary {
id: number;
title: string;
updated_at: Date;
}
const DEFAULT_CONVERSATION_TITLE = "Nová konverzace";
const MESSAGE_LIMIT = 200; // cap a single thread read
export async function listConversations(
userId: number,
): Promise<ConversationSummary[]> {
return prisma.ai_conversations.findMany({
where: { user_id: userId },
orderBy: { id: "asc" }, // stable tab order
select: { id: true, title: true, updated_at: true },
});
}
export async function createConversation(
userId: number,
title?: string,
): Promise<ConversationSummary> {
return prisma.ai_conversations.create({
data: {
user_id: userId,
title: title?.trim() || DEFAULT_CONVERSATION_TITLE,
},
select: { id: true, title: true, updated_at: true },
});
}
/** Ownership-checked fetch; null when not found / not owned. */
async function ownConversation(userId: number, convId: number) {
return prisma.ai_conversations.findFirst({
where: { id: convId, user_id: userId },
select: { id: true, title: true },
});
}
export async function getConversationMessages(
userId: number,
convId: number,
): Promise<{ data: StoredChatMessage[] } | { error: string; status: number }> {
if (!(await ownConversation(userId, convId)))
return { error: "Konverzace nenalezena", status: 404 };
const rows = await prisma.ai_chat_messages.findMany({
where: { conversation_id: convId },
orderBy: { id: "desc" },
take: MESSAGE_LIMIT,
select: { role: true, content: true, created_at: true },
});
return { data: rows.reverse() };
}
export async function appendConversationMessages(
userId: number,
convId: number,
messages: { role: string; content: string }[],
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
const conv = await ownConversation(userId, convId);
if (!conv) return { error: "Konverzace nenalezena", status: 404 };
if (messages.length > 0) {
await prisma.ai_chat_messages.createMany({
data: messages.map((m) => ({
user_id: userId,
conversation_id: convId,
role: m.role,
content: m.content,
})),
});
}
// Auto-title from the first user message (only while still the default), and
// always bump updated_at.
const firstUser = messages.find((m) => m.role === "user");
const data: { updated_at: Date; title?: string } = { updated_at: new Date() };
if (conv.title === DEFAULT_CONVERSATION_TITLE && firstUser) {
const t = firstUser.content.replace(/\s+/g, " ").trim().slice(0, 60);
if (t) data.title = t;
}
await prisma.ai_conversations.update({ where: { id: convId }, data });
return { data: { ok: true } };
}
export async function renameConversation(
userId: number,
convId: number,
title: string,
): Promise<{ data: ConversationSummary } | { error: string; status: number }> {
if (!(await ownConversation(userId, convId)))
return { error: "Konverzace nenalezena", status: 404 };
const updated = await prisma.ai_conversations.update({
where: { id: convId },
data: { title: title.trim() || DEFAULT_CONVERSATION_TITLE },
select: { id: true, title: true, updated_at: true },
});
return { data: updated };
}
export async function deleteConversation(
userId: number,
convId: number,
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
if (!(await ownConversation(userId, convId)))
return { error: "Konverzace nenalezena", status: 404 };
await prisma.ai_conversations.delete({ where: { id: convId } }); // cascade
return { data: { ok: true } };
}
```
- [ ] **Step 2: Schemas** — in `src/schemas/ai.schema.ts`, keep `AiHistoryAppendSchema` (reused for append) and add:
```ts
export const AiCreateConversationSchema = z.object({
title: z.string().max(255).optional(),
});
export const AiRenameConversationSchema = z.object({
title: z.string().min(1, "Název je povinný").max(255),
});
```
- [ ] **Step 3: Routes** — in `src/routes/admin/ai.ts`: update imports (drop `getChatHistory/appendChatMessages/clearChatHistory`, add the new fns; add `AiCreateConversationSchema, AiRenameConversationSchema`; import `parseId` from `"../../schemas/common"`). Delete the three `/history` route blocks and append these (all `requirePermission("ai.use")`):
```ts
// GET /conversations — this user's conversations (stable order)
app.get(
"/conversations",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const conversations = await listConversations(request.authData!.userId);
return success(reply, { conversations });
},
);
// POST /conversations — create
app.post(
"/conversations",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const body = parseBody(AiCreateConversationSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const conversation = await createConversation(
request.authData!.userId,
body.data.title,
);
return success(reply, { conversation });
},
);
// GET /conversations/:id/messages
app.get(
"/conversations/:id/messages",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const result = await getConversationMessages(request.authData!.userId, id);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, { messages: result.data });
},
);
// POST /conversations/:id/messages — append turns
app.post(
"/conversations/:id/messages",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(AiHistoryAppendSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const result = await appendConversationMessages(
request.authData!.userId,
id,
body.data.messages,
);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, result.data);
},
);
// PATCH /conversations/:id — rename
app.patch(
"/conversations/:id",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(AiRenameConversationSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const result = await renameConversation(
request.authData!.userId,
id,
body.data.title,
);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, { conversation: result.data });
},
);
// DELETE /conversations/:id
app.delete(
"/conversations/:id",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const result = await deleteConversation(request.authData!.userId, id);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, result.data);
},
);
```
- [ ] **Step 4: Gate**
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success.
- [ ] **Step 5: Commit**
```bash
git add src/services/ai.service.ts src/schemas/ai.schema.ts src/routes/admin/ai.ts
git commit -m "feat(odin): conversation-scoped service + routes (replace /history)"
```
---
## Task 3: Backend tests
**Files:**
- Modify: `src/__tests__/ai.test.ts`
- [ ] **Step 1: Replace the imports + cleanup markers.** Swap the history-fn imports for the conversation fns, and update the top-of-file constants/cleanup:
```ts
import {
computeCostUsd,
recordUsage,
getMonthSpendUsd,
getBudgetUsd,
assertBudgetAvailable,
isConfigured,
listConversations,
createConversation,
getConversationMessages,
appendConversationMessages,
renameConversation,
deleteConversation,
} from "../services/ai.service";
const KIND = "test_ai";
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
const CONV_UID_B = 999999992;
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
```
Update the top-level cleanup (`afterAll` and the conversation `beforeEach`) to delete by these. Deleting a conversation cascades its messages:
```ts
afterAll(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
});
await prisma.ai_conversations.deleteMany({
where: { title: { contains: HIST_MARKER } },
});
});
```
- [ ] **Step 2: Replace the `describe("ai.service chat history", …)` block** with conversation service tests:
```ts
describe("ai.service conversations", () => {
beforeEach(async () => {
await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
});
});
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
const conv = await createConversation(CONV_UID_A);
expect(conv.title).toBe("Nová konverzace");
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "Kolik je hodin v Praze?" },
{ role: "assistant", content: "Nevím, ale…" },
]);
const list = await listConversations(CONV_UID_A);
expect(list).toHaveLength(1);
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
expect("data" in msgs && msgs.data.map((m) => m.content)).toEqual([
"Kolik je hodin v Praze?",
"Nevím, ale…",
]);
});
it("does not overwrite a renamed title on later appends", async () => {
const conv = await createConversation(CONV_UID_A);
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "tohle by nemělo přepsat název" },
]);
const list = await listConversations(CONV_UID_A);
expect(list[0].title).toBe("Moje téma");
});
it("isolates conversations per user (404 across owners)", async () => {
const a = await createConversation(CONV_UID_A);
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
const read = await getConversationMessages(CONV_UID_B, a.id);
expect("error" in read && read.status).toBe(404);
const ren = await renameConversation(CONV_UID_B, a.id, "hack");
expect("error" in ren && ren.status).toBe(404);
const del = await deleteConversation(CONV_UID_B, a.id);
expect("error" in del && del.status).toBe(404);
});
it("cascade-deletes messages with the conversation", async () => {
const conv = await createConversation(CONV_UID_A);
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "x" },
]);
await deleteConversation(CONV_UID_A, conv.id);
const remaining = await prisma.ai_chat_messages.count({
where: { conversation_id: conv.id },
});
expect(remaining).toBe(0);
});
});
```
- [ ] **Step 3: Replace the HTTP history tests** (the `/history` `it(...)` blocks) with conversation HTTP tests inside the existing `describe("HTTP /api/admin/ai", …)`:
```ts
it("GET /conversations requires ai.use", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
});
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
const created = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} t` },
});
expect(created.statusCode).toBe(200);
const id = created.json().data.conversation.id as number;
const app2 = await app.inject({
method: "POST",
url: `/api/admin/ai/conversations/${id}/messages`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [{ role: "user", content: "ahoj" }] },
});
expect(app2.statusCode).toBe(200);
const msgs = await app.inject({
method: "GET",
url: `/api/admin/ai/conversations/${id}/messages`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(
msgs.json().data.messages.map((m: { content: string }) => m.content),
).toContain("ahoj");
const renamed = await app.inject({
method: "PATCH",
url: `/api/admin/ai/conversations/${id}`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} renamed` },
});
expect(renamed.json().data.conversation.title).toBe(`${HIST_MARKER} renamed`);
const del = await app.inject({
method: "DELETE",
url: `/api/admin/ai/conversations/${id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(del.statusCode).toBe(200);
});
it("GET messages of a non-existent conversation → 404", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations/999999000/messages",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
});
```
- [ ] **Step 4: Gate**
Run: `npx vitest run src/__tests__/ai.test.ts` — expect all passing. Then `npx vitest run` — expect the whole suite green.
- [ ] **Step 5: Commit**
```bash
git add src/__tests__/ai.test.ts
git commit -m "test(odin): conversation CRUD, isolation, auto-title, cascade"
```
---
## Task 4: Frontend queries + types
**Files:**
- Modify: `src/admin/lib/queries/ai.ts`
- [ ] **Step 1: Add conversation options.** Keep everything (incl. `aiHistoryOptions` for now — it's removed in Task 6 alongside its only consumer `DashAssistant.tsx`, so every task stays green). Add:
```ts
export interface Conversation {
id: number;
title: string;
updated_at: string;
}
export const aiConversationsOptions = () =>
queryOptions({
queryKey: ["ai", "conversations"],
queryFn: () =>
jsonQuery<{ conversations: Conversation[] }>(
"/api/admin/ai/conversations",
),
staleTime: 30_000,
});
export const aiConversationMessagesOptions = (id: number) =>
queryOptions({
queryKey: ["ai", "conversation", id, "messages"],
queryFn: () =>
jsonQuery<{ messages: StoredChatMessage[] }>(
`/api/admin/ai/conversations/${id}/messages`,
),
// Re-read the DB on each mount/selection (no stale-cache resurrection).
staleTime: Infinity,
gcTime: 0,
});
```
- [ ] **Step 2: Gate**
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (we kept `aiHistoryOptions`, so `DashAssistant.tsx` still compiles).
- [ ] **Step 3: Commit**
```bash
git add src/admin/lib/queries/ai.ts
git commit -m "feat(odin): conversation query options"
```
---
## Task 5: Frontend presentational components
**Files:**
- Create: `src/admin/components/odin/OdinTabs.tsx`
- Create: `src/admin/components/odin/OdinThread.tsx`
- Create: `src/admin/components/odin/InvoiceReviewCard.tsx`
- Create: `src/admin/components/odin/OdinComposer.tsx`
These are **props-driven** (no data fetching). Port presentational details (bubble styling, the `Typography`-color fix, review-card fields, staged chips) from the current `src/admin/components/dashboard/DashAssistant.tsx`, which the implementer should read first.
- [ ] **Step 1: Shared types** — define at the top of `OdinChat.tsx` (Task 6) and import where needed, OR duplicate the tiny `ChatTurn` shape. Use these exact shapes across files:
```ts
export interface ChatTurn {
role: "user" | "assistant";
content: string;
}
export interface ReviewInvoice {
uid: string;
supplier_name: string;
invoice_number: string;
amount: string;
currency: string;
vat_rate: string;
issue_date: string;
due_date: string;
description: string;
file_name: string;
file: File;
}
export type EditableField =
| "supplier_name"
| "invoice_number"
| "amount"
| "currency"
| "vat_rate"
| "issue_date"
| "due_date"
| "description";
export interface StagedFile {
id: string;
file: File;
}
```
Put these in a new `src/admin/components/odin/types.ts` and import from there in every odin file.
- [ ] **Step 2: `OdinTabs.tsx`** — props and behavior:
```tsx
interface OdinTabsProps {
conversations: { id: number; title: string }[];
activeId: number | null;
busy: boolean;
onSelect: (id: number) => void;
onNew: () => void;
onRename: (id: number, title: string) => void;
onDelete: (id: number) => void;
}
```
Render a horizontally-scrollable row (`Box` with `overflowX: "auto"`, `display: flex`, `gap`). Each conversation is a clickable pill (`Button` size small; active = `contained`, else `outlined color="inherit"`). After the pills, a `+` `Button` calls `onNew`. The **active** pill shows a small `⋮` `IconButton` opening an `@mui/material` `Menu` with "Přejmenovat" and "Smazat". "Přejmenovat" opens the kit `Modal` containing a `TextField` (prefilled with current title) → on submit calls `onRename`. "Smazat" opens the kit `ConfirmDialog` → on confirm calls `onDelete`. All actions disabled while `busy`.
- [ ] **Step 3: `OdinThread.tsx`** — props:
```tsx
interface OdinThreadProps {
turns: ChatTurn[];
busy: boolean;
loading: boolean;
threadRef: React.RefObject<HTMLDivElement>;
}
```
Render the scrolling message area (`flex: 1, minHeight: 0, overflowY: "auto"`). Assistant turns left with a small circular Odin avatar (`Box` 28px, `borderRadius: "50%"`, `bgcolor: "primary.main"`, white "O"); user turns right-aligned `primary.main` bubble. **Message colour MUST be on the `Typography`** (`color: user ? "common.white" : "text.primary"`) — GlobalStyles pins `p` to text.secondary. Empty state (no turns, not busy, not loading): centered Odin avatar + "Dobrý den, jsem Odin. Zeptejte se, nebo přiložte fakturu (PDF) k importu." When `loading`: "Načítám…". When `busy`: a `CircularProgress size={14}` + "Pracuji…" row.
- [ ] **Step 4: `InvoiceReviewCard.tsx`** — props:
```tsx
interface InvoiceReviewCardProps {
inv: ReviewInvoice;
busy: boolean;
onChange: (uid: string, field: EditableField, value: string) => void;
onSave: (inv: ReviewInvoice) => void;
onDiscard: (uid: string) => void;
}
```
Port the card body from `DashAssistant.tsx` verbatim (the `Chip "Faktura"` + filename header, the 2-col grid of `TextField`s including **"Částka s DPH"**, "Datum splatnosti", "Popis", and the Uložit / Zahodit buttons). Replace `patch(...)``onChange(...)`, `saveInvoice``onSave`, the discard filter→`onDiscard(inv.uid)`.
- [ ] **Step 5: `OdinComposer.tsx`** — props:
```tsx
interface OdinComposerProps {
input: string;
attachments: StagedFile[];
busy: boolean;
canSubmit: boolean;
fileRef: React.RefObject<HTMLInputElement>;
onInput: (v: string) => void;
onFiles: (files: FileList | null) => void;
onRemoveAttachment: (id: string) => void;
onSubmit: () => void;
}
```
Port the staged-attachment chips + the composer row (hidden file `input`, "Přiložit" button, `TextField` with Enter-to-submit, "Odeslat" button) from `DashAssistant.tsx`. Disable per `busy` / `!canSubmit`.
- [ ] **Step 6: Gate**
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (these compile independently of the orchestrator). If a component is unused at this point, that is fine — Task 6 wires them.
- [ ] **Step 7: Commit**
```bash
git add src/admin/components/odin
git commit -m "feat(odin): tabs, thread, review-card, composer components"
```
---
## Task 6: OdinChat orchestrator + page + delete DashAssistant
**Files:**
- Create: `src/admin/components/odin/OdinChat.tsx`
- Modify: `src/admin/pages/Odin.tsx`
- Modify: `src/admin/lib/queries/ai.ts` (remove `aiHistoryOptions`)
- Delete: `src/admin/components/dashboard/DashAssistant.tsx`
- [ ] **Step 1: Write `OdinChat.tsx`.** It owns all state + queries + the ported submit/extract/save logic, scoped to the active conversation. Port the helper fns (`summaryNote`, `nextUid`, `plural`, `MODEL_CONTEXT`, `MAX_STORE`) and the **submit / saveInvoice / onFiles / persist** logic from `DashAssistant.tsx` with these adaptations:
- **Queries:** `usage` (unchanged), `aiConversationsOptions()`, and `aiConversationMessagesOptions(activeId)` (enabled when `activeId != null`).
- **State:** `activeId: number | null`, plus `input`, `busy`, `turns`, `review`, `attachments`, `fileRef`, `threadRef`.
- **Ensure a conversation exists:** when the conversations query resolves: if empty, `POST /conversations` to create a default and `setActiveId(new.id)` + invalidate `["ai","conversations"]`; else if `activeId == null`, `setActiveId(list[0].id)`.
- **Seed per conversation:** a `seededId = useRef<number|null>(null)`. Effect keyed on `[activeId, messagesData]`: when `messagesData` for the active id arrives and `seededId.current !== activeId`, set `seededId.current = activeId`, `setTurns(messagesData.messages.map(m => ({role:m.role, content:m.content})))`, `setReview([])`.
- **Switch tab (`onSelect`):** `setActiveId(id); setTurns([]); setReview([]); seededId.current = null;` (force re-seed; the messages query for the new id loads and seeds).
- **`persist(msgs)`** posts to `/api/admin/ai/conversations/${activeId}/messages` (was `/history`); best-effort + `console.error` on failure.
- **`submit`:** identical to `DashAssistant.submit` EXCEPT it first ensures `activeId` (if null, await-create one and use it); marks `seededId.current = activeId`; on success persists via the conversation endpoint; on error rolls back + keeps input/attachments. Gate `canSubmit` on `!conversationsPending && activeId != null && !busy && (input.trim() || attachments.length)`.
- **`onNew`:** `POST /conversations` → select it (`setActiveId`, clear turns/review, `seededId.current=null`) + invalidate `["ai","conversations"]`.
- **`onRename(id, title)`:** `PATCH /conversations/:id` → invalidate `["ai","conversations"]`.
- **`onDelete(id)`:** `DELETE /conversations/:id` → invalidate `["ai","conversations"]`; if `id === activeId`, pick another conversation from the (refetched) list or null.
- **Layout:** a full-height shell — `Box sx={{ height: "calc(100dvh - 140px)", display: "flex", flexDirection: "column" }}` containing: header (`Odin` + budget `Utraceno: $x / $y`), `<OdinTabs … />`, `<OdinThread … />` (flex 1), the review list (`review.map(inv => <InvoiceReviewCard … />)` in a bounded `maxHeight: "35vh", overflowY: "auto"` box), and `<OdinComposer … />`.
- **Self-hide:** keep `if (usage && usage.configured === false) return null;`.
Reference the exact bodies of `submit`, `saveInvoice`, `onFiles`, `summaryNote`, `nextUid` in the current `DashAssistant.tsx` (lines ~52333) — copy them, applying only the adaptations above.
- [ ] **Step 2: Update `src/admin/pages/Odin.tsx`** — swap the import and render:
```tsx
import OdinChat from "../components/odin/OdinChat";
// …
<Box sx={{ maxWidth: 1100, mx: "auto" }}>
<OdinChat />
</Box>;
```
- [ ] **Step 3: Delete `src/admin/components/dashboard/DashAssistant.tsx`** and remove the now-orphaned `aiHistoryOptions` from `src/admin/lib/queries/ai.ts` (DashAssistant was its only consumer).
```bash
git rm src/admin/components/dashboard/DashAssistant.tsx
```
- [ ] **Step 4: Gate**
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success. Run: `npx vitest run` — expect the full suite green.
- [ ] **Step 5: Commit**
```bash
git add src/admin/components/odin/OdinChat.tsx src/admin/pages/Odin.tsx src/admin/lib/queries/ai.ts
git commit -m "feat(odin): OdinChat orchestrator; render on /odin; remove DashAssistant"
```
---
## Final verification (controller)
- `npx tsc -b --noEmit` = 0, `npm run build` = success, `npx vitest run` = all green.
- Manual (user/controller): create a conversation, send a chat, attach a PDF → review → save, rename a tab, delete a tab, reload (history persists per conversation), switch tabs (each keeps its own thread).
- Adversarial review pass (workflow) before merge/release.

File diff suppressed because it is too large Load Diff

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.

View File

@@ -0,0 +1,190 @@
# Design — AI assistant, Phase 1 (chat + invoice import + budget cap)
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation plan
**Phase 1 of 2.** Phase 2 (the AI querying system data via read-tools) is a separate later cycle — see "Deferred: Phase 2" at the end.
---
## Goal
Add the app's first AI capability: an **admin-only chat assistant embedded on the
dashboard** where you can talk to Claude and, crucially, **attach received-invoice
PDFs that the AI reads and imports into Přijaté faktury** (extract → you confirm →
save). All AI usage is bounded by an **admin-editable monthly budget (default
$50)** that hard-stops calls when exhausted.
## Decisions (locked in brainstorming)
- **Scope (Phase 1):** chat + invoice import + budget cap. **No system-data
access** (the AI cannot query your invoices/projects/attendance — that's Phase 2).
- **Placement:** a `DashAssistant` widget on the **dashboard, at the top under the
welcome text** — not a separate page, not a floating bubble.
- **Access:** **admins only**, via a new `ai.use` permission.
- **Model:** **Claude Sonnet 4.6** (strong vision, low cost).
- **Save flow:** extract → **you confirm/edit → save** (never silent auto-save).
- **Budget:** **$50/month**, resets each calendar month, **admin-editable in
Settings**; AI calls are **blocked** once the month's spend reaches the budget.
- **Chat:** **non-streaming** (request → full reply) and **in-browser history
only** (not persisted server-side) for v1 — both are easy later upgrades.
## Non-goals / out of scope (Phase 1)
- No system-data query tools / function-calling (Phase 2).
- No server-side conversation persistence, no streaming.
- No new invoice storage path — reuses `POST /received-invoices` verbatim.
---
## Architecture
Greenfield AI integration; the rest of the app is unaffected and works unchanged
if `ANTHROPIC_API_KEY` is absent (the assistant widget simply doesn't render and
the AI routes return a clear "not configured" error).
- **Dependency:** `@anthropic-ai/sdk`.
- **Env:** `ANTHROPIC_API_KEY` (required for the feature). Optional
`AI_MONTHLY_BUDGET_USD` default seed (the live value lives in the DB, below).
- **`src/services/ai.service.ts`** — wraps the SDK, owns the model choice + system
prompt + cost accounting. Two operations:
- `chat(messages)``client.messages.create({ model: "claude-sonnet-4-6", … })`,
non-streaming, returns `{ reply: string, usage }`.
- `extractInvoice(pdfBuffer, fileName)``client.messages.create` with the PDF as
a `document` content block + `output_config.format` JSON-schema for the
received-invoice fields, returns `{ fields, usage }`.
- Both call the spend tracker to record usage **and** are gated by the budget
guard (below) BEFORE the API call.
- Isolated so tests can stub the Anthropic call (no real API in CI).
## Spend cap (the $50/month budget)
- **New table `ai_usage`** (migration): `id`, `user_id?`, `kind` ("chat" |
"extract"), `model`, `input_tokens`, `output_tokens`, `cost_usd`
`@db.Decimal(10,6)`, `created_at @db.Timestamp(0)`, with an index on
`created_at`.
- **Cost map** (in `ai.service.ts`): model → `{ inputPerToken, outputPerToken }`.
Sonnet 4.6 = $3 / $15 per 1M → `0.000003` / `0.000015` per token. `cost_usd =
in*inP + out*outP`.
- **Budget value:** a new `ai_monthly_budget_usd Decimal @default(50)` column on
`company_settings` (the existing app-config singleton). Admin-editable in
Settings.
- **Budget guard `assertBudgetAvailable()`:** sum `cost_usd` for rows where
`created_at >= startOfMonth(now)`; if `>= ai_monthly_budget_usd`, return
`{ error: "Měsíční rozpočet AI byl vyčerpán", status: 402 }`. Called at the top
of every AI route BEFORE the Claude call.
- **Usage read:** `GET /api/admin/ai/usage` → `{ month_spend_usd, budget_usd,
remaining_usd }` for the chat's budget indicator and the Settings page.
## Backend routes — `src/routes/admin/ai.ts`
All guarded by `requirePermission("ai.use")`.
- `POST /api/admin/ai/chat` — body `{ messages: {role, content}[] }`. Budget guard →
`ai.service.chat` → log usage → `success(reply: string, remaining_usd)`.
- `POST /api/admin/ai/extract-invoices` — multipart PDF files (≤ `MAX_UPLOAD_SIZE`,
PDF/image only). Budget guard → `extractInvoice` per file → returns the extracted
fields per file (NOT saved) + usage. The files are **not** persisted here.
- `GET /api/admin/ai/usage` — current-month spend + budget + remaining.
If `ANTHROPIC_API_KEY` is unset, the POST routes return
`{ error: "AI není nakonfigurováno", status: 503 }`.
## Invoice import flow (extract → confirm → save)
1. Admin attaches one or more invoice PDFs in the dashboard chat (optionally with a
text note).
2. Frontend posts the files to `POST /ai/extract-invoices`; the AI returns the
extracted fields for each.
3. The chat renders an **editable review card per invoice** (dodavatel, číslo
faktury, částka, měna, sazba DPH, datum vystavení/splatnosti, poznámka),
pre-filled with the AI's values, plus a duplicate hint if `invoice_number` +
`supplier_name` already exists.
4. Admin glances/edits → **"Uložit"** (per card) or **"Uložit vše"**.
5. The frontend submits **the original File + the confirmed metadata** to the
**existing `POST /api/admin/received-invoices`** (multipart) — same NAS storage,
same server-side VAT recompute, same audit. The AI never writes invoices
directly. (The PDF is uploaded twice — once to extract, once to save — which is
fine for invoice-sized files and avoids any server-side temp-file handling.)
## Frontend
- **`src/admin/components/dashboard/DashAssistant.tsx`** — the chat widget:
message list, input box, attach-file button, and a small budget indicator
("Rozpočet: $X.XX / $50"). Non-streaming; conversation in component state only.
Renders the invoice review cards inline (reusing the received-invoice field
inputs / kit components).
- **`src/admin/pages/Dashboard.tsx`** — render `<DashAssistant />` at the top,
directly under the welcome text, **only when** the user is admin / has `ai.use`
(and only when AI is configured — a `GET /ai/usage` 503 hides it gracefully).
- **`src/admin/lib/queries/ai.ts`** — React Query options/mutations for chat,
extract, and usage. On a successful invoice save, invalidate `["received-invoices"]`.
- **Settings** — an admin field to set the monthly budget (writes
`company_settings.ai_monthly_budget_usd`).
## Permissions & migration
One Prisma migration:
- Create `ai_usage` table.
- Add `ai_monthly_budget_usd` to `company_settings` (default 50).
- Insert the `ai.use` permission and grant it to the **admin** role (explicit
`INSERT`s in `migration.sql`, per the project's migration policy).
(The admin role bypasses permission checks via `roleName === "admin"`, but the
explicit `ai.use` permission lets the access be widened later without code changes
and documents intent.)
## Security / privacy
- **Phase 1 has no tools**, so the AI cannot take actions on your data — its only
effect is the human-confirmed invoice save. A malicious/auto-generated invoice
PDF therefore can't trigger anything (the injection-to-action surface arrives in
Phase 2 and is designed there).
- Attached PDFs are sent to the Anthropic API to be read. The API key lives in
`.env` (server-side only), never exposed to the browser.
- Standard sanitization already applies on the received-invoice render/PDF paths;
AI-extracted text flows through the same validated `received_invoices` create.
## Testing
Server-side, real test DB, **Anthropic call stubbed** (no real API in CI):
- **Spend tracker:** `cost_usd` computed correctly from tokens for Sonnet 4.6;
monthly sum only counts the current month.
- **Budget guard:** under budget → allowed; at/over budget → `402` with the Czech
message; the guard reads the DB budget value.
- **Permissions:** all `/ai/*` routes return `403` without `ai.use`.
- **Not-configured:** with no `ANTHROPIC_API_KEY`, POST routes return `503`.
- **extract field-mapping:** given a stubbed AI response, the route returns the
expected `received_invoices` field shape (and computes nothing that the existing
create doesn't already).
Frontend has no component-test harness → gated by `tsc -b` + `build` + a manual
check (the dashboard widget renders for admins, hidden otherwise).
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
## Rollout
Ships as **v2.1.0** (notable new capability) via the standard process. **Requires a
migration**, so the dev server must be stopped for `prisma migrate dev` (ask first),
and the release runs `prisma migrate deploy` on prod. `ANTHROPIC_API_KEY` must be
set in the production `.env` before the feature works.
---
## Deferred: Phase 2 (system-data access) — captured
A later, separate spec + cycle. The AI gains **read-only tools** (function calling)
to answer questions about your data — e.g. "co je po splatnosti?", "kolik jsme
fakturovali v květnu?". Key design work for Phase 2:
- A curated set of **read tools** (search invoices, overdue list, project lookup,
attendance summary, …), each **scoped to the calling user's permissions** so the
AI never returns data the user can't see.
- The **agentic tool-loop** (Claude requests a tool → app executes against Prisma →
returns results → Claude continues), with a per-conversation tool-call cap to
bound cost (within the same monthly budget).
- **Prompt-injection hardening:** because the AI will both read untrusted invoice
content _and_ hold tools, tools stay **read-only**, results are treated as data,
and any state-changing action remains human-confirmed.

View File

@@ -0,0 +1,218 @@
# Odin — Multi-Conversation AI Chat — Design
**Date:** 2026-06-08
**Status:** Approved (design), pending spec review
## Goal
Replace the single-thread Odin assistant with a multi-conversation chat: a
purpose-built UI on the `/odin` page where the user keeps several named
conversations (like topics), switches between them via top tabs, and can
rename or delete them. All existing assistant capabilities (chat, PDF invoice
extraction → review → save, monthly budget) carry over unchanged.
## Background (current state)
- `ai_chat_messages` is a single rolling thread per user (`user_id, role,
content, created_at`). Endpoints `GET/POST/DELETE /api/admin/ai/history`
read/append/clear that one thread.
- The chat lives in `DashAssistant.tsx`, rendered on the `/odin` page (moved
off the dashboard in the prior change, branch `feat/odin-page`).
- Stateless AI endpoints (`/chat`, `/extract-invoices`, `/usage`, `/budget`)
are unaffected by conversations — they don't persist anything themselves.
## Decisions (from brainstorming)
- **Titling:** new conversation auto-titles from its first user message; user
can rename.
- **Per-conversation actions:** rename + delete.
- **Existing history:** migrate the current single thread into one conversation
per user ("keep as first conversation"); nothing is lost.
- **Layout:** top tabs + chat (not a left sidebar).
## Data model
### `ai_conversations` (new)
| column | type | notes |
| ---------- | --------------------- | ----------------------------------- |
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL | owner; isolation key |
| title | VARCHAR(255) NOT NULL | default "Nová konverzace" |
| created_at | TIMESTAMP(0) NOT NULL | default CURRENT_TIMESTAMP |
| updated_at | TIMESTAMP(0) NOT NULL | bumped on each append; sort/recency |
Index: `idx_ai_conv_user (user_id, id)`.
### `ai_chat_messages` (modified)
- Add `conversation_id INT NOT NULL` (FK → `ai_conversations(id)` `ON DELETE
CASCADE`). Keep `user_id` (denormalized, harmless; simplifies cleanup).
- Replace index `idx_ai_chat_user (user_id, id)` with
`idx_ai_chat_conv (conversation_id, id)` (messages are read per conversation).
### Prisma
```prisma
model ai_conversations {
id Int @id @default(autoincrement())
user_id Int
title String @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
messages ai_chat_messages[]
@@index([user_id, id], map: "idx_ai_conv_user")
}
model ai_chat_messages {
id Int @id @default(autoincrement())
user_id Int
conversation_id Int
role String @db.VarChar(20)
content String @db.Text
created_at DateTime @default(now()) @db.Timestamp(0)
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
@@index([conversation_id, id], map: "idx_ai_chat_conv")
}
```
### Migration (hand-written SQL; one tracked migration)
1. `CREATE TABLE ai_conversations (...)`.
2. `ALTER TABLE ai_chat_messages ADD COLUMN conversation_id INT NULL AFTER user_id`.
3. Backfill one conversation per user who has messages:
`INSERT INTO ai_conversations (user_id, title, created_at, updated_at)
SELECT user_id, 'Konverzace', NOW(), NOW() FROM ai_chat_messages GROUP BY user_id;`
4. Point messages at it:
`UPDATE ai_chat_messages m JOIN ai_conversations c ON c.user_id = m.user_id
SET m.conversation_id = c.id;`
5. Drop the old index, enforce NOT NULL, add the FK + new index:
`ALTER TABLE ai_chat_messages DROP INDEX idx_ai_chat_user,
MODIFY conversation_id INT NOT NULL,
ADD CONSTRAINT fk_ai_chat_conv FOREIGN KEY (conversation_id)
REFERENCES ai_conversations(id) ON DELETE CASCADE,
ADD INDEX idx_ai_chat_conv (conversation_id, id);`
The backfill is a no-op when `ai_chat_messages` is empty (prod today), and
preserves the dev thread.
## Service layer (`ai.service.ts`)
Replace the single-thread helpers (`getChatHistory` / `appendChatMessages` /
`clearChatHistory`) with conversation-scoped ones. Every function takes
`userId` and verifies the conversation belongs to the user.
- `listConversations(userId)` → `{id, title, updated_at}[]`, ordered by `id` asc
(stable tab order).
- `createConversation(userId, title?)` → the new conversation (default title
"Nová konverzace").
- `getConversation(userId, id)` → conversation or null (ownership check helper).
- `getConversationMessages(userId, id)` → `StoredChatMessage[]` oldest→newest,
or `{ error, status }` if not owned/found.
- `appendConversationMessages(userId, id, msgs)` → append; **bump `updated_at`**;
**auto-title**: if the conversation's title is still the default and this batch
has a user message, set `title` = first ~60 chars of that user message.
- `renameConversation(userId, id, title)` / `deleteConversation(userId, id)` —
ownership-checked; delete cascades messages.
Auto-title keeps titling server-side and robust (no client title logic).
## Routes (`routes/admin/ai.ts`, all `requirePermission("ai.use")`)
| Method | Path | Body / result |
| ------ | ----------------------------- | ------------------------------------------ |
| GET | `/conversations` | → `{ conversations: [...] }` |
| POST | `/conversations` | `{ title? }` → `{ conversation }` |
| GET | `/conversations/:id/messages` | → `{ messages: [...] }` (404 if not owned) |
| POST | `/conversations/:id/messages` | `{ messages:[{role,content}] }` (1..20) |
| PATCH | `/conversations/:id` | `{ title }` (1..255) |
| DELETE | `/conversations/:id` | → `{ ok: true }` |
`:id` parsed via `parseId`; bodies via `parseBody`; ownership 404 from the
service surfaced with `error(reply, ...)`. The old `/history` routes are removed.
`/chat`, `/extract-invoices`, `/usage`, `/budget` are unchanged.
## Frontend
### Queries (`lib/queries/ai.ts`)
- `aiConversationsOptions()` → `["ai","conversations"]`, `GET /conversations`.
- `aiConversationMessagesOptions(id)` → `["ai","conversation",id,"messages"]`,
`GET /conversations/:id/messages`, `gcTime: 0` (each mount re-reads the DB,
same rationale as today: no stale-cache resurrection).
- Types: `Conversation { id, title, updated_at }`, reuse `StoredChatMessage`.
### Components (`src/admin/components/odin/`)
- **`OdinChat.tsx`** — orchestrator. Owns: active conversation id, input, busy,
staged attachments, review list. Loads the conversation list; ensures one
exists (creates a default if the user has none); loads the active thread;
routes send/extract/save through the active conversation. Full-height flex
layout: header → tabs → thread (flex-grow, scroll) → review → composer.
- **`OdinTabs.tsx`** — horizontally-scrollable tabs (one per conversation) +
a "+" new-chat button + a ⋮ menu on the active tab (Přejmenovat / Smazat).
Rename uses a small Modal with a text field; delete uses ConfirmDialog.
- **`OdinThread.tsx`** — message list with an Odin avatar on assistant turns,
user turns right-aligned; the "Pracuji…" indicator; empty-state greeting.
(Message text colour set on the `Typography` — GlobalStyles pins `p` to
text.secondary.)
- **`InvoiceReviewCard.tsx`** — the editable extract card (supplier, number,
Částka s DPH, měna, sazba, dates, popis) + Uložit / Zahodit.
- **`OdinComposer.tsx`** — staged-attachment chips + attach button + message
field (Enter to send) + send button; disabled until the active thread loads.
`DashAssistant.tsx` is deleted; `pages/Odin.tsx` renders `OdinChat`.
### Data flow
1. Mount → load `aiConversationsOptions`. If empty, `POST /conversations` to
create a default and select it; else select the first (or last-active).
2. Selecting a tab sets the active id → `aiConversationMessagesOptions(id)`
loads that thread into the displayed turns (seed-once per id; submit marks
the thread live so a late load can't clobber — same guard as today).
3. **Send (chat):** optimistic user turn → `POST /chat` → append assistant
reply → `POST /conversations/:id/messages` persists both → bump usage. On
error: roll back, keep input.
4. **Send (attachments):** `POST /extract-invoices` → review cards →
client-side summary turn → persist the user+summary turns. Save a card →
`POST /received-invoices` (gross/VAT-inclusive, unchanged).
5. **New / rename / delete** → mutations that invalidate `["ai","conversations"]`
(and reset the active id appropriately on delete).
### Reuse
All proven logic carries over verbatim: staged-attach (no auto-send), the three
bug fixes (no clear-on-error, rollback dangling turn, stable ids — `nextUid`,
NOT `crypto.randomUUID`), the model-context trimming (last 20, leading-user),
budget indicator, gross/VAT save. The only structural change is "one thread" →
"active conversation thread", plus the tab/CRUD shell.
## Error handling
- Ownership: service returns `{ error: "Konverzace nenalezena", status: 404 }`
for a conversation the user doesn't own; routes surface it. No cross-user read
or write is possible (every query filters by `user_id`).
- Persistence is best-effort and logged (never swallowed), as today.
- Auto-create-default and create/rename/delete failures surface a Czech toast.
## Testing
Backend (Vitest, real DB):
- Conversation CRUD: create → list → rename → delete (cascade removes messages).
- **Isolation:** user A cannot read/append/rename/delete user B's conversation
(404), and `listConversations(A)` excludes B's.
- Append: ordering oldest→newest; `updated_at` bumps; **auto-title** sets the
title from the first user message and does not overwrite a renamed title.
- HTTP: routes 403 without `ai.use`; 404 for a foreign/:id; 400 on bad body.
Frontend: no component tests (consistent with the codebase); gated by
`tsc -b` + `npm run build`. Live verification by the user/controller.
## Out of scope (Phase 2+)
- Querying system data inside chat (the original deferred Phase-2 scope).
- Conversation search, pinning, folders, sharing.
- Streaming responses.

View File

@@ -0,0 +1,49 @@
# Odin → General Assistant (Phase 2+) — Forward-Looking Notes
**Date:** 2026-06-08 · **Status:** NOT scheduled — design notes only, recorded so Phase 1 choices don't box us in.
The vision: Odin grows from "chat + invoice import" into a **general assistant** that can **query system data** (invoices, projects, attendance, vehicles…) and **take actions** (create an order, mark paid, draft a quote…). This note assesses whether the Phase-1 architecture supports that, and what to carry forward deliberately.
## Bottom line
Phase 1 sets us up well; it does **not** box us in. Two things to carry forward on purpose:
1. **Structured message storage** (content blocks, not a plain string) — cheap to plan now, costly to retrofit later.
2. **The assistant acts strictly as the authenticated user** — every tool runs through the existing permission/ownership layer.
Everything else (conversations, budget/usage, thin services) is already pointing the right way.
## What Phase 1 already gets right
- **Conversations** — multi-turn, persisted, per-user, isolated. Exactly the context substrate an agent needs.
- **Budget + usage tracking + `ai.use` gate** — the cost/access spine. Tool loops spend more, so this matters _more_, not less.
- **Thin service layer** (`{ data } | { error, status }`, ownership-checked) — ideal to wrap as tools without rewriting business logic.
- **The extract → review → save flow** — a perfect template for "propose action → human confirms → execute." The `canSave`/`invoices.create` gate added in Phase 1 is the one-tool version of per-action authorization.
## The one schema thing to note now
`ai_chat_messages.content` is a **plain string**. Tool use requires persisting **content blocks** (`text` + `tool_use` + `tool_result`), because the model must see the full tool-call history to continue. When Phase 2 lands, store the **raw content-block array** (JSON column, e.g. `content_json`/`blocks`) and keep a derived plain-text for display/search. Don't change it now — just design Phase 2 persistence as "store the blocks," treating the current string as a lossy projection.
## The core shift (Phase 2)
1. **Tool use over the existing services.** Define tools (`list_invoices`, `get_project`, `create_order`, …) whose handlers call the **same service functions the routes use**. The SDK tool-runner loops; we execute. Reuses all validation/business logic and — critically — permissions.
2. **Authorization is the hard part.** Every tool MUST run **as the user**, through `requirePermission` / service ownership checks. The assistant must never do what the user can't. It is the user's _delegate_, not a privileged actor.
3. **Read vs write.** Read/query tools may run autonomously. Write/destructive tools **propose → confirm → execute** (generalize the invoice review card into a generic "action card"). Aligns with the assistant safety rules (confirm side-effecting actions).
4. **Audit.** Every action the assistant takes should `logAudit` like a manual action, attributed to the user with a marker (e.g. `via: "odin"`), for a real trail.
5. **Cost.** Tool loops are multi-round-trip → 310× the tokens of a chat turn. The $50 cap bites faster; consider per-conversation cost display and per-action budget re-checks (the inter-file budget re-check in `extract-invoices` is the pattern).
## Data access: tools-over-services, NOT RAG
Recommendation: **function-calling against the real services**, not embeddings/RAG. The data is structured, live, and permissioned — tools give correct, current, authorized answers. RAG over a snapshot would be stale, would leak across permission boundaries, and would duplicate logic. Reserve embeddings for genuinely unstructured document search if it ever comes up.
## Other forward notes
- **Model / effort:** a true agentic assistant wants a stronger model and possibly `effort` tuning; Phase-1 Sonnet single-shot is right for now.
- **Streaming:** multi-step tool use feels slow without it — add SSE when Phase 2 lands.
- **System prompt:** describe the user's role + available tools + the confirm-before-write rule.
- **Managed Agents vs self-hosted loop:** Anthropic's Managed Agents host the loop, but for a self-hosted business app with our own services + permissions, **Claude API + tool use with a self-hosted loop** is the better fit — authorization stays in our stack.
## What NOT to do prematurely
- Don't add a tool framework, embeddings, or streaming now — Phase 1 is plain chat + a fixed-prompt extractor, and that's the right scope.
- Don't widen `ai.use` to non-admins until the per-action authorization story (point 2) is in place.

View File

@@ -0,0 +1,383 @@
# Objednávky vydané (Issued Purchase Orders) — Design Spec
**Date:** 2026-06-09
**Status:** Approved (brainstorm) → ready for implementation plan
**Author:** brainstorm session
> **Updated 2026-06-09 (as-built):** Shipped with three deviations from this
> brainstorm — **Vydané-first tabs** (Vydané | Přijaté, mirroring Invoices), a
> **shared chevron month/year filter** above the tabs that filters both lists,
> and a PDF **rebuilt on the shared invoice/order-confirmation template** rather
> than the custom layout sketched below. See the affected sections (UI placement,
> PDF export, Frontend) and the plan's "Post-plan consistency pass" note.
---
## Goal
Add a new document type — **objednávky vydané** (purchase orders you issue to a
supplier) — alongside the existing customer orders. You author the PO with line
items, it gets an auto-generated number, you track its status, and you export it
to PDF to send to the supplier. The existing orders module becomes the
**přijaté** (customer-order) side of a two-tab UI, mirroring how the Invoices
page already splits **Vydané | Přijaté**.
## Non-goals (v1)
- **No cross-module wiring.** A PO does **not** link to warehouse goods receipts
(`sklad_receipts`) or to received invoices (`faktury přijaté`). The full
procurement loop (ordered → received → invoiced) is a deliberate later phase.
- **No new supplier master.** POs reuse the existing `customers` table as generic
business partners.
- **No NAS persistence of the PO PDF** in v1 (generated on demand only).
- **No "scope sections"** (the CZ/EN rich-text blocks orders/quotations carry).
- **No AI/Odin authoring.** POs are authored by hand; Odin extraction is a
received-document concept and does not apply to documents you create.
---
## Decisions log (resolved during brainstorm)
1. **Direction mapping.** Today's `orders` (status default `prijata`, flowing
quotation → order → project → invoice) stay as the **Přijaté** side, largely
unchanged — only surfaced under a new tab. The new **Vydané** side is a
brand-new document type. _(Not a `direction` flag on the existing table.)_
2. **Supplier source.** A PO references the existing **`customers`** table (FK),
used as a generic business partner. No new supplier/vendor master.
3. **Linkage.** **Standalone document** in v1. No automatic links to warehouse or
received invoices.
4. **UI placement.** **Tabs on the Orders page**, mirroring the Invoices page.
_(As-built: **Vydané first / Přijaté second**`Vydané | Přijaté`, tab values
`vydane`/`prijate`, persisted in `?tab=` — matching the Invoices page order. A
shared **chevron month/year selector** sits above the tabs and filters BOTH
lists: Vydané by `order_date`, Přijaté by `created_at`. The pre-existing Přijaté
search bug — search term sent but ignored by the backend — was also fixed.)_
5. **Model shape.** A **dedicated `issued_orders` + `issued_order_items`** pair,
mirroring `invoices`/`invoice_items`_not_ an extension of `orders` and _not_
an upload-only record like `received_invoices`.
6. **VAT regime.** **NET base + VAT-on-top** (the _issued-invoice_ convention),
the **opposite** of `received_invoices` (which are gross/VAT-inclusive). Totals
use the same per-line-rounded math as `computeInvoiceTotals`.
7. **Numbering.** A **dedicated** `number_sequences` type (`"issued_order"`),
separate from the `"shared"` orders/projects pool and from `"invoice"`. Number
assigned **at creation** (even for a `draft`), with release-on-delete.
8. **Permissions.** **Reuse the existing `orders.*` set** (`view`/`create`/`edit`/
`delete`) plus `orders.export` for the PDF — mirrors invoices sharing one
permission set across both tabs; **no permission migration needed**.
---
## Architecture
A self-contained vertical slice following the codebase's established document
pattern (the same shape `invoices` uses):
```
prisma/schema.prisma → models issued_orders, issued_order_items + enum
src/schemas/issued-orders.schema.ts
src/services/issued-orders.service.ts
src/services/numbering.service.ts (extend: issued-order number fns)
src/routes/admin/issued-orders.ts (register in routes/admin/index.ts)
src/routes/admin/issued-orders-pdf.ts (PDF route, modeled on invoices-pdf.ts)
src/admin/pages/Orders.tsx (add Přijaté | Vydané tabs)
src/admin/pages/IssuedOrderDetail.tsx (create/edit form + PDF export)
src/admin/lib/queries/issued-orders.ts
src/admin/lib/entityTypeLabels.ts (add "issued_order" Czech label)
src/admin/AdminApp.tsx (lazy routes for detail/new)
src/__tests__/issued-orders.test.ts
```
**Tech stack:** Fastify 5, Prisma 7 (MySQL), Zod 4, React 19 + MUI v7, React
Query, Puppeteer (PDF), Vitest. All new code follows the audited conventions
(success/error helpers, `parseBody`/`parseId`, shared Zod coercers, broad query
invalidation, deterministic ordering with an `id` tiebreak, locked
read-modify-write for numbering).
---
## Data model
### `issued_orders` (parallels `invoices`)
| Field | Type | Notes |
| ---------------- | -------------------------------------------------- | ------------------------------------------- |
| `id` | Int PK autoincrement | |
| `po_number` | VARCHAR(50) unique | auto-assigned (see Numbering) |
| `customer_id` | Int FK → `customers` (onDelete Restrict, nullable) | the **supplier/partner** being ordered from |
| `status` | enum `issued_orders_status` | default `draft` |
| `currency` | VARCHAR(10) default `"CZK"` | |
| `vat_rate` | Decimal(5,2) default `21.00` | doc-level default rate |
| `apply_vat` | Boolean default `true` | |
| `exchange_rate` | Decimal(12,4) nullable | foreign-currency, like orders |
| `order_date` | Date | date the PO is issued |
| `delivery_date` | Date nullable | requested delivery / required-by |
| `language` | VARCHAR(5) default `"cs"` | drives PDF language |
| `delivery_terms` | VARCHAR(500) nullable | optional, shown on PDF |
| `payment_terms` | VARCHAR(500) nullable | optional, shown on PDF |
| `issued_by` | VARCHAR(255) nullable | signatory name |
| `notes` | Text nullable | rich text, **rendered on PDF** (sanitized) |
| `internal_notes` | Text nullable | private, **not** on PDF |
| `created_at` | DateTime default now | |
| `modified_at` | DateTime @updatedAt | |
**Relations:** `issued_order_items[]`, `customers?`.
**Indexes:** `(customer_id)`, `(status, order_date)`.
### `issued_order_items` (parallels `invoice_items`)
| Field | Type | Notes |
| ------------------ | ------------------------------------------- | ------------------------------- |
| `id` | Int PK | |
| `issued_order_id` | Int FK → `issued_orders` (onDelete Cascade) | |
| `description` | VARCHAR(500) | main line text |
| `item_description` | Text nullable | optional long detail |
| `quantity` | Decimal(12,3) default `1.000` | |
| `unit` | VARCHAR(20) nullable | e.g. ks, hod, kg |
| `unit_price` | Decimal(12,2) default `0.00` | **NET** unit price |
| `vat_rate` | Decimal(5,2) default `21.00` | per-line; overrides doc default |
| `position` | Int default `0` | ordering |
**Index:** `(issued_order_id)`.
### `issued_orders_status` enum
`draft`, `sent`, `confirmed`, `completed`, `cancelled`
(Czech labels: _Koncept · Odeslaná · Potvrzená · Dokončená · Stornovaná_.)
---
## Numbering
A dedicated sequence, exactly like invoices:
- New `number_sequences` type string `"issued_order"` (unique on `(type, year)`,
as the table already enforces). Separate from `"shared"` and `"invoice"`.
- New `company_settings` columns:
- `issued_order_number_pattern` VARCHAR default `"{YY}{CODE}{NNNN}"`
- `issued_order_type_code` VARCHAR default `"72"` (orders use `71`, invoices
`81`; `72` is free and configurable).
- New functions in `src/services/numbering.service.ts`, reusing the existing
`applyPattern` + `getNextSequence` (`SELECT … FOR UPDATE` lock, P2002
first-of-year retry):
- `generateIssuedOrderNumber(tx)` — atomically consumes the next number.
- `previewIssuedOrderNumber()` — non-consuming peek (for the create form).
- `releaseIssuedOrderNumber(year, number)` — decrement only if the deleted PO
held the current highest number that year (gap prevention), mirroring
`releaseInvoiceNumber`.
- The number is **assigned at creation**, including for a `draft`. Deleting a PO
releases the number per the rule above.
---
## Backend API
### Schema — `src/schemas/issued-orders.schema.ts`
`CreateIssuedOrderSchema`, `UpdateIssuedOrderSchema`, `IssuedOrderItemSchema`,
built **only** from the shared coercers in `src/schemas/common.ts`:
- `customer_id``nullableIntIdFromForm`
- `quantity``positiveNumberFromForm`; `unit_price``nonNegativeNumberFromForm`
- `vat_rate``numberInRange(0, 100)`
- `order_date` / `delivery_date``isoDateString` (lenient)
- text fields → `z.string().max(...)`; optional email-like none here
- Czech user-facing messages; English identifiers.
**No** raw `z.union([z.number(), z.string()]).transform(Number)` — that idiom is
banned (silent `NaN`).
### Service — `src/services/issued-orders.service.ts`
Plain exported async functions returning `{ data }` or `{ error, status }`:
- `listIssuedOrders(params)` — paginated; filters `status`, `customer_id`,
`search` (po_number / supplier name); whitelist sort
(`id`/`po_number`/`status`/`order_date`/`created_at`) with a deterministic
**`{ id }` tiebreak**; each row enriched via `computeIssuedOrderTotals`.
- `getIssuedOrder(id)` — full detail with items, supplier name, and
`valid_transitions`.
- `createIssuedOrder(body)` — inside `prisma.$transaction`:
`generateIssuedOrderNumber(tx)` → insert header → batch-insert items.
- `updateIssuedOrder(id, body)` — validates the status transition against
`VALID_TRANSITIONS`; replaces items (delete-all + recreate) only while the doc
is editable (`draft`/`sent`); blocks any `po_number` change.
- `deleteIssuedOrder(id)` — delete (items cascade) then
`releaseIssuedOrderNumber(year, po_number)`.
- `computeIssuedOrderTotals(items, applyVat, docRate)` — **NET + VAT-on-top,
rounded per line before accumulation** (same shape as `computeInvoiceTotals`):
`base = qty × unit_price`; `lineVat = round(base × rate/100)` when
`applyVat`; `subtotal = Σ base`, `vat = Σ lineVat`, `total = subtotal + vat`.
Falls back line `vat_rate` → doc `vat_rate` → 21.
- `getNextIssuedOrderNumber()` — proxies `previewIssuedOrderNumber()`.
`VALID_TRANSITIONS`:
```
draft → [sent, cancelled]
sent → [confirmed, cancelled]
confirmed → [completed, cancelled]
completed → [] // terminal
cancelled → [] // terminal
```
### Routes — `src/routes/admin/issued-orders.ts`
Registered in `src/routes/admin/index.ts`. Every handler uses `success()`/
`error()`, `parseBody(Schema, …)`, `parseId((request.params as any).id, reply)`,
and `logAudit` with a **new** entity type `"issued_order"` (create/update/delete,
with `oldValues`/`newValues`). Adding this entity type requires also registering
its Czech label in `src/admin/lib/entityTypeLabels.ts` (keyed to the server's
`EntityType` value), per the single-source-of-truth convention.
| Method · Path | Guard | Purpose |
| ------------------------------------------ | --------------- | -------------------------- |
| GET `/api/admin/issued-orders` | `orders.view` | list (paginated, filtered) |
| GET `/api/admin/issued-orders/next-number` | `orders.create` | preview next PO number |
| GET `/api/admin/issued-orders/:id` | `orders.view` | detail |
| POST `/api/admin/issued-orders` | `orders.create` | create |
| PUT `/api/admin/issued-orders/:id` | `orders.edit` | update |
| DELETE `/api/admin/issued-orders/:id` | `orders.delete` | delete |
| GET `/api/admin/issued-orders/:id/pdf` | `orders.export` | render + stream PDF |
---
## PDF export
> **As-built (2026-06-09):** rather than the custom/compact layout this section
> originally sketched, the issued-order PDF was **rebuilt on the shared
> invoice / order-confirmation red-accent (#de3a3a) template** (`src/routes/admin/issued-orders-pdf.ts`),
> titled **OBJEDNÁVKA**, for visual consistency with the rest of the document
> suite. Because a PO is a buying document, the direction is **flipped vs an
> invoice**: the selected **customer record renders as "Dodavatel" (supplier)**
> and **your company as "Odběratel" (buyer)**. It is generated **on demand** and
> streamed as `application/pdf` — **no NAS persistence**. Sanitization,
> on-demand/no-NAS behavior, and the NET+VAT-on-top totals below are unchanged.
New route + template `src/routes/admin/issued-orders-pdf.ts`, reusing
`htmlToPdf` (Puppeteer) from `src/utils/pdf.ts`:
- **Dodavatel (supplier) block** = the selected `customers` record (name,
IČO/`company_id`, DIČ/`vat_id`, address). **Odběratel (buyer) block** = your
company (`company_settings`: name, IČO, DIČ, address, logo as base64).
- Body: items table → VAT recap grouped by rate → **NET total + VAT + gross**.
- `cs` / `en` strings selected by `language`. Czech date/currency via the
existing formatters.
- `notes` passed through the **same** `cleanQuillHtml` + DOMPurify sanitization
used by invoices/orders before going into the template (mandatory for any new
rich-text-on-PDF field).
- **v1 behavior:** generate on demand and stream `application/pdf` with
`Content-Disposition` filename `{po_number}.pdf`. **No NAS write.** (A future
`?save=1 → Objednávky vydané/YYYY/MM/{po_number}.pdf` add-on is a small,
isolated follow-up.)
---
## Frontend
- **`src/admin/pages/Orders.tsx` — add `Vydané | Přijaté` tabs** using the same
MUI Tabs pattern as `Invoices.tsx`. The current orders list moves **verbatim**
into the **Přijaté** tab — every hook, mutation, `invalidate` array, validation,
and permission check preserved unchanged; only presentation is reorganized. The
**Vydané** tab renders the new list.
_(As-built: `Orders.tsx` became a **parent shell** that owns the `PageHeader` +
the shared month/year selector + the tabs; `IssuedOrders.tsx` (Vydané) and
`OrdersReceived.tsx` (Přijaté) are **content-only children** receiving `month`/`year`
props. `OrdersReceived`'s create modal is **hoisted** to the shell via
`createOpen`/`setCreateOpen`, the ReceivedInvoices pattern. **No KPI/stat cards**
on the Orders landing — deliberate, since orders have no paid/overdue semantics.)_
- **Vydané list** — `DataTable` columns: `po_number`, supplier (customer name),
status chip (color per status), `order_date`, total (currency), actions (open,
**Export PDF**, delete). `FilterBar` with status + customer `Select`s and search
at standard widths; pagination + sortable headers; an "Vytvořit objednávku
vydanou" button gated on `orders.create`.
- **`src/admin/pages/IssuedOrderDetail.tsx`** (lazy routes `/orders/issued/new`
and `/orders/issued/:id` in `AdminApp.tsx`): supplier `Select`, `order_date` /
`delivery_date` `DateField`s, currency / VAT rate / `apply_vat`, a **line-items
table** (add/remove rows + `@dnd-kit` reorder, like `InvoiceDetail`), `RichEditor`
notes, internal notes, `delivery_terms` / `payment_terms`, `issued_by`, **live
totals** (net / VAT / gross), status-transition buttons, and an **Export PDF**
button gated on `orders.export`. Top-level sections wrapped in `<PageEnter>`;
imports come from the `ui/` kit.
- **Queries** `src/admin/lib/queries/issued-orders.ts``issuedOrderListOptions`,
`issuedOrderDetailOptions`, `issuedOrderNextNumberOptions`. All mutations
invalidate the **broad `["issued-orders"]`** domain key.
- **Rules of Hooks:** all hooks run before any early permission `return`
(`<Forbidden/>`/`<Navigate/>` go after every hook) — `npm run lint` enforces
`react-hooks/rules-of-hooks` as an error.
- Sidebar nav unchanged — one "Objednávky" item; the split lives in the tabs.
---
## Permissions
Reuse the existing `orders.*` set for the Vydané tab:
- `orders.view` — list + detail + PDF view
- `orders.create` — create + next-number preview
- `orders.edit` — update
- `orders.delete` — delete
- `orders.export` — PDF export (already seeded)
This mirrors invoices (issued + received share one `invoices.*` set) and needs
**no permission migration**. _(Future option, out of scope for v1: a dedicated
`purchase-orders._` set via a seed + migration if purchasing must be locked down
separately from sales orders.)\*
---
## Status lifecycle
`draft → sent → confirmed → completed`, plus any non-terminal → `cancelled`.
- **Editable** (header + items): `draft`, `sent`.
- **Read-only except a permitted status change**: `confirmed`, `completed`,
`cancelled`.
- Enforced server-side by the `VALID_TRANSITIONS` guard in the service; the detail
page only renders buttons for valid next states.
---
## Testing
`src/__tests__/issued-orders.test.ts`, against the real **`app_test`** DB via
`buildApp()` (no Prisma mocks):
- **Numbering:** sequential allocation; release-only-if-highest on delete; the
generated number matches the configured pattern.
- **Create + totals:** create with multiple items, assert NET+VAT-on-top totals
with **per-line rounding pinned to exact 2-decimal values**; mixed per-line VAT
rates; `apply_vat=false` zeroes VAT but preserves the NET subtotal.
- **Status transitions:** every valid transition succeeds; invalid transitions
return the proper error; items become read-only after `confirmed`.
- **Permissions:** each endpoint rejects a caller lacking the required `orders.*`
permission (not bare auth).
- **PDF:** `GET /:id/pdf` returns `200` with `content-type: application/pdf`.
---
## Migration
A single `prisma migrate dev --name issued_orders`:
1. Creates `issued_orders` and `issued_order_items` tables + the
`issued_orders_status` enum.
2. Adds `issued_order_number_pattern` and `issued_order_type_code` columns to
`company_settings` with SQL defaults (`"{YY}{CODE}{NNNN}"`, `"72"`).
Per project rules: **ask the user to stop the dev server first**; commit both
`schema.prisma` and the generated migration folder; run `prisma generate`. No raw
SQL on prod — the column defaults ship inside the migration's `migration.sql`.
**Quality gates before "done":** `npx tsc -b --noEmit`, `npm run build`,
`npx vitest run`, `npm run lint` (0 errors).
---
## Future phases (explicitly out of scope for v1)
- **Procurement links:** PO ↔ warehouse goods receipt (`sklad_receipts`) and
PO ↔ received invoice matching (ordered → received → invoiced rollups), with
Odin pre-filling a received invoice from a PO.
- **NAS persistence** of the generated PO PDF (`?save=1`).
- **Dedicated `purchase-orders.*` permission set.**
- **Scope sections** (CZ/EN rich-text blocks) if POs ever need narrative content.

78
eslint.config.mjs Normal file
View File

@@ -0,0 +1,78 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import prettier from "eslint-config-prettier";
export default tseslint.config(
{
ignores: [
"dist/**",
"dist-client/**",
"node_modules/**",
"prisma/migrations/**",
"src/admin/bones/**",
"*.config.js",
"*.config.mjs",
"*.config.ts",
".claude/**",
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
// Server + shared code
files: ["src/**/*.{ts,tsx}", "scripts/**/*.ts", "prisma/seed.ts"],
languageOptions: {
globals: { ...globals.node, ...globals.browser },
},
rules: {
// TS already resolves identifiers — `no-undef` only false-positives on
// global types/ambient names in .ts files.
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-require-imports": "warn",
"no-empty": ["warn", { allowEmptyCatch: false }],
// Intentional control-character regexes (NUL/path-traversal guards,
// combining-marks stripping) — not a defect here.
"no-control-regex": "off",
// Stylistic / advisory — surface as warnings, don't fail the lint gate.
"no-useless-escape": "warn",
"no-useless-assignment": "warn",
"preserve-caught-error": "warn",
"prefer-const": "warn",
},
},
{
// Frontend React code
files: ["src/admin/**/*.{ts,tsx}", "src/**/*.tsx"],
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
},
},
{
// Plain Node helper scripts (.js/.mjs/.cjs in scripts/)
files: ["scripts/**/*.{js,mjs,cjs}"],
languageOptions: { globals: { ...globals.node } },
rules: { "no-undef": "off" },
},
{
// Tests
files: ["src/__tests__/**/*.ts"],
languageOptions: { globals: { ...globals.node } },
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
prettier,
);

View File

@@ -13,7 +13,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />

2864
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.0.7",
"version": "2.4.3",
"description": "",
"main": "dist/server.js",
"scripts": {
@@ -18,16 +18,19 @@
"db:studio": "prisma studio",
"test": "vitest run",
"test:watch": "vitest",
"seed": "tsx prisma/seed.ts"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
"seed": "tsx prisma/seed.ts",
"typecheck": "tsc -b --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
@@ -41,52 +44,59 @@
"@fastify/static": "^9.0.0",
"@mui/material": "^7.3.11",
"@mui/x-date-pickers": "^8.29.0",
"@prisma/client": "^6.19.2",
"@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
"dotenv": "^17.3.1",
"fastify": "^5.8.2",
"file-type": "^16.5.4",
"file-type": "^22.0.1",
"framer-motion": "^12.38.0",
"hi-base32": "^0.5.1",
"jsdom": "^29.0.2",
"jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4",
"mariadb": "^3.5.2",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.2",
"otpauth": "^9.5.0",
"prisma": "^6.19.2",
"prisma": "^7.8.0",
"puppeteer": "^24.40.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-datepicker": "^9.1.0",
"react-dom": "^18.3.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-quill-new": "^3.8.3",
"react-router-dom": "^6.30.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/dompurify": "^3.0.5",
"@eslint/js": "^10.0.1",
"@types/jsdom": "^28.0.1",
"@types/jsonwebtoken": "^9.0.10",
"@types/leaflet": "^1.9.21",
"@types/mysql": "^2.15.27",
"@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.61.0",
"vite": "^8.0.0",
"vitest": "^4.1.0"
},
"overrides": {
"@hono/node-server": "^1.19.13"
}
}

16
prisma.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
// Prisma 7 moved the datasource connection URL and seed config out of
// schema.prisma / package.json into this file. `import "dotenv/config"` is
// required because Prisma 7 no longer auto-loads .env. The runtime database
// connection itself is made via the driver adapter in src/config/database.ts.
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: {
url: env("DATABASE_URL"),
},
migrations: {
seed: "tsx prisma/seed.ts",
},
});

View File

@@ -0,0 +1,29 @@
-- AI assistant (Phase 1): usage-tracking table, monthly budget column, ai.use permission.
CREATE TABLE `ai_usage` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`user_id` INTEGER NULL,
`kind` VARCHAR(20) NOT NULL,
`model` VARCHAR(50) NOT NULL,
`input_tokens` INTEGER NOT NULL DEFAULT 0,
`output_tokens` INTEGER NOT NULL DEFAULT 0,
`cost_usd` DECIMAL(10, 6) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`),
INDEX `idx_ai_usage_created` (`created_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `company_settings`
ADD COLUMN `ai_monthly_budget_usd` DECIMAL(10, 2) NULL DEFAULT 50.00;
-- ai.use permission + grant to admin (INSERT IGNORE → idempotent), mirroring
-- the warehouse-permissions migration.
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
('ai.use', 'AI asistent', 'ai', 'Používat AI asistenta (chat a import faktur)', NOW());
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
SELECT r.id, p.id
FROM `roles` r
CROSS JOIN `permissions` p
WHERE r.name = 'admin'
AND p.name = 'ai.use';

View File

@@ -0,0 +1,11 @@
-- AI assistant (Phase 1): per-user chat history (server-side, permanent).
CREATE TABLE `ai_chat_messages` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`user_id` INTEGER NOT NULL,
`role` VARCHAR(20) NOT NULL,
`content` TEXT NOT NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`),
INDEX `idx_ai_chat_user` (`user_id`, `id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@@ -0,0 +1,32 @@
-- Multi-conversation Odin: conversations table + conversation_id on messages.
CREATE TABLE `ai_conversations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`user_id` INTEGER NOT NULL,
`title` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`),
INDEX `idx_ai_conv_user` (`user_id`, `id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `ai_chat_messages`
ADD COLUMN `conversation_id` INTEGER NULL AFTER `user_id`;
-- Backfill: one conversation per user who has messages ("keep as first conversation").
INSERT INTO `ai_conversations` (`user_id`, `title`, `created_at`, `updated_at`)
SELECT `user_id`, 'Konverzace', NOW(), NOW()
FROM `ai_chat_messages`
GROUP BY `user_id`;
UPDATE `ai_chat_messages` m
JOIN `ai_conversations` c ON c.`user_id` = m.`user_id`
SET m.`conversation_id` = c.`id`;
-- Enforce: drop old per-user index, NOT NULL, FK (cascade), per-conversation index.
ALTER TABLE `ai_chat_messages`
DROP INDEX `idx_ai_chat_user`,
MODIFY `conversation_id` INTEGER NOT NULL,
ADD CONSTRAINT `fk_ai_chat_conv` FOREIGN KEY (`conversation_id`)
REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE,
ADD INDEX `idx_ai_chat_conv` (`conversation_id`, `id`);

View File

@@ -0,0 +1,137 @@
-- Audit schema hardening (from the 2026-06-09 codebase audit).
--
-- ⚠️ PENDING — NOT YET APPLIED. Review, then apply with the dev server stopped:
-- npx prisma migrate deploy (prod / already-tracked)
-- -- or, in dev, `npx prisma migrate dev` will pick it up as pending.
--
-- What this does:
-- • Warehouse line→header relations now CASCADE on delete (deleting a
-- receipt/issue/inventory session removes its lines/attachments).
-- • Warehouse line→master-data (item/batch/location) and the financial FKs
-- (invoices/orders/projects/quotations → customer/order/quotation) are now
-- explicit ON DELETE RESTRICT — preventing deletion of a referenced master
-- record / orphaning of a financial record. (Some of these were previously
-- the optional-relation default SET NULL; RESTRICT is the safer intent.)
-- • UNIQUE on warehouse document numbers (receipt_number/issue_number/
-- session_number) — like every other document-number column.
-- • New indexes on hot FK filters (sklad_issues/receipts, bank_accounts).
--
-- ⚠️ BEFORE APPLYING ON PRODUCTION, verify existing data won't violate the new
-- constraints: no DUPLICATE non-null receipt/issue/session numbers (else the
-- UNIQUE index fails), and no rows that would break the RESTRICT FKs. Test on
-- a copy first.
--
-- DropForeignKey
ALTER TABLE `invoices` DROP FOREIGN KEY `invoices_ibfk_1`;
-- DropForeignKey
ALTER TABLE `invoices` DROP FOREIGN KEY `invoices_ibfk_2`;
-- DropForeignKey
ALTER TABLE `orders` DROP FOREIGN KEY `orders_ibfk_1`;
-- DropForeignKey
ALTER TABLE `orders` DROP FOREIGN KEY `orders_ibfk_2`;
-- DropForeignKey
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_1`;
-- DropForeignKey
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_2`;
-- DropForeignKey
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_3`;
-- DropForeignKey
ALTER TABLE `quotations` DROP FOREIGN KEY `quotations_ibfk_1`;
-- DropForeignKey
ALTER TABLE `sklad_receipt_lines` DROP FOREIGN KEY `sklad_receipt_lines_receipt_id_fkey`;
-- DropForeignKey
ALTER TABLE `sklad_receipt_lines` DROP FOREIGN KEY `sklad_receipt_lines_location_id_fkey`;
-- DropForeignKey
ALTER TABLE `sklad_receipt_attachments` DROP FOREIGN KEY `sklad_receipt_attachments_receipt_id_fkey`;
-- DropForeignKey
ALTER TABLE `sklad_issue_lines` DROP FOREIGN KEY `sklad_issue_lines_issue_id_fkey`;
-- DropForeignKey
ALTER TABLE `sklad_issue_lines` DROP FOREIGN KEY `sklad_issue_lines_location_id_fkey`;
-- DropForeignKey
ALTER TABLE `sklad_inventory_lines` DROP FOREIGN KEY `sklad_inventory_lines_session_id_fkey`;
-- DropForeignKey
ALTER TABLE `sklad_inventory_lines` DROP FOREIGN KEY `sklad_inventory_lines_location_id_fkey`;
-- CreateIndex
CREATE INDEX `idx_bank_accounts_is_default` ON `bank_accounts`(`is_default`);
-- CreateIndex
CREATE UNIQUE INDEX `sklad_receipts_receipt_number_key` ON `sklad_receipts`(`receipt_number`);
-- CreateIndex
CREATE INDEX `sklad_receipts_supplier_id` ON `sklad_receipts`(`supplier_id`);
-- CreateIndex
CREATE INDEX `sklad_receipts_received_by` ON `sklad_receipts`(`received_by`);
-- CreateIndex
CREATE UNIQUE INDEX `sklad_issues_issue_number_key` ON `sklad_issues`(`issue_number`);
-- CreateIndex
CREATE INDEX `sklad_issues_project_id` ON `sklad_issues`(`project_id`);
-- CreateIndex
CREATE INDEX `sklad_issues_issued_by` ON `sklad_issues`(`issued_by`);
-- CreateIndex
CREATE UNIQUE INDEX `sklad_inventory_sessions_session_number_key` ON `sklad_inventory_sessions`(`session_number`);
-- AddForeignKey
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_ibfk_1` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `orders` ADD CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`quotation_id`) REFERENCES `quotations`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `orders` ADD CONSTRAINT `orders_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_2` FOREIGN KEY (`quotation_id`) REFERENCES `quotations`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_3` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `quotations` ADD CONSTRAINT `quotations_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_attachments` ADD CONSTRAINT `sklad_receipt_attachments_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_issue_id_fkey` FOREIGN KEY (`issue_id`) REFERENCES `sklad_issues`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `sklad_inventory_sessions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE `ai_chat_messages` DROP FOREIGN KEY `fk_ai_chat_conv`;
-- AlterTable
ALTER TABLE `quotations` MODIFY `project_code` VARCHAR(100) NULL;
-- AddForeignKey
ALTER TABLE `ai_chat_messages` ADD CONSTRAINT `ai_chat_messages_conversation_id_fkey` FOREIGN KEY (`conversation_id`) REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,52 @@
-- AlterTable
ALTER TABLE `company_settings` ADD COLUMN `issued_order_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `issued_order_type_code` VARCHAR(10) NULL;
-- CreateTable
CREATE TABLE `issued_orders` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`po_number` VARCHAR(50) NULL,
`customer_id` INTEGER NULL,
`status` ENUM('draft', 'sent', 'confirmed', 'completed', 'cancelled') NOT NULL DEFAULT 'draft',
`currency` VARCHAR(10) NULL DEFAULT 'CZK',
`vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
`apply_vat` BOOLEAN NULL DEFAULT true,
`exchange_rate` DECIMAL(10, 4) NULL DEFAULT 1.0000,
`order_date` DATE NULL,
`delivery_date` DATE NULL,
`language` VARCHAR(5) NULL DEFAULT 'cs',
`delivery_terms` VARCHAR(500) NULL,
`payment_terms` VARCHAR(500) NULL,
`issued_by` VARCHAR(255) NULL,
`notes` TEXT NULL,
`internal_notes` TEXT NULL,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `idx_issued_orders_number_unique`(`po_number`),
INDEX `issued_orders_customer_id`(`customer_id`),
INDEX `idx_issued_orders_status_date`(`status`, `order_date`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `issued_order_items` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`issued_order_id` INTEGER NOT NULL,
`description` VARCHAR(500) NULL,
`item_description` TEXT NULL,
`quantity` DECIMAL(12, 3) NULL DEFAULT 1.000,
`unit` VARCHAR(20) NULL,
`unit_price` DECIMAL(12, 2) NULL DEFAULT 0.00,
`vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
`position` INTEGER NULL DEFAULT 0,
INDEX `issued_order_id`(`issued_order_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `issued_order_items` ADD CONSTRAINT `issued_order_items_ibfk_1` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `company_settings` ADD COLUMN `project_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `project_type_code` VARCHAR(10) NULL;

View File

@@ -0,0 +1,61 @@
-- Drop the three DEAD document "export" permissions and make the 12 surviving
-- document permissions reproducible on a fresh production database.
--
-- Why: `offers.export`, `orders.export`, `invoices.export` gate NOTHING on the
-- backend — every PDF/export route is guarded by `*.view`. They only ever
-- toggled frontend buttons and showed up as dead checkboxes in role management.
-- The 12 real keys (offers/orders/invoices x view/create/edit/delete) ARE each
-- enforced on >=1 route and must survive.
--
-- All 15 keys were previously defined ONLY in prisma/seed.ts (dev-only), so a
-- fresh production DB never had ANY of them. This migration both removes the 3
-- dead keys AND seeds the 12 survivors + their admin grant, mirroring the
-- prisma/seed.ts column set exactly (name, display_name, module, description,
-- created_at; role_permissions has only the composite PK).
--
-- Idempotent: safe whether the rows exist or not, and safe on a prod DB that
-- never had them. Deletes are no-ops when absent; INSERT IGNORE skips on the
-- unique `permissions.name` key and on the `role_permissions` composite PK.
-- 1. Remove the dead export role assignments first (FK-safe: children before
-- parents). No-op when the permissions/assignments don't exist.
DELETE FROM `role_permissions`
WHERE `permission_id` IN (
SELECT `id` FROM `permissions`
WHERE `name` IN ('offers.export', 'orders.export', 'invoices.export')
);
-- 2. Remove the dead export permissions themselves.
DELETE FROM `permissions`
WHERE `name` IN ('offers.export', 'orders.export', 'invoices.export');
-- 3. Idempotently (re)insert the 12 surviving document permissions. INSERT
-- IGNORE skips any that already exist (unique `name`). display_name /
-- description / module match prisma/seed.ts verbatim.
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
('offers.view', 'Zobrazit nabídky', 'offers', 'Prohlížet seznam nabídek', NOW()),
('offers.create', 'Vytvořit nabídku', 'offers', 'Vytvářet nové nabídky', NOW()),
('offers.edit', 'Upravit nabídku', 'offers', 'Upravovat existující nabídky', NOW()),
('offers.delete', 'Smazat nabídku', 'offers', 'Mazat nabídky', NOW()),
('orders.view', 'Zobrazit objednávky', 'orders', 'Prohlížet seznam objednávek', NOW()),
('orders.create', 'Vytvořit objednávku', 'orders', 'Vytvářet nové objednávky', NOW()),
('orders.edit', 'Upravit objednávku', 'orders', 'Upravovat existující objednávky', NOW()),
('orders.delete', 'Smazat objednávku', 'orders', 'Mazat objednávky', NOW()),
('invoices.view', 'Zobrazit faktury', 'invoices', 'Prohlížet seznam faktur', NOW()),
('invoices.create', 'Vytvořit fakturu', 'invoices', 'Vytvářet nové faktury', NOW()),
('invoices.edit', 'Upravit fakturu', 'invoices', 'Upravovat existující faktury', NOW()),
('invoices.delete', 'Smazat fakturu', 'invoices', 'Mazat faktury', NOW());
-- 4. Grant all 12 survivors to the admin role. INSERT IGNORE on the composite
-- PK (role_id, permission_id) makes this idempotent and preserves any other
-- role assignments that already exist.
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
SELECT r.id, p.id
FROM `roles` r
CROSS JOIN `permissions` p
WHERE r.name = 'admin'
AND p.name IN (
'offers.view', 'offers.create', 'offers.edit', 'offers.delete',
'orders.view', 'orders.create', 'orders.edit', 'orders.delete',
'invoices.view', 'invoices.create', 'invoices.edit', 'invoices.delete'
);

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

@@ -4,7 +4,6 @@ generator client {
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model attendance {
@@ -68,7 +67,6 @@ model audit_logs {
session_id String? @db.VarChar(128)
created_at DateTime? @default(now()) @db.Timestamp(0)
@@index([created_at], map: "idx_audit_log_created")
@@index([action], map: "idx_audit_logs_action")
@@index([created_at], map: "idx_audit_logs_created")
@@index([entity_type, entity_id, created_at], map: "idx_audit_logs_entity")
@@ -76,6 +74,42 @@ model audit_logs {
@@fulltext([description], map: "idx_audit_search")
}
model ai_usage {
id Int @id @default(autoincrement())
user_id Int?
kind String @db.VarChar(20)
model String @db.VarChar(50)
input_tokens Int @default(0)
output_tokens Int @default(0)
cost_usd Decimal @default(0) @db.Decimal(10, 6)
created_at DateTime @default(now()) @db.Timestamp(0)
@@index([created_at], map: "idx_ai_usage_created")
}
model ai_conversations {
id Int @id @default(autoincrement())
user_id Int
title String @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
messages ai_chat_messages[]
@@index([user_id, id], map: "idx_ai_conv_user")
}
model ai_chat_messages {
id Int @id @default(autoincrement())
user_id Int
conversation_id Int
role String @db.VarChar(20)
content String @db.Text
created_at DateTime @default(now()) @db.Timestamp(0)
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
@@index([conversation_id, id], map: "idx_ai_chat_conv")
}
model bank_accounts {
id Int @id @default(autoincrement())
account_name String? @db.VarChar(255)
@@ -88,6 +122,8 @@ model bank_accounts {
position Int? @default(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
@@index([is_default], map: "idx_bank_accounts_is_default")
}
model company_settings {
@@ -128,12 +164,17 @@ model company_settings {
offer_number_pattern String? @db.VarChar(100)
order_number_pattern String? @db.VarChar(100)
invoice_number_pattern String? @db.VarChar(100)
issued_order_number_pattern String? @db.VarChar(100)
issued_order_type_code String? @db.VarChar(10)
project_number_pattern String? @db.VarChar(100)
project_type_code String? @db.VarChar(10)
warehouse_receipt_prefix String? @db.VarChar(20)
warehouse_receipt_number_pattern String? @db.VarChar(100)
warehouse_issue_prefix String? @db.VarChar(20)
warehouse_issue_number_pattern String? @db.VarChar(100)
warehouse_inventory_prefix String? @db.VarChar(20)
warehouse_inventory_number_pattern String? @db.VarChar(100)
ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2)
}
model customers {
@@ -198,8 +239,8 @@ model invoices {
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
invoice_items invoice_items[]
orders orders? @relation(fields: [order_id], references: [id], onUpdate: NoAction, map: "invoices_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "invoices_ibfk_2")
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")
@@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date")
@@ -306,8 +347,6 @@ model orders {
status String? @default("prijata") @db.VarChar(30)
currency String? @default("CZK") @db.VarChar(10)
language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
@@ -317,14 +356,61 @@ model orders {
invoices invoices[]
order_items order_items[]
order_sections order_sections[]
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "orders_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "orders_ibfk_2")
quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "orders_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "orders_ibfk_2")
projects projects[]
@@index([customer_id], map: "customer_id")
@@index([quotation_id], map: "quotation_id")
}
model issued_orders {
id Int @id @default(autoincrement())
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
supplier_id Int?
status issued_orders_status @default(draft)
currency String? @default("CZK") @db.VarChar(10)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
order_date DateTime? @db.Date
delivery_date DateTime? @db.Date
language String? @default("cs") @db.VarChar(5)
delivery_terms String? @db.VarChar(500)
payment_terms String? @db.VarChar(500)
issued_by String? @db.VarChar(255)
order_text String? @db.VarChar(500)
notes String? @db.Text
internal_notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
issued_order_items issued_order_items[]
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
@@index([supplier_id], map: "issued_orders_supplier_id")
@@index([status, order_date], map: "idx_issued_orders_status_date")
}
model issued_order_items {
id Int @id @default(autoincrement())
issued_order_id Int
description String? @db.VarChar(500)
item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
position Int? @default(0)
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
@@index([issued_order_id], map: "issued_order_id")
}
enum issued_orders_status {
draft
sent
confirmed
completed
cancelled
}
model permissions {
id Int @id @default(autoincrement())
name String @unique(map: "name") @db.VarChar(50)
@@ -368,9 +454,9 @@ model projects {
work_plan_entries work_plan_entries[]
work_plan_overrides work_plan_overrides[]
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1")
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_2")
orders orders? @relation(fields: [order_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_3")
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")
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_3")
@@index([customer_id], map: "customer_id")
@@index([responsible_user_id], map: "fk_projects_responsible_user")
@@ -403,8 +489,6 @@ model quotations {
valid_until DateTime? @db.Date
currency String? @default("CZK") @db.VarChar(10)
language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
order_id Int?
status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500)
@@ -415,7 +499,7 @@ model quotations {
orders orders[]
projects projects[]
quotation_items quotation_items[]
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "quotations_ibfk_1")
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")
@@ -705,7 +789,8 @@ model sklad_suppliers {
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[]
receipts sklad_receipts[]
issued_orders issued_orders[]
@@map("sklad_suppliers")
}
@@ -740,7 +825,7 @@ model sklad_items {
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
category sklad_categories? @relation(fields: [category_id], references: [id])
category sklad_categories? @relation(fields: [category_id], references: [id], onDelete: SetNull)
batches sklad_batches[]
item_locations sklad_item_locations[]
receipt_lines sklad_receipt_lines[]
@@ -762,8 +847,8 @@ model sklad_batches {
received_at DateTime? @db.DateTime(0)
is_consumed Boolean @default(false)
item sklad_items @relation(fields: [item_id], references: [id])
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id])
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)
issue_lines sklad_issue_lines[]
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
@@ -776,8 +861,8 @@ model sklad_item_locations {
location_id Int
quantity Decimal @default(0) @db.Decimal(12, 3)
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations @relation(fields: [location_id], references: [id])
item sklad_items @relation(fields: [item_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")
@@map("sklad_item_locations")
@@ -785,7 +870,7 @@ model sklad_item_locations {
model sklad_receipts {
id Int @id @default(autoincrement())
receipt_number String? @db.VarChar(50)
receipt_number String? @unique @db.VarChar(50)
supplier_id Int?
delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0)
@@ -795,11 +880,13 @@ model sklad_receipts {
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id])
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id])
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull)
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull)
items sklad_receipt_lines[]
attachments sklad_receipt_attachments[]
@@index([supplier_id], map: "sklad_receipts_supplier_id")
@@index([received_by], map: "sklad_receipts_received_by")
@@map("sklad_receipts")
}
@@ -812,9 +899,9 @@ model sklad_receipt_lines {
location_id Int?
notes String? @db.VarChar(255)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
batch sklad_batches?
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
@@ -830,14 +917,14 @@ model sklad_receipt_attachments {
file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
@@map("sklad_receipt_attachments")
}
model sklad_issues {
id Int @id @default(autoincrement())
issue_number String? @db.VarChar(50)
issue_number String? @unique @db.VarChar(50)
project_id Int
issued_by Int?
notes String? @db.Text
@@ -845,10 +932,12 @@ model sklad_issues {
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
project projects @relation(fields: [project_id], references: [id])
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id])
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull)
items sklad_issue_lines[]
@@index([project_id], map: "sklad_issues_project_id")
@@index([issued_by], map: "sklad_issues_issued_by")
@@map("sklad_issues")
}
@@ -862,11 +951,11 @@ model sklad_issue_lines {
reservation_id Int?
notes String? @db.VarChar(255)
issue sklad_issues @relation(fields: [issue_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
batch sklad_batches @relation(fields: [batch_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id])
issue sklad_issues @relation(fields: [issue_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_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)
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull)
@@index([issue_id], map: "sklad_issue_lines_issue_id")
@@map("sklad_issue_lines")
@@ -884,9 +973,9 @@ model sklad_reservations {
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
item sklad_items @relation(fields: [item_id], references: [id])
project projects @relation(fields: [project_id], references: [id])
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id])
item sklad_items @relation(fields: [item_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)
issue_lines sklad_issue_lines[]
@@index([item_id, status], map: "sklad_reservations_item_status")
@@ -895,7 +984,7 @@ model sklad_reservations {
model sklad_inventory_sessions {
id Int @id @default(autoincrement())
session_number String? @db.VarChar(50)
session_number String? @unique @db.VarChar(50)
notes String? @db.Text
status sklad_inventory_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
@@ -916,9 +1005,9 @@ model sklad_inventory_lines {
difference Decimal @db.Decimal(12, 3)
notes String? @db.VarChar(255)
session sklad_inventory_sessions @relation(fields: [session_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_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")
@@map("sklad_inventory_lines")

View File

@@ -1,7 +1,8 @@
import { PrismaClient } from "@prisma/client";
import "dotenv/config";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
// Use the shared, adapter-wired client (Prisma 7 needs a driver adapter).
// dotenv is imported first so DATABASE_URL is set before database.ts reads it.
import prisma from "../src/config/database";
const PERMISSIONS: {
name: string;
@@ -94,12 +95,6 @@ const PERMISSIONS: {
module: "offers",
description: "Mazat nabídky",
},
{
name: "offers.export",
display_name: "Exportovat nabídku",
module: "offers",
description: "Exportovat nabídky do PDF",
},
// Objednávky
{
@@ -126,12 +121,6 @@ const PERMISSIONS: {
module: "orders",
description: "Mazat objednávky",
},
{
name: "orders.export",
display_name: "Exportovat objednávku",
module: "orders",
description: "Exportovat objednávky do PDF",
},
// Faktury
{
@@ -158,12 +147,6 @@ const PERMISSIONS: {
module: "invoices",
description: "Mazat faktury",
},
{
name: "invoices.export",
display_name: "Exportovat fakturu",
module: "invoices",
description: "Exportovat faktury do PDF",
},
// Projekty
{
@@ -315,6 +298,22 @@ const PERMISSIONS: {
];
async function main() {
// HARD PRODUCTION GUARD: this seed unconditionally wipes ALL permissions and
// role_permissions (and seeds an admin/admin user) — purely dev-convenience
// behavior. Production baseline data must come from tracked Prisma migrations,
// never from the seed (see CLAUDE.md "Database Migrations"). Refuse to run on
// production so the destructive wipe can never hit a live database.
if (process.env.APP_ENV === "production") {
throw new Error(
"Odmítnuto: prisma/seed.ts NESMÍ běžet na produkci (APP_ENV=production). " +
"Seed maže všechna oprávnění a je určen pouze pro vývoj. " +
"Produkční data musí být zavedena migrací.\n" +
"Refusing to run: prisma/seed.ts must NEVER run on production " +
"(APP_ENV=production). It wipes all permissions and is dev-only; " +
"seed production baseline data via a migration instead.",
);
}
console.log("Seeding permissions...");
// 1. Clear existing permissions and re-insert current set

View File

@@ -5,10 +5,10 @@
*/
import "../src/config/env";
import { PrismaClient } from "@prisma/client";
import fs from "fs";
import { nasFinancialsManager } from "../src/services/nas-financials-manager";
const prisma = new PrismaClient();
// Shared adapter-wired client (Prisma 7 needs a driver adapter).
import prisma from "../src/config/database";
async function main() {
if (!nasFinancialsManager.isConfigured()) {
@@ -51,6 +51,39 @@ async function main() {
continue;
}
// Read-back verification before we null out the ONLY DB copy of the bytes.
// The manager uses writeFileSync (synchronous) and returns a relative path;
// resolve it back to disk and confirm the file exists and its size matches
// the source buffer. A 0-byte/truncated/corrupt write would otherwise lose
// the invoice permanently once file_data is set to null. If verification
// fails we KEEP file_data and report the row as failed.
const expectedSize = rec.file_data.length;
const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath);
let writtenSize = -1;
if (readBack) {
writtenSize = readBack.data.length;
} else {
// Fall back to a direct stat in case readReceivedInvoice's path guard
// rejects an otherwise-valid path; either way we need a confirmed size.
try {
writtenSize = fs.statSync(
(nasFinancialsManager as unknown as { basePath: string }).basePath +
"/" +
result.filePath,
).size;
} catch {
writtenSize = -1;
}
}
if (writtenSize !== expectedSize) {
console.error(
` FAIL id=${rec.id}: read-back mismatch (expected ${expectedSize} bytes, got ${writtenSize}); keeping DB copy`,
);
failed++;
continue;
}
await prisma.received_invoices.update({
where: { id: rec.id },
data: { file_path: result.filePath, file_data: null },

View File

@@ -1,35 +1,29 @@
import crypto from 'crypto';
import { PrismaClient } from '@prisma/client';
import "dotenv/config";
import { decryptWithKey, encryptWithKey } from "../src/utils/encryption";
// Shared adapter-wired client (Prisma 7 needs a driver adapter).
import prisma from "../src/config/database";
const prisma = new PrismaClient();
function decrypt(ciphertext: string, keyHex: string): string {
const key = Buffer.from(keyHex, 'hex');
const [ivHex, encHex, tagHex] = ciphertext.split(':');
const iv = Buffer.from(ivHex, 'hex');
const encrypted = Buffer.from(encHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8');
}
function encrypt(plaintext: string, keyHex: string): string {
const key = Buffer.from(keyHex, 'hex');
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`;
}
// Reuse the dual-format decrypt/encrypt from src/utils/encryption.ts so this
// script handles BOTH wire formats: the TS `hex:hex:hex` form and the
// PHP-legacy single-base64 blob. The previous hand-rolled `decrypt` only parsed
// the TS form and threw `Buffer.from(undefined, 'hex')` on any legacy secret —
// inside the $transaction that aborted/rolled back the ENTIRE batch on the first
// legacy user. These key-parameterized helpers accept the old/new keys passed on
// the command line (the config-bound `decrypt`/`encrypt` use the env key only).
const decrypt = (ciphertext: string, keyHex: string): string =>
decryptWithKey(ciphertext, keyHex);
const encrypt = (plaintext: string, keyHex: string): string =>
encryptWithKey(plaintext, keyHex);
async function main() {
const oldKey = process.argv[2];
const newKey = process.argv[3];
const dryRun = process.argv.includes('--dry-run');
const dryRun = process.argv.includes("--dry-run");
if (!oldKey || !newKey) {
console.error('Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]');
console.error(
"Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]",
);
process.exit(1);
}
@@ -46,12 +40,14 @@ async function main() {
const decrypted = decrypt(user.totp_secret!, oldKey);
const reEncrypted = encrypt(decrypted, newKey);
decrypt(reEncrypted, newKey); // verify round-trip
console.log(` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`);
console.log(
` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`,
);
} catch (e) {
console.error(` [FAIL] ${user.username} (id=${user.id}) — ${e}`);
}
}
console.log('\nDry run complete. No changes made.');
console.log("\nDry run complete. No changes made.");
return;
}
@@ -59,13 +55,19 @@ async function main() {
for (const user of users) {
const decrypted = decrypt(user.totp_secret!, oldKey);
const reEncrypted = encrypt(decrypted, newKey);
await tx.users.update({ where: { id: user.id }, data: { totp_secret: reEncrypted } });
await tx.users.update({
where: { id: user.id },
data: { totp_secret: reEncrypted },
});
console.log(` Re-encrypted TOTP for ${user.username} (id=${user.id})`);
}
});
console.log('\nAll TOTP secrets re-encrypted successfully.');
console.log("\nAll TOTP secrets re-encrypted successfully.");
await prisma.$disconnect();
}
main().catch((e) => { console.error(e); process.exit(1); });
main().catch((e) => {
console.error(e);
process.exit(1);
});

468
src/__tests__/ai.test.ts Normal file
View File

@@ -0,0 +1,468 @@
import { describe, it, expect, beforeEach, 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 { config as appConfig } from "../config/env";
import { securityHeaders } from "../middleware/security";
import aiRoutes from "../routes/admin/ai";
import {
computeCostUsd,
recordUsage,
getMonthSpendUsd,
getBudgetUsd,
assertBudgetAvailable,
isConfigured,
listConversations,
createConversation,
getConversationMessages,
appendConversationMessages,
renameConversation,
deleteConversation,
} from "../services/ai.service";
const KIND = "test_ai"; // marker so we only clean our own rows
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
const CONV_UID_B = 999999992;
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
beforeEach(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
});
afterAll(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
});
await prisma.ai_conversations.deleteMany({
where: { title: { contains: HIST_MARKER } },
});
});
describe("ai.service cost + budget", () => {
it("computes Sonnet 4.6 cost from tokens", () => {
// 1,000,000 input @ $3 + 1,000,000 output @ $15 = $18
expect(
computeCostUsd("claude-sonnet-4-6", 1_000_000, 1_000_000),
).toBeCloseTo(18, 6);
expect(computeCostUsd("claude-sonnet-4-6", 4000, 500)).toBeCloseTo(
0.0195,
6,
);
});
it("records usage with the computed cost", async () => {
await recordUsage({
userId: null,
kind: KIND,
model: "claude-sonnet-4-6",
inputTokens: 4000,
outputTokens: 500,
});
const rows = await prisma.ai_usage.findMany({ where: { kind: KIND } });
expect(rows.length).toBe(1);
expect(Number(rows[0].cost_usd)).toBeCloseTo(0.0195, 6);
});
it("sums only the current month's spend (excludes prior months)", async () => {
// Measure the baseline so the assertion is robust to any OTHER current-month
// rows that may already exist in the test DB (getMonthSpendUsd sums ALL
// kinds, not just ours). We assert the DELTA we introduce, not an absolute
// upper bound.
const before = await getMonthSpendUsd();
await recordUsage({
userId: null,
kind: KIND,
model: "claude-sonnet-4-6",
inputTokens: 1_000_000,
outputTokens: 0,
}); // +$3 this month
// An old row (year 2000) must NOT count toward the current month.
await prisma.ai_usage.create({
data: {
kind: KIND,
model: "claude-sonnet-4-6",
input_tokens: 1_000_000,
output_tokens: 0,
cost_usd: 3,
created_at: new Date("2000-01-01T00:00:00Z"),
},
});
const after = await getMonthSpendUsd();
// Only the $3 current-month row moved the needle; the year-2000 $3 is
// excluded. The delta is exactly $3 (not $6).
expect(after - before).toBeCloseTo(3, 6);
});
it("assertBudgetAvailable blocks at/over budget, allows under", async () => {
const budget = await getBudgetUsd();
// Under budget → null (allowed)
expect(await assertBudgetAvailable()).toBeNull();
// Push spend over budget for this month, then it must block with 402.
// Tag with a unique marker so we can delete EXACTLY this row at the end of
// the test — that keeps the inflated spend from leaking into any other
// current-month test regardless of execution order (the beforeEach kind=KIND
// cleanup would catch it too, but cleaning in-test makes it order-proof).
const overBudget = await prisma.ai_usage.create({
data: {
kind: KIND,
model: "claude-sonnet-4-6",
input_tokens: 0,
output_tokens: 0,
cost_usd: budget + 1,
created_at: new Date(),
},
});
try {
const blocked = await assertBudgetAvailable();
expect(blocked).not.toBeNull();
expect(blocked?.status).toBe(402);
} finally {
// Remove the over-budget row immediately so it cannot inflate
// getMonthSpendUsd for any subsequently-running test.
await prisma.ai_usage
.delete({ where: { id: overBudget.id } })
.catch(() => {});
}
});
it("isConfigured reflects the API key presence", () => {
expect(typeof isConfigured()).toBe("boolean");
});
});
describe("ai.service conversations", () => {
beforeEach(async () => {
await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
});
});
// Narrow a service result to its error branch with a clear failure message.
const expectError = (
r: { error: string; status: number } | { data: unknown },
status: number,
) => {
if (!("error" in r))
throw new Error(`expected an error result, got ${JSON.stringify(r)}`);
expect(r.status).toBe(status);
};
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
const conv = await createConversation(CONV_UID_A);
expect(conv.title).toBe("Nová konverzace");
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "Kolik je hodin v Praze?" },
{ role: "assistant", content: "Nevím, ale…" },
]);
const list = await listConversations(CONV_UID_A);
expect(list).toHaveLength(1);
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
if (!("data" in msgs)) throw new Error("expected messages, got an error");
expect(msgs.data.map((m) => m.content)).toEqual([
"Kolik je hodin v Praze?",
"Nevím, ale…",
]);
});
it("does not overwrite a renamed title on later appends", async () => {
const conv = await createConversation(CONV_UID_A);
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "tohle by nemělo přepsat název" },
]);
const list = await listConversations(CONV_UID_A);
expect(list[0].title).toBe("Moje téma");
});
it("bumps updated_at on append", async () => {
const conv = await createConversation(CONV_UID_A);
// Force an old timestamp, then prove append refreshes it (TIMESTAMP(0) is
// second-precision, so compare against a year rather than ms deltas).
await prisma.ai_conversations.update({
where: { id: conv.id },
data: { updated_at: new Date("2000-01-01T00:00:00Z") },
});
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "ping" },
]);
const after = (await listConversations(CONV_UID_A))[0].updated_at;
expect(after.getFullYear()).toBeGreaterThan(2000);
});
it("isolates conversations per user (404 on every op across owners)", async () => {
const a = await createConversation(CONV_UID_A);
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
expectError(await getConversationMessages(CONV_UID_B, a.id), 404);
expectError(
await appendConversationMessages(CONV_UID_B, a.id, [
{ role: "user", content: "x" },
]),
404,
);
expectError(await renameConversation(CONV_UID_B, a.id, "hack"), 404);
expectError(await deleteConversation(CONV_UID_B, a.id), 404);
});
it("cascade-deletes messages with the conversation", async () => {
const conv = await createConversation(CONV_UID_A);
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "x" },
]);
await deleteConversation(CONV_UID_A, conv.id);
const remaining = await prisma.ai_chat_messages.count({
where: { conversation_id: conv.id },
});
expect(remaining).toBe(0);
});
});
describe("HTTP /api/admin/ai", () => {
let app: Awaited<ReturnType<typeof buildAiApp>>;
let adminToken: string;
let noPermToken: string;
let noPermRoleId: number;
let noPermUserId: number;
let savedKey: string;
async function buildAiApp() {
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(aiRoutes, { prefix: "/api/admin/ai" });
return a;
}
function token(user: {
id: number;
username: string;
roleName: string | null;
}) {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
appConfig.jwt.secret,
{ expiresIn: "15m" },
);
}
beforeAll(async () => {
savedKey = appConfig.anthropic.apiKey;
app = await buildAiApp();
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("admin not found");
adminToken = token({
id: admin.id,
username: admin.username,
roleName: admin.roles?.name ?? null,
});
const stamp = Date.now();
const role = await prisma.roles.create({
data: { name: `noperm_ai_${stamp}`, display_name: "No Perm AI" },
});
noPermRoleId = role.id;
const u = await prisma.users.create({
data: {
username: `noperm_ai_${stamp}`,
first_name: "No",
last_name: "Perm",
email: `noperm_ai_${stamp}@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
role_id: role.id,
is_active: true,
},
});
noPermUserId = u.id;
noPermToken = token({
id: u.id,
username: u.username,
roleName: role.name,
});
});
afterAll(async () => {
if (app) await app.close();
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
if (noPermUserId)
await prisma.users
.deleteMany({ where: { id: noPermUserId } })
.catch(() => {});
if (noPermRoleId)
await prisma.roles
.deleteMany({ where: { id: noPermRoleId } })
.catch(() => {});
});
it("GET /usage requires ai.use", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/usage",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
});
it("GET /usage returns spend + budget for an admin", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/usage",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(typeof body.data.budget_usd).toBe("number");
expect(typeof body.data.month_spend_usd).toBe("number");
});
it("POST /chat returns 503 when AI is not configured", async () => {
(appConfig.anthropic as { apiKey: string }).apiKey = "";
const res = await app.inject({
method: "POST",
url: "/api/admin/ai/chat",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [{ role: "user", content: "ahoj" }] },
});
expect(res.statusCode).toBe(503);
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
});
it("GET /conversations requires ai.use", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
});
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
const created = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} t` },
});
expect(created.statusCode).toBe(200);
const id = created.json().data.conversation.id as number;
const appended = await app.inject({
method: "POST",
url: `/api/admin/ai/conversations/${id}/messages`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [{ role: "user", content: "ahoj" }] },
});
expect(appended.statusCode).toBe(200);
const msgs = await app.inject({
method: "GET",
url: `/api/admin/ai/conversations/${id}/messages`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(
msgs.json().data.messages.map((m: { content: string }) => m.content),
).toContain("ahoj");
const renamed = await app.inject({
method: "PATCH",
url: `/api/admin/ai/conversations/${id}`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} renamed` },
});
expect(renamed.json().data.conversation.title).toBe(
`${HIST_MARKER} renamed`,
);
const del = await app.inject({
method: "DELETE",
url: `/api/admin/ai/conversations/${id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(del.statusCode).toBe(200);
});
it("GET messages of a non-existent conversation → 404", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations/999999000/messages",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
});
it("POST /conversations requires ai.use", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations",
headers: {
Authorization: `Bearer ${noPermToken}`,
"Content-Type": "application/json",
},
payload: { title: "x" },
});
expect(res.statusCode).toBe(403);
});
it("GET /conversations lists the user's conversations", async () => {
const created = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} listed` },
});
const id = created.json().data.conversation.id as number;
const list = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(list.statusCode).toBe(200);
expect(
list.json().data.conversations.map((c: { id: number }) => c.id),
).toContain(id);
});
it("rejects an empty title (PATCH) and empty messages (POST) with 400", async () => {
const patch = await app.inject({
method: "PATCH",
url: "/api/admin/ai/conversations/999999000",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: "" },
});
expect(patch.statusCode).toBe(400);
const append = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations/999999000/messages",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [] },
});
expect(append.statusCode).toBe(400);
});
});

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

@@ -1,10 +1,72 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { verifyAccessToken, hashToken } from "../services/auth";
import jwt from "jsonwebtoken";
import { config } from "../config/env";
import prisma from "../config/database";
// Prefix for fixtures so cleanup never touches real data.
const N = "auth_svc_test_";
let testUserId: number;
let testUsername: string;
let testRoleName: string | null;
beforeAll(async () => {
// Clean up any leftover fixtures from a previous failed run.
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
// Use a real (active, unlocked) user from the seeded DB so loadAuthData
// returns a full AuthData. We don't need a specific role — any active user
// works — but we record its role so we can assert roleName precisely.
const role = await prisma.roles.findFirst();
if (!role) throw new Error("Test setup: no roles found — seed the database");
const user = await prisma.users.create({
data: {
username: `${N}user`,
email: `${N}user@test.local`,
// DUMMY_HASH used elsewhere in the suite; we never verify a password
// here, only that the token's `sub` resolves to this user.
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "Auth",
last_name: "Service",
is_active: true,
role_id: role.id,
},
include: { roles: true },
});
testUserId = user.id;
testUsername = user.username;
testRoleName = user.roles?.name ?? null;
});
afterAll(async () => {
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
});
describe("auth service", () => {
describe("verifyAccessToken", () => {
it("returns AuthData for a valid token signed for a real user", async () => {
const token = jwt.sign(
{ sub: testUserId, username: testUsername, role: testRoleName },
config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry, algorithm: "HS256" },
);
const result = await verifyAccessToken(token);
expect(result).not.toBeNull();
expect(result!.userId).toBe(testUserId);
expect(result!.username).toBe(testUsername);
expect(result!.roleName).toBe(testRoleName);
// Permissions resolve to an array (admins get all, others get role perms).
expect(Array.isArray(result!.permissions)).toBe(true);
});
it("returns null WITHOUT logging for an invalid JWT (expected condition)", async () => {
const consoleSpy = vi
.spyOn(console, "error")
@@ -35,6 +97,14 @@ describe("auth service", () => {
});
describe("hashToken", () => {
it("matches the known SHA-256 vector for a fixed input", () => {
// SHA-256("hello") — pins the algorithm so a silent change (e.g. to a
// different but still 64-hex-deterministic hash) is caught.
expect(hashToken("hello")).toBe(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
);
});
it("produces deterministic SHA-256 hex output", () => {
const t1 = hashToken("hello");
const t2 = hashToken("hello");

View File

@@ -1,12 +1,73 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import bcrypt from "bcryptjs";
import { buildApp, extractCookie } from "./helpers";
import prisma from "../config/database";
import { config } from "../config/env";
let app: Awaited<ReturnType<typeof buildApp>>;
// Fixture prefix so cleanup never touches real data.
const N = "auth_test_";
const TEST_USERNAME = `${N}user`;
const TEST_PASSWORD = "Sup3r-Secret-Pw!";
let testUserId: number;
/** Pull every Set-Cookie header line for a given cookie name (raw, with attrs). */
function rawSetCookie(res: { headers: Record<string, any> }, name: string) {
const cookies = res.headers["set-cookie"];
if (!cookies) return undefined;
const arr = Array.isArray(cookies) ? cookies : [cookies];
return arr.find((c: string) => c.startsWith(`${name}=`));
}
beforeAll(async () => {
app = await buildApp();
// Clean up any leftover fixture from a previous failed run, plus its tokens.
const leftovers = await prisma.users.findMany({
where: { username: { startsWith: N } },
select: { id: true },
});
if (leftovers.length) {
await prisma.refresh_tokens.deleteMany({
where: { user_id: { in: leftovers.map((u) => u.id) } },
});
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");
// Hash the known password with the SAME bcrypt cost the app uses so login's
// bcrypt.compare succeeds.
const passwordHash = await bcrypt.hash(
TEST_PASSWORD,
config.security.bcryptCost,
);
const user = await prisma.users.create({
data: {
username: TEST_USERNAME,
email: `${N}user@test.local`,
password_hash: passwordHash,
first_name: "Auth",
last_name: "Test",
is_active: true,
totp_enabled: false,
role_id: role.id,
},
});
testUserId = user.id;
});
afterAll(async () => {
await prisma.refresh_tokens
.deleteMany({ where: { user_id: testUserId } })
.catch(() => {});
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
await app.close();
});
@@ -29,6 +90,39 @@ describe("POST /api/admin/login", () => {
});
expect(res.statusCode).toBe(400);
});
it("returns 401 for a valid user with the wrong password", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/login",
payload: { username: TEST_USERNAME, password: "definitely-wrong" },
});
expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false);
});
it("returns 200 with an access token and a refresh cookie on valid credentials", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/login",
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
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);
expect(body.data.user.username).toBe(TEST_USERNAME);
// The refresh token must be set as an httpOnly, SameSite=Strict cookie.
const cookie = extractCookie(res, "refresh_token");
expect(cookie).toBeTruthy();
const rawCookie = rawSetCookie(res, "refresh_token")!;
expect(rawCookie).toMatch(/HttpOnly/i);
expect(rawCookie).toMatch(/SameSite=Strict/i);
});
});
describe("POST /api/admin/refresh", () => {
@@ -39,14 +133,88 @@ describe("POST /api/admin/refresh", () => {
});
expect(res.statusCode).toBe(401);
});
it("returns 401 for an invalid/unknown refresh token", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/refresh",
cookies: { refresh_token: "not-a-real-token" },
});
expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false);
});
it("rotates a valid refresh cookie into a new access token + new refresh cookie", async () => {
// First log in to obtain a valid refresh cookie.
const loginRes = await app.inject({
method: "POST",
url: "/api/admin/login",
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
expect(loginRes.statusCode).toBe(200);
const originalRefresh = extractCookie(loginRes, "refresh_token")!;
expect(originalRefresh).toBeTruthy();
const refreshRes = await app.inject({
method: "POST",
url: "/api/admin/refresh",
cookies: { refresh_token: originalRefresh },
});
expect(refreshRes.statusCode).toBe(200);
const body = refreshRes.json();
expect(body.success).toBe(true);
expect(typeof body.data.access_token).toBe("string");
expect(body.data.user.userId).toBe(testUserId);
// The refresh token must be ROTATED: a new cookie value, different from
// the one we presented.
const rotated = extractCookie(refreshRes, "refresh_token");
expect(rotated).toBeTruthy();
expect(rotated).not.toBe(originalRefresh);
// Reuse-detection: presenting the now-consumed original token must fail.
const reuseRes = await app.inject({
method: "POST",
url: "/api/admin/refresh",
cookies: { refresh_token: originalRefresh },
});
expect(reuseRes.statusCode).toBe(401);
});
});
describe("POST /api/admin/logout", () => {
it("clears refresh token cookie", async () => {
it("clears the refresh token cookie", async () => {
// Log in so logout has a real token to delete.
const loginRes = await app.inject({
method: "POST",
url: "/api/admin/login",
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
const refresh = extractCookie(loginRes, "refresh_token")!;
const res = await app.inject({
method: "POST",
url: "/api/admin/logout",
cookies: { refresh_token: refresh },
});
expect(res.statusCode).toBeLessThan(500);
expect(res.statusCode).toBe(200);
// The response must actively clear the cookie — an expiry in the past
// and/or an empty value (clearCookie sets Max-Age=0 / Expires in the past).
const raw = rawSetCookie(res, "refresh_token");
expect(raw).toBeTruthy();
const cleared =
/Max-Age=0/i.test(raw!) ||
/Expires=Thu, 01 Jan 1970/i.test(raw!) ||
/^refresh_token=;/.test(raw!);
expect(cleared).toBe(true);
// The deleted token must no longer be usable for refresh.
const reuseRes = await app.inject({
method: "POST",
url: "/api/admin/refresh",
cookies: { refresh_token: refresh },
});
expect(reuseRes.statusCode).toBe(401);
});
});

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import bankAccountsRoutes from "../routes/admin/bank-accounts";
import prisma from "../config/database";
import { config } from "../config/env";
// Fixture marker so cleanup never touches real data.
const N = "bank_accounts_test_";
let app: ReturnType<typeof Fastify>;
let token: string;
const createdIds: number[] = [];
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(bankAccountsRoutes, {
prefix: "/api/admin/bank-accounts",
});
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
// Admin role bypasses the settings.banking permission guard.
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
if (!adminRole)
throw new Error("Test setup: no admin role found — seed the database");
const user = await prisma.users.create({
data: {
username: `${N}user`,
email: `${N}user@test.local`,
password_hash: "x",
first_name: "Bank",
last_name: "Test",
is_active: true,
totp_enabled: false,
role_id: adminRole.id,
},
});
token = jwt.sign(
{ sub: user.id, username: user.username, role: "admin" },
config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry },
);
});
afterAll(async () => {
if (createdIds.length) {
await prisma.bank_accounts
.deleteMany({ where: { id: { in: createdIds } } })
.catch(() => {});
}
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
await app.close();
});
const auth = { authorization: () => `Bearer ${token}` };
describe("bank accounts — flat-body create/update persists all fields", () => {
it("creates an account from a flat top-level body and persists every field", async () => {
const payload = {
account_name: `${N}Hlavní účet`,
bank_name: "Komerční banka",
account_number: "123456789/0100",
iban: "CZ6508000000192000145399",
bic: "KOMBCZPP",
currency: "CZK",
is_default: true,
};
const post = await app.inject({
method: "POST",
url: "/api/admin/bank-accounts/",
headers: { authorization: auth.authorization() },
payload,
});
expect(post.statusCode).toBe(201);
const id = post.json().data.id as number;
expect(typeof id).toBe("number");
createdIds.push(id);
// Read back via the list endpoint and confirm NONE of the fields are blank.
const row = await prisma.bank_accounts.findUnique({ where: { id } });
expect(row?.account_name).toBe(payload.account_name);
expect(row?.bank_name).toBe(payload.bank_name);
expect(row?.account_number).toBe(payload.account_number);
expect(row?.iban).toBe(payload.iban);
expect(row?.bic).toBe(payload.bic);
expect(row?.currency).toBe("CZK");
expect(row?.is_default).toBe(true);
});
it("updates an account from a flat body that also carries the id", async () => {
const created = await prisma.bank_accounts.create({
data: { account_name: `${N}orig`, bank_name: "Orig", currency: "CZK" },
});
createdIds.push(created.id);
// Mirrors the client payload: spread fields + id (id routes the URL/method,
// is declared in the schema, and is ignored by the route which uses :id).
const put = await app.inject({
method: "PUT",
url: `/api/admin/bank-accounts/${created.id}`,
headers: { authorization: auth.authorization() },
payload: {
id: created.id,
account_name: `${N}upraveno`,
bank_name: "Nová banka",
iban: "CZ9999",
currency: "EUR",
is_default: false,
},
});
expect(put.statusCode).toBe(200);
const row = await prisma.bank_accounts.findUnique({
where: { id: created.id },
});
expect(row?.account_name).toBe(`${N}upraveno`);
expect(row?.bank_name).toBe("Nová banka");
expect(row?.iban).toBe("CZ9999");
expect(row?.currency).toBe("EUR");
});
it("rejects the old nested {bank:{...}} body shape with 400 (no silent blank save)", async () => {
const post = await app.inject({
method: "POST",
url: "/api/admin/bank-accounts/",
headers: { authorization: auth.authorization() },
payload: { bank: { account_name: `${N}nested`, bank_name: "X" } },
});
expect(post.statusCode).toBe(400);
// And nothing was persisted under that name.
const leaked = await prisma.bank_accounts.findFirst({
where: { account_name: `${N}nested` },
});
expect(leaked).toBeNull();
});
it("requires authentication", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/bank-accounts/",
});
expect(res.statusCode).toBe(401);
});
});

View File

@@ -0,0 +1,132 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import companySettingsRoutes from "../routes/admin/company-settings";
import prisma from "../config/database";
import { config } from "../config/env";
// Fixture prefix so cleanup never touches real data.
const N = "settings_numbering_test_";
let app: ReturnType<typeof Fastify>;
let token: string;
// Snapshot the prior values of the four numbering fields we mutate so we can
// restore them in afterAll — other tests read company_settings and must not be
// poisoned by our writes.
let priorValues: {
issued_order_number_pattern: string | null;
issued_order_type_code: string | null;
project_number_pattern: string | null;
project_type_code: string | null;
} | null = null;
let settingsId: number | null = null;
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(companySettingsRoutes, {
prefix: "/api/admin/company-settings",
});
// Clean up any leftover fixture from a previous failed run.
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
// Attach the fixture user to the admin role so the PUT/GET permission guards
// (settings.company / settings.system) pass.
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
if (!adminRole)
throw new Error("Test setup: no admin role found — seed the database");
const user = await prisma.users.create({
data: {
username: `${N}user`,
email: `${N}user@test.local`,
password_hash: "x",
first_name: "Settings",
last_name: "Test",
is_active: true,
totp_enabled: false,
role_id: adminRole.id,
},
});
token = jwt.sign(
{ sub: user.id, username: user.username, role: "admin" },
config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry },
);
// Snapshot existing settings so we can restore after the suite.
const existing = await prisma.company_settings.findFirst({
select: {
id: true,
issued_order_number_pattern: true,
issued_order_type_code: true,
project_number_pattern: true,
project_type_code: true,
},
});
if (existing) {
settingsId = existing.id;
priorValues = {
issued_order_number_pattern: existing.issued_order_number_pattern,
issued_order_type_code: existing.issued_order_type_code,
project_number_pattern: existing.project_number_pattern,
project_type_code: existing.project_type_code,
};
}
});
afterAll(async () => {
// Restore the four numbering fields to their pre-test values.
if (settingsId !== null && priorValues !== null) {
await prisma.company_settings
.update({ where: { id: settingsId }, data: priorValues })
.catch(() => {});
}
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
await app.close();
});
describe("company-settings numbering — issued orders + projects round-trip", () => {
it("persists and returns issued_order_* and project_* numbering fields", async () => {
const payload = {
issued_order_number_pattern: "{YY}V{NNNN}",
issued_order_type_code: "72",
project_number_pattern: "{YY}P{NNNN}",
project_type_code: "73",
};
const put = await app.inject({
method: "PUT",
url: "/api/admin/company-settings/",
headers: { authorization: `Bearer ${token}` },
payload,
});
expect(put.statusCode).toBe(200);
expect(put.json().success).toBe(true);
const get = await app.inject({
method: "GET",
url: "/api/admin/company-settings/",
headers: { authorization: `Bearer ${token}` },
});
expect(get.statusCode).toBe(200);
const data = get.json().data;
expect(data.issued_order_number_pattern).toBe("{YY}V{NNNN}");
expect(data.issued_order_type_code).toBe("72");
expect(data.project_number_pattern).toBe("{YY}P{NNNN}");
expect(data.project_type_code).toBe("73");
});
it("requires authentication", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/company-settings/",
});
expect(res.statusCode).toBe(401);
});
});

View File

@@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
import { UpdateCustomerSchema } from "../schemas/customers.schema";
import {
CreateCustomerSchema,
UpdateCustomerSchema,
} from "../schemas/customers.schema";
describe("UpdateCustomerSchema", () => {
it("rejects empty name", () => {
@@ -16,4 +19,87 @@ describe("UpdateCustomerSchema", () => {
const result = UpdateCustomerSchema.safeParse({ street: "Main St" });
expect(result.success).toBe(true);
});
it("rejects a name longer than 255 chars", () => {
const result = UpdateCustomerSchema.safeParse({ name: "x".repeat(256) });
expect(result.success).toBe(false);
});
it("accepts nullable address/identity fields set to null", () => {
const result = UpdateCustomerSchema.safeParse({
street: null,
city: null,
postal_code: null,
country: null,
company_id: null,
vat_id: null,
});
expect(result.success).toBe(true);
});
});
describe("CreateCustomerSchema", () => {
it("requires a name", () => {
const result = CreateCustomerSchema.safeParse({ street: "Main St" });
expect(result.success).toBe(false);
});
it("accepts a full valid payload with all optional fields", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
street: "Main St 1",
city: "Praha",
postal_code: "11000",
country: "CZ",
company_id: "12345678",
vat_id: "CZ12345678",
custom_fields: [{ label: "Note", value: "VIP" }],
customer_field_order: ["name", "street"],
});
expect(result.success).toBe(true);
});
it("accepts custom_fields as an array of arbitrary objects", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
custom_fields: [
{ label: "a", value: 1 },
{ nested: { deep: true } },
"raw string",
],
});
expect(result.success).toBe(true);
});
it("rejects custom_fields that is not an array", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
custom_fields: { not: "an array" },
});
expect(result.success).toBe(false);
});
it("rejects custom_fields longer than the 100-item cap", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
custom_fields: Array.from({ length: 101 }, (_, i) => ({ i })),
});
expect(result.success).toBe(false);
});
it("rejects customer_field_order entries that are not strings", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
customer_field_order: ["name", 5],
});
expect(result.success).toBe(false);
});
it("rejects an over-length nullable field (vat_id > 255)", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
vat_id: "x".repeat(256),
});
expect(result.success).toBe(false);
});
});

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

@@ -0,0 +1,116 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createInvoice, getInvoiceStats } from "../services/invoices.service";
import { createOffer } from "../services/offers.service";
// These tests prove that DRAFT documents (a status introduced by the DB-drafts
// feature) are NOT counted in period reporting aggregations. A draft is not a
// real financial document, so its VAT/amount/created-count must be excluded.
//
// They hit the real `app_test` DB (per the suite convention) via the service
// layer directly. Every row we create is tracked and removed in afterEach so we
// leave the DB clean, and we reset the touched number sequences so a finalized
// (issued/active) doc created here doesn't leak a consumed number into other
// files.
const invoiceIds: number[] = [];
const offerIds: number[] = [];
afterEach(async () => {
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
for (const id of offerIds)
await prisma.quotations.deleteMany({ where: { id } });
invoiceIds.length = 0;
offerIds.length = 0;
await prisma.number_sequences.deleteMany({
where: { type: { in: ["invoice", "offer"] } },
});
});
describe("drafts excluded from invoice monthly stats (VAT)", () => {
it("a draft invoice's VAT is NOT in vat_month while an issued one IS", async () => {
// Pick a far-future month so no seeded/other-test invoices fall in it and
// the totals are deterministic. issue_date drives the stats window.
const year = 2099;
const month = 7;
const issueDate = `${year}-0${month}-15`;
// Single CZK line, 21% VAT: base 1000, VAT 210. Identical on both docs so
// the only difference between "draft excluded" and "issued counted" is the
// status filter under test.
const item = { quantity: 1, unit_price: 1000, vat_rate: 21 };
const draft = await createInvoice({
status: "draft",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
issue_date: issueDate,
items: [item],
});
invoiceIds.push(draft.id);
expect(draft.status).toBe("draft");
expect(draft.invoice_number).toBeNull();
// Sanity: with ONLY the draft present, vat_month must be empty (draft excluded).
const draftOnly = await getInvoiceStats(month, year);
expect(draftOnly.vat_month).toEqual([]);
expect(draftOnly.vat_month_czk).toBe(0);
const issued = await createInvoice({
status: "issued",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
issue_date: issueDate,
items: [item],
});
invoiceIds.push(issued.id);
expect(issued.status).toBe("issued");
// Now the issued invoice's VAT (210 CZK) must be counted — and ONLY that
// one, not the draft's (which would double it to 420 if drafts leaked in).
const stats = await getInvoiceStats(month, year);
const czk = stats.vat_month.find((v) => v.currency === "CZK");
expect(czk?.amount).toBe(210);
expect(stats.vat_month_czk).toBe(210);
});
});
describe("drafts excluded from quotations created-this-month count", () => {
it("a draft offer is NOT counted; an active one IS", async () => {
// Mirror the dashboard route's exact aggregation: count quotations created
// in the window, excluding drafts. Use a tight window around `now` (the
// route uses the current month) and assert the delta this test introduces.
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const countNonDraft = () =>
prisma.quotations.count({
where: {
status: { not: "draft" },
created_at: { gte: monthStart, lt: monthEnd },
},
});
const before = await countNonDraft();
const draft = await createOffer({ status: "draft" });
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
offerIds.push(draft.id);
expect(draft.status).toBe("draft");
// The draft must not move the non-draft count.
expect(await countNonDraft()).toBe(before);
const active = await createOffer({ status: "active" });
if ("error" in active)
throw new Error(`createOffer failed: ${active.error}`);
offerIds.push(active.id);
// The active offer is a real document → the count goes up by exactly 1.
expect(await countNonDraft()).toBe(before + 1);
});
});

View File

@@ -0,0 +1,285 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import {
previewIssuedOrderNumber,
previewInvoiceNumber,
previewOfferNumber,
} from "../services/numbering.service";
import {
createIssuedOrder,
updateIssuedOrder,
deleteIssuedOrder,
} from "../services/issued-orders.service";
import {
createInvoice,
updateInvoice,
deleteInvoice,
} from "../services/invoices.service";
import {
createOffer,
updateOffer,
deleteOffer,
} from "../services/offers.service";
// Track every row we create so the suite leaves the (real) app_test DB clean.
const issuedOrderIds: number[] = [];
const invoiceIds: number[] = [];
const offerIds: number[] = [];
afterEach(async () => {
for (const id of issuedOrderIds)
await prisma.issued_orders.deleteMany({ where: { id } });
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
for (const id of offerIds)
await prisma.quotations.deleteMany({ where: { id } });
issuedOrderIds.length = 0;
invoiceIds.length = 0;
offerIds.length = 0;
// Reset every sequence this suite may have touched so preview values are
// stable across tests and we don't leak consumed numbers into other files.
await prisma.number_sequences.deleteMany({
where: { type: { in: ["issued_order", "invoice", "offer"] } },
});
});
/* -------------------------------------------------------------------------- */
/* Issued orders */
/* -------------------------------------------------------------------------- */
describe("issued-order drafts — deferred numbering", () => {
it("a draft has no number and does NOT consume the sequence", async () => {
const before = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({}); // defaults to draft
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
expect(draft.status).toBe("draft");
expect(draft.po_number).toBeNull();
// Preview is unchanged → the sequence was not consumed.
const after = (await previewIssuedOrderNumber()).number;
expect(after).toBe(before);
});
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
const expected = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
const res = await updateIssuedOrder(draft.id, { status: "sent" });
expect("error" in res).toBe(false);
const row = await prisma.issued_orders.findUnique({
where: { id: draft.id },
});
expect(row?.status).toBe("sent");
expect(row?.po_number).toBe(expected);
expect("po_number" in res && res.po_number).toBe(expected);
});
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
await updateIssuedOrder(draft.id, { status: "sent" });
const first = (
await prisma.issued_orders.findUnique({ where: { id: draft.id } })
)?.po_number;
const seqAfterFirst = await prisma.number_sequences.findFirst({
where: { type: "issued_order" },
});
// draft -> sent is no longer valid (already sent); the number must not change.
// Drive a second finalize-style update that keeps status sent.
await updateIssuedOrder(draft.id, { status: "sent" });
const second = (
await prisma.issued_orders.findUnique({ where: { id: draft.id } })
)?.po_number;
const seqAfterSecond = await prisma.number_sequences.findFirst({
where: { type: "issued_order" },
});
expect(second).toBe(first);
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
});
it("two drafts coexist with null numbers (no unique violation)", async () => {
const a = await createIssuedOrder({});
const b = await createIssuedOrder({});
if ("error" in a) throw new Error(`createIssuedOrder failed: ${a.error}`);
if ("error" in b) throw new Error(`createIssuedOrder failed: ${b.error}`);
issuedOrderIds.push(a.id, b.id);
expect(a.po_number).toBeNull();
expect(b.po_number).toBeNull();
});
it("deleting a draft releases nothing (no number was consumed)", async () => {
const before = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
await deleteIssuedOrder(draft.id);
const after = (await previewIssuedOrderNumber()).number;
expect(after).toBe(before);
});
});
/* -------------------------------------------------------------------------- */
/* Invoices */
/* -------------------------------------------------------------------------- */
describe("invoice drafts — deferred numbering", () => {
it("a draft has no number and does NOT consume the sequence", async () => {
const before = (await previewInvoiceNumber()).number;
const draft = await createInvoice({});
invoiceIds.push(draft.id);
expect(draft.status).toBe("draft");
expect(draft.invoice_number).toBeNull();
const after = (await previewInvoiceNumber()).number;
expect(after).toBe(before);
});
it("finalizing a draft (draft -> issued) assigns the next sequence number", async () => {
const expected = (await previewInvoiceNumber()).number;
const draft = await createInvoice({});
invoiceIds.push(draft.id);
const res = await updateInvoice(draft.id, { status: "issued" });
expect("error" in res).toBe(false);
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.status).toBe("issued");
expect(row?.invoice_number).toBe(expected);
expect("invoice_number" in res && res.invoice_number).toBe(expected);
});
it("finalizing twice does not re-number / double-consume", async () => {
const draft = await createInvoice({});
invoiceIds.push(draft.id);
await updateInvoice(draft.id, { status: "issued" });
const first = (
await prisma.invoices.findUnique({ where: { id: draft.id } })
)?.invoice_number;
const seqAfterFirst = await prisma.number_sequences.findFirst({
where: { type: "invoice" },
});
// issued -> issued is a no-op status-wise; the number must stay put.
await updateInvoice(draft.id, { status: "issued" });
const second = (
await prisma.invoices.findUnique({ where: { id: draft.id } })
)?.invoice_number;
const seqAfterSecond = await prisma.number_sequences.findFirst({
where: { type: "invoice" },
});
expect(second).toBe(first);
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
});
it("two invoice drafts coexist with null numbers", async () => {
const a = await createInvoice({});
const b = await createInvoice({});
invoiceIds.push(a.id, b.id);
expect(a.invoice_number).toBeNull();
expect(b.invoice_number).toBeNull();
});
it("deleting a draft releases nothing", async () => {
const before = (await previewInvoiceNumber()).number;
const draft = await createInvoice({});
await deleteInvoice(draft.id);
const after = (await previewInvoiceNumber()).number;
expect(after).toBe(before);
});
});
/* -------------------------------------------------------------------------- */
/* Offers */
/* -------------------------------------------------------------------------- */
describe("offer drafts — deferred numbering", () => {
it("a draft has no number and does NOT consume the sequence", async () => {
const before = await previewOfferNumber();
const draft = await createOffer({});
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
offerIds.push(draft.id);
expect(draft.status).toBe("draft");
expect(draft.quotation_number).toBeNull();
const after = await previewOfferNumber();
expect(after).toBe(before);
});
it("finalizing a draft (draft -> active) assigns the next sequence number", async () => {
const expected = await previewOfferNumber();
const draft = await createOffer({});
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
offerIds.push(draft.id);
const res = await updateOffer(draft.id, { status: "active" });
expect("error" in res).toBe(false);
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
expect(row?.status).toBe("active");
expect(row?.quotation_number).toBe(expected);
expect("quotation_number" in res && res.quotation_number).toBe(expected);
});
it("finalizing twice does not re-number / double-consume", async () => {
const draft = await createOffer({});
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
offerIds.push(draft.id);
await updateOffer(draft.id, { status: "active" });
const first = (
await prisma.quotations.findUnique({ where: { id: draft.id } })
)?.quotation_number;
const seqAfterFirst = await prisma.number_sequences.findFirst({
where: { type: "offer" },
});
// active -> active is a no-op; the number must stay put.
await updateOffer(draft.id, { status: "active" });
const second = (
await prisma.quotations.findUnique({ where: { id: draft.id } })
)?.quotation_number;
const seqAfterSecond = await prisma.number_sequences.findFirst({
where: { type: "offer" },
});
expect(second).toBe(first);
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
});
it("two offer drafts coexist with null numbers", async () => {
const a = await createOffer({});
const b = await createOffer({});
if ("error" in a) throw new Error(`createOffer failed: ${a.error}`);
if ("error" in b) throw new Error(`createOffer failed: ${b.error}`);
offerIds.push(a.id, b.id);
expect(a.quotation_number).toBeNull();
expect(b.quotation_number).toBeNull();
});
it("an offer created already-finalized (status active) numbers immediately", async () => {
const expected = await previewOfferNumber();
const offer = await createOffer({ status: "active" });
if ("error" in offer) throw new Error(`createOffer failed: ${offer.error}`);
offerIds.push(offer.id);
expect(offer.quotation_number).toBe(expected);
});
it("deleting a draft releases nothing", async () => {
const before = await previewOfferNumber();
const draft = await createOffer({});
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
await deleteOffer(draft.id);
const after = await previewOfferNumber();
expect(after).toBe(before);
});
});

View File

@@ -32,3 +32,37 @@ describe("env validation", () => {
expect(config.db.url).toBeTruthy();
});
});
describe("env validation — failure path", () => {
// The config singleton runs its validation at module-load time and is then
// cached by the module system, so we cannot re-import it with bad env to
// observe a throw without complex module-registry resetting. Instead we test
// the exact predicates config/env.ts uses to reject bad input — this is the
// logic that protects the boot, and the loaded config above proves the
// happy path. (Predicates kept in sync with src/config/env.ts.)
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
it("rejects a too-short / non-hex JWT_SECRET", () => {
expect(HEX64_RE.test("short")).toBe(false);
expect(HEX64_RE.test("a".repeat(63))).toBe(false); // one char short
expect(HEX64_RE.test("z".repeat(64))).toBe(false); // 64 chars, not hex
// The real config's secret must pass the very same check.
expect(HEX64_RE.test(config.jwt.secret)).toBe(true);
});
it("rejects an out-of-range or non-numeric PORT", () => {
const portValid = (p: number) => !Number.isNaN(p) && p >= 1 && p <= 65535;
expect(portValid(parseInt("0", 10))).toBe(false);
expect(portValid(parseInt("70000", 10))).toBe(false);
expect(portValid(parseInt("notaport", 10))).toBe(false); // NaN
expect(portValid(config.port)).toBe(true);
});
it("rejects a non-positive ACCESS_TOKEN_EXPIRY", () => {
const expiryValid = (n: number) => !Number.isNaN(n) && n > 0;
expect(expiryValid(0)).toBe(false);
expect(expiryValid(-1)).toBe(false);
expect(expiryValid(parseInt("oops", 10))).toBe(false); // NaN
expect(expiryValid(config.jwt.accessTokenExpiry)).toBe(true);
});
});

View File

@@ -1,5 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { toCzk, getRate } from "../services/exchange-rates";
import {
toCzk,
getRate,
__resetRateCacheForTest,
} from "../services/exchange-rates";
// Mock global fetch
const mockFetch = vi.fn();
@@ -8,6 +12,11 @@ global.fetch = mockFetch;
describe("exchange-rates", () => {
beforeEach(() => {
mockFetch.mockReset();
// The rate cache is module-level and persists across tests. Without this
// reset, the first test to populate "today" would short-circuit every
// later mockFetch resolution (cache hit), making assertions order-dependent
// and asserting stale rates. Reset it so each test gets a clean fetch.
__resetRateCacheForTest();
});
describe("toCzk", () => {
@@ -43,6 +52,21 @@ describe("exchange-rates", () => {
const result = await toCzk(100, "EUR");
expect(result).toBe(2500);
});
it("honours amount != 1 (currency quoted per 100 units)", async () => {
// CNB quotes some currencies (e.g. HUF, JPY) per 100 units: the `rate`
// is for `amount` units, so the per-unit rate is rate/amount. Here the
// per-unit rate is 250/100 = 2.5 CZK; converting 100 units → 250 CZK.
// If the service ignored `amount` it would compute 100*250 = 25000.
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }],
}),
});
const result = await toCzk(100, "HUF");
expect(result).toBe(250);
});
});
describe("getRate", () => {
@@ -60,5 +84,17 @@ describe("exchange-rates", () => {
});
await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
});
it("returns the per-unit rate for amount != 1", async () => {
// rate 250 for amount 100 → per-unit rate 2.5 (CZK per 1 unit).
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }],
}),
});
const result = await getRate("HUF");
expect(result).toBe(2.5);
});
});
});

View File

@@ -13,13 +13,31 @@ export async function buildApp() {
return app;
}
export function extractCookie(response: any, name: string): string | undefined {
/**
* Minimal structural shape of the only thing extractCookie needs from a
* response — its `set-cookie` header. Matches Fastify's `LightMyRequestResponse`
* (from `app.inject`) and a plain supertest response without coupling to either.
*/
interface ResponseWithCookies {
headers: Record<string, string | string[] | number | undefined>;
}
export function extractCookie(
response: ResponseWithCookies,
name: string,
): string | undefined {
const cookies = response.headers["set-cookie"];
if (!cookies) return undefined;
const arr = Array.isArray(cookies) ? cookies : [cookies];
for (const c of arr) {
if (typeof c !== "string") continue;
if (c.startsWith(`${name}=`)) {
return c.split(";")[0].split("=")[1];
// The value is everything from the first "=" up to the first ";".
// Split ONLY on the first "=" so a base64/JWT value containing "=" (e.g.
// padding) is not truncated — `split("=")[1]` would drop everything after
// the second "=".
const pair = c.split(";")[0];
return pair.slice(pair.indexOf("=") + 1);
}
}
return undefined;

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,20 +1,53 @@
import { describe, it, expect } from "vitest";
import { invoiceTotalWithVat } from "../services/invoices.service";
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { Prisma } from "@prisma/client";
import prisma from "../config/database";
import {
invoiceTotalWithVat,
createInvoice,
updateInvoice,
listInvoices,
getInvoiceListTotals,
} from "../services/invoices.service";
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
// real Decimals here — instead of the old `{ toNumber: () => N }` duck types —
// means a regression in genuine Decimal handling (e.g. swapping `.toNumber()`
// for `Number(decimal)`) is actually caught.
const dec = (n: number | string) => new Prisma.Decimal(n);
// Helper so each test reads as a small declarative table of lines.
function buildInvoice(opts: {
applyVat: boolean;
vatRate: number | null;
currency?: string;
items: Array<{
quantity: number | null;
unit_price: number | null;
vat_rate: number | null;
}>;
}) {
return {
apply_vat: opts.applyVat,
vat_rate: opts.vatRate === null ? null : dec(opts.vatRate),
currency: opts.currency ?? "CZK",
invoice_items: opts.items.map((i) => ({
quantity: i.quantity === null ? null : dec(i.quantity),
unit_price: i.unit_price === null ? null : dec(i.unit_price),
vat_rate: i.vat_rate === null ? null : dec(i.vat_rate),
})),
};
}
describe("invoiceTotalWithVat", () => {
it("calculates subtotal without VAT when apply_vat is false", () => {
const inv = {
apply_vat: false,
vat_rate: { toNumber: () => 21 },
currency: "CZK",
invoice_items: [
{
quantity: { toNumber: () => 2 },
unit_price: { toNumber: () => 100 },
vat_rate: { toNumber: () => 21 },
},
],
};
const inv = buildInvoice({
applyVat: false,
vatRate: 21,
items: [{ quantity: 2, unit_price: 100, vat_rate: 21 }],
});
expect(invoiceTotalWithVat(inv)).toBe(200);
});
@@ -24,32 +57,207 @@ describe("invoiceTotalWithVat", () => {
// Total VAT = 7.00 * 3 = 21.00
// Subtotal = 33.33 * 3 = 99.99
// Total = 99.99 + 21.00 = 120.99
const inv = {
apply_vat: true,
vat_rate: { toNumber: () => 21 },
currency: "CZK",
invoice_items: Array.from({ length: 3 }, () => ({
quantity: { toNumber: () => 1 },
unit_price: { toNumber: () => 33.33 },
vat_rate: { toNumber: () => 21 },
const inv = buildInvoice({
applyVat: true,
vatRate: 21,
items: Array.from({ length: 3 }, () => ({
quantity: 1,
unit_price: 33.33,
vat_rate: 21,
})),
};
});
expect(invoiceTotalWithVat(inv)).toBe(120.99);
});
it("handles null quantity and unit_price gracefully", () => {
const inv = {
apply_vat: true,
vat_rate: { toNumber: () => 21 },
currency: "CZK",
invoice_items: [
{
quantity: { toNumber: () => 0 },
unit_price: { toNumber: () => 0 },
vat_rate: { toNumber: () => 21 },
},
it("treats a null quantity (and null unit_price) as 0 for that line", () => {
// The line is genuinely null here (not 0): quantity?.toNumber() === undefined
// -> Number(undefined) is NaN -> `|| 0` -> base 0. A second, well-formed
// line proves the null line contributes nothing while the rest still totals.
const inv = buildInvoice({
applyVat: true,
vatRate: 21,
items: [
{ quantity: null, unit_price: null, vat_rate: 21 },
{ quantity: 1, unit_price: 100, vat_rate: 21 }, // base 100, vat 21
],
};
});
expect(invoiceTotalWithVat(inv)).toBe(121);
});
it("returns 0 when every line is null", () => {
const inv = buildInvoice({
applyVat: true,
vatRate: 21,
items: [{ quantity: null, unit_price: null, vat_rate: null }],
});
expect(invoiceTotalWithVat(inv)).toBe(0);
});
it("applies mixed per-line vat_rate values, falling back to the invoice rate when a line rate is 0/absent", () => {
// Line 1: 2 x 100 @ 21% -> base 200, vat 42
// Line 2: 1 x 50 @ 12% -> base 50, vat 6
// Line 3: 1 x 100 @ 0% -> a line rate of 0 falls through to the invoice
// default (15%) per the `|| inv.vat_rate || 21` semantics -> vat 15
// Subtotal = 350, VAT = 63, Total = 413
const inv = buildInvoice({
applyVat: true,
vatRate: 15,
items: [
{ quantity: 2, unit_price: 100, vat_rate: 21 },
{ quantity: 1, unit_price: 50, vat_rate: 12 },
{ quantity: 1, unit_price: 100, vat_rate: 0 },
],
});
expect(invoiceTotalWithVat(inv)).toBe(413);
});
it("handles a negative (discount) line correctly", () => {
// Line 1: 1 x 1000 @ 21% -> base 1000, vat 210
// Line 2 (discount): 1 x -200 @ 21% -> base -200, vat -42
// Subtotal = 800, VAT = 168, Total = 968
const inv = buildInvoice({
applyVat: true,
vatRate: 21,
items: [
{ quantity: 1, unit_price: 1000, vat_rate: 21 },
{ quantity: 1, unit_price: -200, vat_rate: 21 },
],
});
expect(invoiceTotalWithVat(inv)).toBe(968);
});
});
/* -------------------------------------------------------------------------- */
/* PUT regression — billing_text must survive UpdateInvoiceSchema */
/* -------------------------------------------------------------------------- */
// The PUT route does `parseBody(UpdateInvoiceSchema, body)` and hands the
// PARSED data to `updateInvoice`. UpdateInvoiceSchema is a plain z.object,
// which STRIPS unknown keys — so a field missing from the schema is silently
// dropped before the service ever sees it, while the client still gets a
// success response. This block mirrors that exact flow against the real DB.
describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema", () => {
const invoiceIds: number[] = [];
afterEach(async () => {
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
invoiceIds.length = 0;
});
it("persists an edited billing_text (the schema must not strip it)", async () => {
const draft = await createInvoice({ billing_text: "Původní text" });
invoiceIds.push(draft.id);
// Mirror the route: raw body -> UpdateInvoiceSchema -> updateInvoice.
const parsed = UpdateInvoiceSchema.parse({
billing_text: "Fakturujeme Vám dle objednávky č. 123",
});
expect(parsed.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
const res = await updateInvoice(draft.id, parsed);
expect("error" in res).toBe(false);
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
});
it("clears billing_text when null is sent, and leaves it untouched when absent", async () => {
const draft = await createInvoice({ billing_text: "Text na PDF" });
invoiceIds.push(draft.id);
// Absent from the body -> updateInvoice's `!== undefined` guard skips it.
await updateInvoice(draft.id, UpdateInvoiceSchema.parse({ notes: "x" }));
let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBe("Text na PDF");
// Explicit null -> cleared (the strFields path maps falsy -> null).
await updateInvoice(
draft.id,
UpdateInvoiceSchema.parse({ billing_text: null }),
);
row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBeNull();
});
});
/* -------------------------------------------------------------------------- */
/* Month filter boundaries — issue_date is @db.Date */
/* -------------------------------------------------------------------------- */
// issue_date is @db.Date: Prisma compares a Date filter by its UTC date part.
// The old LOCAL-midnight month bounds (= 22:00/23:00 UTC of the previous day)
// shifted the window a day back — the list/totals included the previous
// month's last day and DROPPED invoices issued on the selected month's LAST
// day. buildInvoiceWhere is shared by listInvoices AND getInvoiceListTotals,
// so both are exercised. Fixtures use the test-only "TST" currency + far-future
// 2098 dates so totals can be asserted exactly without colliding with other
// suites' rows.
describe("listInvoices / getInvoiceListTotals — month filter (@db.Date boundaries)", () => {
const cleanup = async () => {
// invoice_items cascade on invoice delete.
await prisma.invoices.deleteMany({ where: { currency: "TST" } });
};
beforeEach(cleanup);
afterEach(cleanup);
const listParams = {
page: 1,
limit: 100,
skip: 0,
sort: "id",
order: "asc" as const,
search: "",
};
async function createBoundaryFixtures() {
// Drafts: no invoice number is consumed, but buildInvoiceWhere has no
// status filter so they appear in the list/totals like any invoice.
const lastDayJan = await createInvoice({
status: "draft",
issue_date: "2098-01-31",
currency: "TST",
vat_rate: 21,
items: [{ quantity: 1, unit_price: 100, vat_rate: 21 }], // total 121
});
const firstDayFeb = await createInvoice({
status: "draft",
issue_date: "2098-02-01",
currency: "TST",
vat_rate: 21,
items: [{ quantity: 1, unit_price: 200, vat_rate: 21 }], // total 242
});
return { lastDayJan, firstDayFeb };
}
it("a month's list contains its own LAST day and not the neighbor month's first day", async () => {
const { lastDayJan, firstDayFeb } = await createBoundaryFixtures();
const jan = await listInvoices({ ...listParams, month: 1, year: 2098 });
const janIds = jan.data.map((i) => i.id);
expect(janIds).toContain(lastDayJan.id);
expect(janIds).not.toContain(firstDayFeb.id);
const feb = await listInvoices({ ...listParams, month: 2, year: 2098 });
const febIds = feb.data.map((i) => i.id);
expect(febIds).toContain(firstDayFeb.id);
expect(febIds).not.toContain(lastDayJan.id);
});
it("per-currency totals include the month's last day exactly once", async () => {
await createBoundaryFixtures();
const jan = await getInvoiceListTotals({ month: 1, year: 2098 });
const janTst = jan.totals.find((t) => t.currency === "TST");
// 100 + 21 % VAT — proves the Jan 31 invoice is counted and the
// Feb 1 invoice (242) is NOT pulled into January.
expect(janTst?.amount).toBe(121);
const feb = await getInvoiceListTotals({ month: 2, year: 2098 });
const febTst = feb.totals.find((t) => t.currency === "TST");
expect(febTst?.amount).toBe(242);
});
});

View File

@@ -0,0 +1,565 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import {
generateIssuedOrderNumber,
previewIssuedOrderNumber,
releaseIssuedOrderNumber,
} from "../services/numbering.service";
import { CreateIssuedOrderSchema } from "../schemas/issued-orders.schema";
import {
computeIssuedOrderTotals,
createIssuedOrder,
getIssuedOrder,
listIssuedOrders,
updateIssuedOrder,
deleteIssuedOrder,
type IssuedOrderInput,
} from "../services/issued-orders.service";
import issuedOrdersRoutes from "../routes/admin/issued-orders";
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } });
});
describe("issued-order numbering", () => {
it("generates sequential numbers", async () => {
const year = new Date().getFullYear();
const a = await generateIssuedOrderNumber(year);
const b = await generateIssuedOrderNumber(year);
expect(Number(b.number)).toBe(Number(a.number) + 1);
});
it("preview does not consume the sequence", async () => {
const year = new Date().getFullYear();
const p1 = await previewIssuedOrderNumber(year);
const p2 = await previewIssuedOrderNumber(year);
expect(p1.number).toBe(p2.number);
});
it("release frees only the highest number", async () => {
const year = new Date().getFullYear();
await generateIssuedOrderNumber(year); // seq 1
await generateIssuedOrderNumber(year); // seq 2
const c = await generateIssuedOrderNumber(year); // seq 3 (current highest)
const beforeRelease = (await previewIssuedOrderNumber(year)).number; // seq 4
// Releasing a number that is NOT the current highest is a no-op.
await releaseIssuedOrderNumber(year, "00000000");
expect((await previewIssuedOrderNumber(year)).number).toBe(beforeRelease);
// Releasing the current highest reclaims it.
await releaseIssuedOrderNumber(year, c.number);
expect((await previewIssuedOrderNumber(year)).number).toBe(c.number);
});
});
describe("CreateIssuedOrderSchema", () => {
it("coerces string form numbers and rejects a NaN quantity", () => {
const ok = CreateIssuedOrderSchema.safeParse({
supplier_id: "5",
items: [{ description: "X", quantity: "2", unit_price: "100" }],
});
expect(ok.success).toBe(true);
if (ok.success) {
expect(ok.data.supplier_id).toBe(5);
expect(ok.data.items![0].quantity).toBe(2);
expect(ok.data.items![0].unit_price).toBe(100);
}
const bad = CreateIssuedOrderSchema.safeParse({
items: [{ description: "X", quantity: "not-a-number" }],
});
expect(bad.success).toBe(false);
});
});
const createdIds: number[] = [];
const createdSupplierIds: number[] = [];
afterEach(async () => {
// Orders first — the supplier FK is onDelete Restrict.
for (const id of createdIds)
await prisma.issued_orders.deleteMany({ where: { id } });
for (const id of createdSupplierIds)
await prisma.sklad_suppliers.deleteMany({ where: { id } });
createdIds.length = 0;
createdSupplierIds.length = 0;
});
async function makeSupplier(
data: Partial<{ name: string; ico: string; is_active: boolean }> = {},
) {
const s = await prisma.sklad_suppliers.create({
data: {
name: data.name ?? "io_test_Dodavatel s.r.o.",
ico: data.ico ?? null,
is_active: data.is_active ?? true,
},
});
createdSupplierIds.push(s.id);
return s;
}
/** createIssuedOrder + narrow the supplier-validation union + track cleanup. */
async function mkIssued(input: IssuedOrderInput = {}) {
const res = await createIssuedOrder(input);
if ("error" in res) throw new Error(`createIssuedOrder failed: ${res.error}`);
createdIds.push(res.id);
return res;
}
describe("computeIssuedOrderTotals (NET only — no VAT on issued orders)", () => {
it("sums qty × unit_price per line, rounded to 2dp", () => {
const t = computeIssuedOrderTotals([
{ quantity: 2, unit_price: 100 },
{ quantity: 1, unit_price: 50 },
]);
expect(t).toEqual({ total: 250 });
});
it("treats null/garbage numerics as zero", () => {
const t = computeIssuedOrderTotals([
{ quantity: null, unit_price: 100 },
{ quantity: 3, unit_price: "abc" },
{ quantity: 2, unit_price: "10.555" },
]);
expect(t).toEqual({ total: 21.11 });
});
});
describe("createIssuedOrder", () => {
it("defaults to draft with NO PO number, stores supplier_id + items", async () => {
const s = await makeSupplier();
const order = await mkIssued({
supplier_id: s.id,
items: [{ description: "Materiál", quantity: 2, unit_price: 100 }],
});
// Deferred numbering: a draft carries no number.
expect(order.po_number).toBeNull();
expect(order.status).toBe("draft");
expect(order.supplier_id).toBe(s.id);
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
});
expect(items.length).toBe(1);
expect(Number(items[0].unit_price)).toBe(100);
});
it("numbers immediately when created already-finalized (status sent)", async () => {
const before = (await previewIssuedOrderNumber()).number;
const order = await mkIssued({ status: "sent" });
expect(order.po_number).toBe(before);
expect(order.status).toBe("sent");
});
it("rejects a nonexistent supplier_id instead of throwing P2003", async () => {
const res = await createIssuedOrder({ supplier_id: 99999999 });
expect("error" in res && res.error).toBe("supplier_not_found");
});
it("persists order_text on create, updates and clears it on update", async () => {
const order = await mkIssued({ order_text: "Objednáváme dle smlouvy:" });
let row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.order_text).toBe("Objednáváme dle smlouvy:");
await updateIssuedOrder(order.id, { order_text: "Jiný text:" });
row = await prisma.issued_orders.findUnique({ where: { id: order.id } });
expect(row!.order_text).toBe("Jiný text:");
// null clears back to the PDF default.
await updateIssuedOrder(order.id, { order_text: null });
row = await prisma.issued_orders.findUnique({ where: { id: order.id } });
expect(row!.order_text).toBeNull();
});
});
describe("updateIssuedOrder status transitions", () => {
it("allows draft -> sent and rejects draft -> completed", async () => {
const order = await mkIssued({});
const ok = await updateIssuedOrder(order.id, { status: "sent" });
expect("error" in ok).toBe(false);
const bad = await updateIssuedOrder(order.id, { status: "completed" });
expect("error" in bad && bad.error).toBe("invalid_transition");
});
it("locks items once confirmed", async () => {
const order = await mkIssued({
items: [{ description: "A", quantity: 1, unit_price: 10 }],
});
await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" });
await updateIssuedOrder(order.id, {
items: [{ description: "B", quantity: 9, unit_price: 99 }],
});
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
});
expect(items.length).toBe(1);
expect(items[0].description).toBe("A");
});
it("rejects a nonexistent supplier_id on update", async () => {
const order = await mkIssued({});
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
expect("error" in res && res.error).toBe("supplier_not_found");
});
});
describe("getIssuedOrder", () => {
it("returns supplier, supplier_name and valid_transitions", async () => {
const s = await makeSupplier();
const order = await mkIssued({ supplier_id: s.id });
const detail = await getIssuedOrder(order.id);
expect(detail?.supplier_name).toBe("io_test_Dodavatel s.r.o.");
expect(detail?.supplier?.id).toBe(s.id);
expect(detail?.valid_transitions).toContain("sent");
});
});
describe("deleteIssuedOrder", () => {
it("deletes, cascades items, frees the latest number", async () => {
const order = await mkIssued({
items: [{ description: "X", quantity: 1, unit_price: 1 }],
});
// Finalize so the order has a real (consumed) number to free on delete.
const finalized = await updateIssuedOrder(order.id, { status: "sent" });
const before = "po_number" in finalized ? finalized.po_number : null;
expect(before).toBeTruthy();
await deleteIssuedOrder(order.id);
expect(
await prisma.issued_orders.findUnique({ where: { id: order.id } }),
).toBeNull();
expect(
(
await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
})
).length,
).toBe(0);
expect((await previewIssuedOrderNumber()).number).toBe(before);
});
it("releases the number against the order's creation year, not the current year", async () => {
const priorYear = new Date().getFullYear() - 1;
// Allocate a real prior-year number (consumes that year's sequence: 0 -> 1)
// so the PO number matches the prior year's current highest.
const { number: poNumber } = await generateIssuedOrderNumber(priorYear);
const order = await mkIssued({ po_number: poNumber });
// Backdate creation into the prior year so delete derives that year.
await prisma.issued_orders.update({
where: { id: order.id },
data: { created_at: new Date(priorYear, 5, 1, 12, 0, 0) },
});
await deleteIssuedOrder(order.id);
// The prior-year sequence must be decremented (1 -> 0). With the old
// current-year bug it would stay at 1 (no current-year row to match).
const seq = await prisma.number_sequences.findFirst({
where: { type: "issued_order", year: priorYear },
});
expect(seq?.last_number).toBe(0);
});
});
describe("listIssuedOrders month filter", () => {
it("returns only orders whose order_date is in the given month", async () => {
const inMonth = await mkIssued({ order_date: "2026-03-15" });
const outMonth = await mkIssued({ order_date: "2026-04-15" });
const res = await listIssuedOrders({
page: 1,
limit: 50,
skip: 0,
sort: "id",
order: "desc",
month: 3,
year: 2026,
});
const ids = res.data.map((o) => o.id);
expect(ids).toContain(inMonth.id);
expect(ids).not.toContain(outMonth.id);
});
});
/* -------------------------------------------------------------------------- */
/* Route-level: suppliers lookup endpoint + supplier validation */
/* -------------------------------------------------------------------------- */
let app: ReturnType<typeof Fastify> | null = null;
let adminToken = "";
let noPermToken = "";
let noPermUserId = 0;
let noPermRoleId = 0;
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" },
);
}
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(issuedOrdersRoutes, {
prefix: "/api/admin/issued-orders",
});
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminToken = generateToken({
id: admin.id,
username: admin.username,
roleName: "admin",
});
// A role with NO orders permissions, to prove the lookup endpoint is gated.
const noPermRole = await prisma.roles.create({
data: {
name: "io_test_no_orders",
display_name: "Test No Orders",
description: "Test role without orders permissions",
},
});
noPermRoleId = noPermRole.id;
const noPermUser = await prisma.users.create({
data: {
username: "io_test_noperm",
email: "io_test_noperm@test.local",
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "No",
last_name: "Orders",
is_active: true,
role_id: noPermRole.id,
},
});
noPermUserId = noPermUser.id;
noPermToken = generateToken({
id: noPermUser.id,
username: noPermUser.username,
roleName: noPermRole.name,
});
});
afterAll(async () => {
if (noPermUserId)
await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {});
if (noPermRoleId)
await prisma.roles.delete({ where: { id: noPermRoleId } }).catch(() => {});
if (app) await app.close();
});
describe("GET /api/admin/issued-orders/suppliers", () => {
it("returns active suppliers only (with the picker fields)", async () => {
const active = await makeSupplier({
name: "io_test_Aktivní dodavatel",
ico: "12345678",
});
const inactive = await makeSupplier({
name: "io_test_Neaktivní dodavatel",
is_active: false,
});
const res = await app!.inject({
method: "GET",
url: "/api/admin/issued-orders/suppliers",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
const ids = body.data.map((s: { id: number }) => s.id);
expect(ids).toContain(active.id);
expect(ids).not.toContain(inactive.id);
const row = body.data.find((s: { id: number }) => s.id === active.id);
expect(row.name).toBe("io_test_Aktivní dodavatel");
expect(row.ico).toBe("12345678");
// Lightweight lookup shape — all picker fields present.
expect(Object.keys(row).sort()).toEqual(
["address", "dic", "email", "ico", "id", "name", "phone"].sort(),
);
});
it("is denied (403) to a user with no orders permissions", async () => {
const res = await app!.inject({
method: "GET",
url: "/api/admin/issued-orders/suppliers",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
expect(res.json().success).toBe(false);
});
});
describe("POST /api/admin/issued-orders supplier validation", () => {
it("returns 400 (not a P2003 500) for a nonexistent supplier_id", async () => {
const res = await app!.inject({
method: "POST",
url: "/api/admin/issued-orders",
headers: { Authorization: `Bearer ${adminToken}` },
payload: { supplier_id: 99999999 },
});
expect(res.statusCode).toBe(400);
const body = res.json();
expect(body.success).toBe(false);
expect(body.error).toBe("Dodavatel nenalezen");
});
});
/* -------------------------------------------------------------------------- */
/* PDF render */
/* -------------------------------------------------------------------------- */
describe("renderIssuedOrderHtml", () => {
const order = {
po_number: "26720001",
order_date: new Date("2026-06-09T12:00:00"),
delivery_date: null,
currency: "CZK",
notes: "<p>Pozn</p><script>alert(1)</script>",
delivery_terms: null,
payment_terms: null,
issued_by: null,
};
const items = [
{
description: "Materiál",
item_description: "Detailní popis položky",
quantity: 2,
unit: "ks",
unit_price: 100,
},
];
const issuer = { name: "Jan Novák" };
// sklad_suppliers shape: single Text address blob + ico/dic columns.
const supplier = {
name: "Dodavatel s.r.o.",
ico: "12345678",
dic: "CZ12345678",
address: "Průmyslová 5\n190 00 Praha",
};
it("renders the PO number, items, and both party names (PO direction)", () => {
const html = renderIssuedOrderHtml(
order,
items,
supplier,
{ company_name: "Naše firma" },
"cs",
issuer,
);
expect(html).toContain("26720001");
expect(html).toContain("Materiál");
// Optional item sub-line.
expect(html).toContain("Detailní popis položky");
// PO direction: the sklad_suppliers record is the Dodavatel (supplier),
// our company is the Odběratel (buyer).
expect(html).toContain("Dodavatel s.r.o.");
expect(html).toContain("Naše firma");
expect(html).toContain("Dodavatel");
expect(html).toContain("Odběratel");
});
it("renders the supplier address (split on newlines) plus IČO and DIČ", () => {
const html = renderIssuedOrderHtml(
order,
items,
supplier,
null,
"cs",
issuer,
);
// The single Text address blob becomes one address line per newline.
expect(html).toContain('<div class="address-line">Průmyslová 5</div>');
expect(html).toContain('<div class="address-line">190 00 Praha</div>');
expect(html).toContain("IČ: 12345678");
expect(html).toContain("DIČ: CZ12345678");
});
it("renders a no-newline address as a single line and skips missing IČO/DIČ", () => {
const html = renderIssuedOrderHtml(
order,
items,
{ name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" },
null,
"cs",
issuer,
);
expect(html).toContain(
'<div class="address-line">Ulice 1, 100 00 Praha</div>',
);
expect(html).not.toContain("IČ: ");
expect(html).not.toContain("DIČ: ");
});
it("strips script tags from notes", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
expect(html).not.toContain("<script>");
expect(html).not.toContain("alert(1)");
});
it("renders the custom order_text heading when set, default when not", () => {
const custom = renderIssuedOrderHtml(
{ ...order, order_text: "Objednáváme dle nabídky č. 123:" },
items,
null,
null,
"cs",
issuer,
);
expect(custom).toContain("Objednáváme dle nabídky č. 123:");
expect(custom).not.toContain("Objednáváme si u Vás:");
const fallback = renderIssuedOrderHtml(
order,
items,
null,
null,
"cs",
issuer,
);
expect(fallback).toContain("Objednáváme si u Vás:");
});
it("never renders VAT columns or notices; totals NET with 'Celkem bez DPH'", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
// No VAT anywhere — the PO is not a tax document.
expect(html).not.toContain("%DPH");
expect(html).not.toContain(">DPH<");
// Line total = netto (2 × 100).
expect(html).toContain('<td class="right total-cell">200,00</td>');
expect(html).toContain("Celkem bez DPH");
// No Mezisoučet row (it would duplicate the grand total) and no
// prices-excl.-VAT notice (user removed it — the total label suffices).
expect(html).not.toContain("Mezisoučet");
expect(html).not.toContain("Ceny jsou uvedeny bez DPH");
});
it("renders the English total label for lang=en, no VAT columns or notice", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "en", issuer);
expect(html).toContain("Total excl. VAT");
expect(html).not.toContain("VAT%");
expect(html).not.toContain("Prices are exclusive of VAT");
});
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
// Footer: Vystavil <name> from authData. No e-mail line.
expect(html).toContain("Jan Novák");
expect(html).not.toContain("E-mail:");
// The old two-column signature footer is gone.
expect(html).not.toContain("footer-row");
expect(html).not.toContain("Schválil:");
});
});

View File

@@ -1,85 +1,218 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createProject } from "../services/projects.service";
import { createOrder } from "../services/orders.service";
import {
createOrder,
createOrderFromQuotation,
deleteOrder,
} from "../services/orders.service";
import { previewProjectNumber } from "../services/numbering.service";
const YEAR = new Date().getFullYear();
const createdProjectIds: number[] = [];
const createdOrderIds: number[] = [];
const createdQuotationIds: number[] = [];
afterEach(async () => {
for (const id of createdProjectIds)
await prisma.projects.deleteMany({ where: { id } });
for (const id of createdOrderIds)
await prisma.orders.deleteMany({ where: { id } });
for (const id of createdQuotationIds)
await prisma.quotations.deleteMany({ where: { id } });
createdProjectIds.length = 0;
createdOrderIds.length = 0;
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
createdQuotationIds.length = 0;
// Orders and projects now draw from SEPARATE sequences — reset both.
await prisma.number_sequences.deleteMany({
where: { type: { in: ["shared", "project"] } },
});
});
/**
* Read the current `last_number` of a (type, current-year) sequence row, or 0
* when the row doesn't exist yet (the first allocation creates it at 1). Used to
* prove each generator advances its OWN row by exactly 1.
*/
async function seqLastNumber(type: "shared" | "project"): Promise<number> {
const row = await prisma.number_sequences.findFirst({
where: { type, year: YEAR },
select: { last_number: true },
});
return row?.last_number ?? 0;
}
const baseOrder = {
status: "prijata" as const,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1,
};
/**
* Fail loudly (instead of the old `if (!("id" in res)) return;`, which silently
* passed the test when a create failed) while still narrowing the union via the
* `in` operator so the success-branch fields stay typed.
*/
function failResult(res: unknown): never {
throw new Error(`expected a success result, got ${JSON.stringify(res)}`);
}
describe("createOrder (manual, optional linked project)", () => {
it("creates an order AND a project sharing one number when create_project=true", async () => {
it("creates an order AND a project, each with its OWN number, linked by FK", async () => {
// Capture BOTH sequence counters before the create so we can prove each
// generator advanced its OWN row (not that the project consumed the shared
// counter). Format difference alone (OBJ-… vs 26730…) would let `not.toBe`
// pass even if the project drew from `shared` — the deltas catch that.
const sharedBefore = await seqLastNumber("shared");
const projectBefore = await seqLastNumber("project");
const res = await createOrder({
...baseOrder,
scope_title: "Ruční zakázka",
create_project: true,
});
expect("id" in res).toBe(true);
if (!("id" in res)) return;
createdOrderIds.push(res.id);
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
expect(res.project_id).toBeTruthy();
const project = await prisma.projects.findUnique({
where: { id: res.project_id! },
});
if (project) createdProjectIds.push(project.id);
expect(project?.project_number).toBe(res.order_number);
// The auto-created project now draws from the dedicated project sequence,
// so its number is distinct from the order's shared number. They relate
// only via the order_id FK, not by sharing a number.
expect(project?.project_number).toBeTruthy();
expect(project?.project_number).not.toBe(res.order_number);
expect(project?.order_id).toBe(res.id);
// Sequence isolation: the order consumed exactly one `shared` number and
// the project consumed exactly one `project` number — each generator
// advanced its OWN row by 1. A regression where the project draws from the
// `shared` counter would bump `shared` by 2 and leave `project` at 0.
const sharedAfter = await seqLastNumber("shared");
const projectAfter = await seqLastNumber("project");
expect(sharedAfter - sharedBefore).toBe(1);
expect(projectAfter - projectBefore).toBe(1);
});
it("creates only the order when create_project=false", async () => {
const res = await createOrder({ ...baseOrder, create_project: false });
expect("id" in res).toBe(true);
if (!("id" in res)) return;
createdOrderIds.push(res.id);
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
expect(res.project_id).toBeUndefined();
});
});
describe("shared pool coherence (tracking)", () => {
it("order → standalone project → order produce three distinct shared numbers", async () => {
const o1 = await createOrder({ ...baseOrder, create_project: true });
if ("id" in o1) {
createdOrderIds.push(o1.id);
if (o1.project_id) createdProjectIds.push(o1.project_id);
}
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
if ("project_number" in p) createdProjectIds.push(p.id);
const o2 = await createOrder({ ...baseOrder, create_project: false });
if ("id" in o2) createdOrderIds.push(o2.id);
describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
it("links a project whose number is from the project sequence, distinct from the order number", async () => {
// Seed a minimal quotation (no items/sections needed — createOrderFrom
// Quotation copies whatever is present and both arrays may be empty).
const quotation = await prisma.quotations.create({
data: {
quotation_number: `Q-FROMQ-${Date.now()}`,
status: "active",
currency: "CZK",
language: "cs",
},
});
createdQuotationIds.push(quotation.id);
const n1 = "id" in o1 ? o1.order_number : null;
const np = "project_number" in p ? p.project_number : null;
const n2 = "id" in o2 ? o2.order_number : null;
expect(new Set([n1, np, n2]).size).toBe(3);
const res = await createOrderFromQuotation({
quotationId: quotation.id,
customerOrderNumber: "PO-FROMQ",
});
if (!("data" in res) || !res.data) failResult(res);
const orderId = res.data.order_id;
createdOrderIds.push(orderId);
const order = await prisma.orders.findUnique({ where: { id: orderId } });
const project = await prisma.projects.findFirst({
where: { order_id: orderId },
});
if (project) createdProjectIds.push(project.id);
// Primary flow: the auto-created project must get a non-empty number from
// the dedicated `project` sequence (code 73), DISTINCT from the order's
// shared number (code 71). Pre-split they shared one number.
expect(project).toBeTruthy();
expect(project?.project_number).toBeTruthy();
expect(order?.order_number).toBeTruthy();
expect(project?.project_number).not.toBe(order?.order_number);
expect(project?.order_id).toBe(orderId);
});
});
describe("deleteOrder releases the project number (release regression)", () => {
it("frees the auto-created project's number back to the project sequence", async () => {
// The project sequence is reset in afterEach, so the preview before the
// create is the number createOrder will consume for the project.
const previewBefore = await previewProjectNumber();
const res = await createOrder({
...baseOrder,
scope_title: "Smazatelná zakázka",
create_project: true,
});
if (!("id" in res)) failResult(res);
const orderId = res.id!;
expect(res.project_id).toBeTruthy();
const project = await prisma.projects.findUnique({
where: { id: res.project_id! },
});
expect(project?.project_number).toBe(previewBefore);
// After the order delete, the project row is gone AND its number must have
// been released (decremented) so the preview returns the freed number — not
// a permanent gap. This is the fix #1 regression guard.
const del = await deleteOrder(orderId);
if ("error" in del) failResult(del);
const gone = await prisma.projects.findUnique({
where: { id: res.project_id! },
});
expect(gone).toBeNull();
const previewAfter = await previewProjectNumber();
expect(previewAfter).toBe(previewBefore);
});
});
describe("separate order vs project sequences (coherence)", () => {
it("order → standalone project → order produce three distinct numbers (from separate sequences)", async () => {
const o1 = await createOrder({ ...baseOrder, create_project: true });
if (!("id" in o1)) failResult(o1);
createdOrderIds.push(o1.id!);
if (o1.project_id) createdProjectIds.push(o1.project_id);
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
if (!("project_number" in p)) failResult(p);
createdProjectIds.push(p.id);
const o2 = await createOrder({ ...baseOrder, create_project: false });
if (!("id" in o2)) failResult(o2);
createdOrderIds.push(o2.id!);
// All three numbers must be present (non-null) AND distinct. Orders draw
// from the `shared` sequence (code 71); projects from the dedicated
// `project` sequence (code 73), so they never collide.
expect(o1.order_number).toBeTruthy();
expect(p.project_number).toBeTruthy();
expect(o2.order_number).toBeTruthy();
expect(
new Set([o1.order_number, p.project_number, o2.order_number]).size,
).toBe(3);
});
});
describe("createProject (manual, standalone)", () => {
it("assigns a shared number and is not linked to an order", async () => {
it("assigns a project number and is not linked to an order", async () => {
const result = await createProject({
name: "Test projekt",
status: "aktivni",
});
expect("project_number" in result).toBe(true);
if (!("project_number" in result)) return;
if (!("project_number" in result)) failResult(result);
createdProjectIds.push(result.id);
expect(typeof result.project_number).toBe("string");
expect(result.project_number!.length).toBeGreaterThan(0);
@@ -112,9 +245,8 @@ describe("createOrder with price line item + attachment", () => {
},
],
});
expect("id" in res).toBe(true);
if (!("id" in res)) return;
createdOrderIds.push(res.id);
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
const items = await prisma.order_items.findMany({
where: { order_id: res.id },
});
@@ -122,21 +254,25 @@ describe("createOrder with price line item + attachment", () => {
expect(Number(items[0].unit_price)).toBe(12345);
});
it("stores an uploaded PO attachment", async () => {
const buf = Buffer.from("%PDF-1.4 fake");
it("stores an uploaded PO attachment with the exact bytes", async () => {
const buf = Buffer.from("%PDF-1.4 fake-attachment-bytes-éí");
const res = await createOrder(
{ ...baseOrder, create_project: false },
buf,
"po.pdf",
);
expect("id" in res).toBe(true);
if (!("id" in res)) return;
createdOrderIds.push(res.id);
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
const order = await prisma.orders.findUnique({
where: { id: res.id },
select: { attachment_name: true, attachment_data: true },
});
expect(order?.attachment_name).toBe("po.pdf");
// Verify the stored content/size, not just truthiness: round-trip the
// bytes and compare length + value against what we uploaded.
expect(order?.attachment_data).toBeTruthy();
const stored = Buffer.from(order!.attachment_data!);
expect(stored.length).toBe(buf.length);
expect(stored.equals(buf)).toBe(true);
});
});

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { NasDocumentManager } from "../services/nas-financials-manager";
/**
* Generic NAS archival manager backing both the invoices NAS (NAS_INVOICES) and
* the orders NAS (NAS_ORDERS). Uses an isolated temp dir via the constructor
* seam so it is deterministic and passes even where the real NAS (Z:) is not
* mounted. Layout: {base}/Vydané/YYYY/MM/<n>.pdf — diacritics preserved.
*/
describe("NasDocumentManager issued-PDF round-trip", () => {
let tmpBase: string;
let nas: NasDocumentManager;
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-doc-"));
nas = new NasDocumentManager(tmpBase);
});
afterEach(() => {
fs.rmSync(tmpBase, { recursive: true, force: true });
});
it("saves an issued PDF under Vydané/YYYY/MM/<number>.pdf and reads it back", () => {
const buf = Buffer.from("%PDF-1.4 fake order pdf bytes", "utf8");
const rel = nas.saveIssuedPdf("25P0001", 2026, 6, buf);
expect(rel).toBe("Vydané/2026/06/25P0001.pdf");
const onDisk = path.join(tmpBase, "Vydané", "2026", "06", "25P0001.pdf");
expect(fs.existsSync(onDisk)).toBe(true);
const read = nas.readIssued(rel!);
expect(read).not.toBeNull();
expect(read!.fileName).toBe("25P0001.pdf");
expect(read!.data.equals(buf)).toBe(true);
});
it("cleanIssued removes a previously-saved PDF across year/month folders", () => {
const buf = Buffer.from("pdf", "utf8");
const rel = nas.saveIssuedPdf("25P0002", 2026, 6, buf);
expect(nas.readIssued(rel!)).not.toBeNull();
nas.cleanIssued("25P0002");
expect(nas.readIssued(rel!)).toBeNull();
});
it("an unconfigured (empty basePath) manager is not configured and saves nothing", () => {
const blank = new NasDocumentManager("");
expect(blank.isConfigured()).toBe(false);
expect(
blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")),
).toBeNull();
});
});

View File

@@ -1,4 +1,7 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { NasFileManager } from "../services/nas-file-manager";
describe("NasFileManager path traversal", () => {
@@ -53,3 +56,73 @@ describe("NasFileManager path traversal", () => {
});
});
});
/**
* Positive-path coverage: a legitimate (non-traversal) path inside a real
* project folder is ACCEPTED — it passes resolveProjectPath validation and
* reaches the underlying fs op (success → null). Without this, a
* "reject-everything" regression in the traversal guard would still pass every
* rejection test above. Uses the constructor basePath seam + a temp dir so it
* is deterministic even where the real NAS (Z:) is not mounted.
*/
describe("NasFileManager accepts legitimate paths", () => {
let tmpBase: string;
let nas: NasFileManager;
const NUM = "29990001";
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-accept-"));
nas = new NasFileManager(tmpBase);
// Create a real project folder <number>_<name> so findProjectFolder resolves.
nas.createProjectFolder(NUM, "Test");
});
afterEach(() => {
fs.rmSync(tmpBase, { recursive: true, force: true });
});
it("deletes a legitimate file inside the project folder (path accepted)", async () => {
const folder = path.join(tmpBase, `${NUM}_Test`);
fs.writeFileSync(path.join(folder, "doc.pdf"), "hello");
expect(fs.existsSync(path.join(folder, "doc.pdf"))).toBe(true);
const result = await nas.deleteItem(NUM, "doc.pdf");
// null = success: the path was accepted and the file actually removed.
expect(result).toBeNull();
expect(fs.existsSync(path.join(folder, "doc.pdf"))).toBe(false);
});
it("moves a legitimate file to a legitimate destination (both accepted)", async () => {
const folder = path.join(tmpBase, `${NUM}_Test`);
fs.writeFileSync(path.join(folder, "from.pdf"), "data");
const result = await nas.moveItem(NUM, "from.pdf", "to.pdf");
expect(result).toBeNull();
expect(fs.existsSync(path.join(folder, "from.pdf"))).toBe(false);
expect(fs.existsSync(path.join(folder, "to.pdf"))).toBe(true);
});
it("a traversal delete is rejected AND deletes nothing (folder intact)", async () => {
const folder = path.join(tmpBase, `${NUM}_Test`);
fs.writeFileSync(path.join(folder, "keep.pdf"), "keep");
const result = await nas.deleteItem(NUM, "../keep.pdf");
expect(result).toContain("Neplatná cesta");
// The guard rejected the path before any fs op — nothing was removed.
expect(fs.existsSync(path.join(folder, "keep.pdf"))).toBe(true);
});
it("rejects a traversal DESTINATION even when the source is legitimate", async () => {
// With a real project folder, the source `from.pdf` resolves cleanly, so
// this isolates the DESTINATION check: `../escape.pdf` must be rejected and
// the source must stay put (no move outside the project folder).
const folder = path.join(tmpBase, `${NUM}_Test`);
fs.writeFileSync(path.join(folder, "from.pdf"), "data");
const result = await nas.moveItem(NUM, "from.pdf", "../escape.pdf");
expect(result).toContain("Neplatná cesta");
expect(fs.existsSync(path.join(folder, "from.pdf"))).toBe(true);
// Nothing escaped the project folder.
expect(fs.existsSync(path.join(tmpBase, "escape.pdf"))).toBe(false);
});
});

View File

@@ -6,9 +6,55 @@ import {
isSharedNumberTaken,
previewOfferNumber,
isOfferNumberTaken,
generateProjectNumber,
previewProjectNumber,
isProjectNumberTaken,
} from "../services/numbering.service";
import prisma from "../config/database";
const YEAR = new Date().getFullYear();
/**
* Assert two consecutive document numbers (a) keep an identical format / year
* prefix and (b) increment by exactly one — without hard-coding which
* `company_settings` pattern is configured.
*
* Some patterns are entirely digits ({YY}{CODE}{NNNN}), so a regex split of
* "trailing digit run" is ambiguous. Instead we find the longest common prefix
* of the two strings (the unchanged format/year portion) and parse the
* remaining differing suffixes as integers — these are the rendered sequences.
*/
function assertStrictIncrement(num1: string, num2: string) {
expect(num1).not.toBe(num2);
expect(num2.length).toBe(num1.length); // same width ⇒ same format
let i = 0;
while (i < num1.length && num1[i] === num2[i]) i++;
const commonPrefix = num1.slice(0, i);
// The format/year portion is shared and non-empty (the numbers don't differ
// from the very first character).
expect(commonPrefix.length).toBeGreaterThan(0);
const seq1 = Number(num1.slice(i));
const seq2 = Number(num2.slice(i));
expect(Number.isNaN(seq1)).toBe(false);
expect(Number.isNaN(seq2)).toBe(false);
expect(seq2).toBe(seq1 + 1); // strict +1, not merely !==
}
/**
* Seed the (type, year) sequence row to a high `last_number` so the next
* generated numbers land far above any real order/quotation in the test DB —
* the generator's skip-taken retry then never fires and the strict +1
* assertion is deterministic.
*/
async function seedSequence(type: string, lastNumber: number) {
await prisma.number_sequences.deleteMany({ where: { type } });
await prisma.number_sequences.create({
data: { type, year: YEAR, last_number: lastNumber },
});
}
describe("generateSharedNumber", () => {
beforeEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
@@ -24,10 +70,94 @@ describe("generateSharedNumber", () => {
expect(num.length).toBeGreaterThan(0);
});
it("increments on consecutive calls", async () => {
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
// Seed high so neither generated number collides with a real order/project
// → the skip-taken retry never fires and the increment is strictly +1.
await seedSequence("shared", 900000);
const num1 = await generateSharedNumber();
const num2 = await generateSharedNumber();
expect(num1).not.toBe(num2);
assertStrictIncrement(num1, num2);
});
});
describe("generateProjectNumber", () => {
beforeEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "project" } });
});
afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "project" } });
});
it("returns a non-empty string", async () => {
const num = await generateProjectNumber();
expect(typeof num).toBe("string");
expect(num.length).toBeGreaterThan(0);
});
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
// Seed high so neither generated number collides with a real project →
// the skip-taken retry never fires and the increment is strictly +1.
await seedSequence("project", 900000);
const num1 = await generateProjectNumber();
const num2 = await generateProjectNumber();
assertStrictIncrement(num1, num2);
});
it("previewProjectNumber returns a number not yet taken by a project", async () => {
const preview = await previewProjectNumber();
expect(typeof preview).toBe("string");
expect(preview.length).toBeGreaterThan(0);
expect(await isProjectNumberTaken(preview)).toBe(false);
});
});
describe("cross-sequence isolation (shared vs project)", () => {
beforeEach(async () => {
await prisma.number_sequences.deleteMany({
where: { type: { in: ["shared", "project"] } },
});
});
afterEach(async () => {
await prisma.number_sequences.deleteMany({
where: { type: { in: ["shared", "project"] } },
});
});
it("generateSharedNumber advances ONLY the shared row; generateProjectNumber ONLY the project row", async () => {
// Seed the two sequences to DISTINCT known highs so neither generated
// number collides with a real row (skip-taken retry never fires) and a
// cross-contamination bug is unambiguous.
await seedSequence("shared", 900000);
await seedSequence("project", 500000);
await generateSharedNumber();
// After a shared allocation only the shared row may have moved.
let shared = await prisma.number_sequences.findFirst({
where: { type: "shared", year: YEAR },
select: { last_number: true },
});
let project = await prisma.number_sequences.findFirst({
where: { type: "project", year: YEAR },
select: { last_number: true },
});
expect(shared?.last_number).toBe(900001);
expect(project?.last_number).toBe(500000);
await generateProjectNumber();
// After a project allocation only the project row may have moved; the
// shared row must still sit at 900001.
shared = await prisma.number_sequences.findFirst({
where: { type: "shared", year: YEAR },
select: { last_number: true },
});
project = await prisma.number_sequences.findFirst({
where: { type: "project", year: YEAR },
select: { last_number: true },
});
expect(shared?.last_number).toBe(900001);
expect(project?.last_number).toBe(500001);
});
});
@@ -46,10 +176,11 @@ describe("generateOfferNumber", () => {
expect(num.length).toBeGreaterThan(0);
});
it("increments on consecutive calls", async () => {
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
await seedSequence("offer", 900000);
const num1 = await generateOfferNumber();
const num2 = await generateOfferNumber();
expect(num1).not.toBe(num2);
assertStrictIncrement(num1, num2);
});
});
@@ -77,9 +208,17 @@ describe("previewSharedNumber", () => {
});
it("skips a number already taken when the sequence counter lags behind", async () => {
// Fresh counter → preview points at the first sequence number. With the
// skip-taken logic this is guaranteed free, so we can safely claim it.
// Seed the counter HIGH so the preview lands far above any real
// order/project in the test DB. Without this, a real row could already
// hold the first sequence number — making the create collide on the unique
// column, or making `second !== first` pass for the wrong reason (because
// the counter happened to already point past `first`).
await seedSequence("shared", 900000);
const first = await previewSharedNumber();
// Preconditions: the seeded preview really is free (so the create below is
// safe) and is exactly what the generator would assign next.
expect(await isSharedNumberTaken(first)).toBe(false);
const project = await prisma.projects.create({
data: { project_number: first, name: "test-preview-skip-taken" },
});
@@ -117,9 +256,14 @@ describe("previewOfferNumber", () => {
});
it("skips a number already taken when the sequence counter lags behind", async () => {
// Fresh counter → preview points at the first sequence number; with the
// skip-taken logic it's guaranteed free, so we can claim it.
// Seed HIGH so the preview lands above any real quotation in the test DB
// (see the shared-number test for the rationale): the create can't collide
// and the skip-advance is genuinely exercised rather than passing because a
// real row already advanced the preview.
await seedSequence("offer", 900000);
const first = await previewOfferNumber();
expect(await isOfferNumberTaken(first)).toBe(false);
const quotation = await prisma.quotations.create({
data: { quotation_number: first },
});

View File

@@ -0,0 +1,276 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createOffer, getOfferTotals } from "../services/offers.service";
import {
createInvoice,
getInvoiceListTotals,
} from "../services/invoices.service";
import { getReceivedInvoiceListTotals } from "../routes/admin/received-invoices";
// Per-currency total aggregation for the offers, issued-invoices and
// received-invoices lists. These hit the real `app_test` DB via the service
// layer (suite convention) and prove the totals fns sum each document's TOTAL
// per currency across the WHOLE filtered set, and that the filtering `where`
// (search / month-year / status) is respected — mirroring order-totals.test.ts.
//
// For invoices a far-future month/year is used so seeded/other-test rows can't
// fall in the window and skew the deterministic sums. Offers have NO date
// filter, so they are isolated by a unique `project_code` searched on instead.
// Received invoices store amount as GROSS (VAT-inclusive), summed directly.
const STATS_YEAR = 2097;
const STATS_MONTH = 8; // -> invoice issue_date window [2097-08-01, 2097-09-01)
// Unique marker so the offers test (which has no date filter) can scope its
// aggregation to only the rows it created via a `search` on project_code.
const OFFER_MARKER = `TOTALS-TEST-${STATS_YEAR}`;
const createdOfferIds: number[] = [];
const createdInvoiceIds: number[] = [];
const createdReceivedIds: number[] = [];
afterEach(async () => {
for (const id of createdOfferIds)
await prisma.quotations.deleteMany({ where: { id } });
for (const id of createdInvoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
for (const id of createdReceivedIds)
await prisma.received_invoices.deleteMany({ where: { id } });
createdOfferIds.length = 0;
createdInvoiceIds.length = 0;
createdReceivedIds.length = 0;
// Finalized offers/invoices consume their sequences — reset so we don't leak
// a consumed number into sibling test files.
await prisma.number_sequences.deleteMany({
where: { type: { in: ["offer", "invoice"] } },
});
});
function amountFor(
totals: { currency: string; amount: number }[],
currency: string,
): number | undefined {
return totals.find((t) => t.currency === currency)?.amount;
}
describe("getOfferTotals per-currency aggregation", () => {
it("sums offer NET total per currency over the full filtered set", async () => {
// Two CZK offers + one EUR. NET only (enrichQuotation math — offers are
// not tax documents, no VAT anywhere).
// CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 => EUR total 300
const mk = async (currency: string, qty: number, price: number) => {
const res = await createOffer({
status: "draft", // draft so no offer number is consumed
currency,
project_code: OFFER_MARKER,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
if ("error" in res) throw new Error(JSON.stringify(res));
createdOfferIds.push(res.id);
return res.id;
};
await mk("CZK", 2, 1000);
await mk("CZK", 1, 500);
await mk("EUR", 3, 100);
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(300);
});
it("respects the where: a status filter narrows the result", async () => {
const mk = async (status: string) => {
const res = await createOffer({
status,
currency: "CZK",
project_code: OFFER_MARKER,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in res) throw new Error(JSON.stringify(res));
createdOfferIds.push(res.id);
return res.id;
};
// Two drafts + one invalidated, all sharing the marker.
await mk("draft");
await mk("draft");
await mk("invalidated");
// Marker only: all three count -> 3 x 1000 = 3000 (net).
const all = await getOfferTotals({ search: OFFER_MARKER });
expect(amountFor(all.totals, "CZK")).toBe(3000);
// Status filter narrows to the two drafts -> 2 x 1000 = 2000.
const onlyDraft = await getOfferTotals({
search: OFFER_MARKER,
status: "draft",
});
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2000);
// Invalidated alone -> 1000.
const onlyInvalid = await getOfferTotals({
search: OFFER_MARKER,
status: "invalidated",
});
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1000);
});
});
describe("getInvoiceListTotals (issued invoices) per-currency aggregation", () => {
// issue_date is settable at create, so no post-create pin needed. VAT is
// applied per-line (computeInvoiceTotals) but with a single line per invoice
// here the result matches the whole-subtotal math.
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const mk = async (
currency: string,
qty: number,
price: number,
status = "draft",
) => {
const inv = await createInvoice({
status,
currency,
vat_rate: 21,
apply_vat: true,
issue_date: dateStr,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
createdInvoiceIds.push(inv.id);
return inv.id;
};
it("sums invoice TOTAL incl. VAT per currency over the full filtered set", async () => {
// CZK #1: 2 x 1000 @21% = 2420
// CZK #2: 1 x 500 @21% = 605 => CZK total 3025
// EUR : 3 x 100 @21% = 363 => EUR total 363
await mk("CZK", 2, 1000);
await mk("CZK", 1, 500);
await mk("EUR", 3, 100);
const { totals } = await getInvoiceListTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
});
it("respects the where: a different month and a status filter change the result", async () => {
const nextDateStr = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
// Target month: one draft, one issued (both 1210). Next month: one draft.
await mk("CZK", 1, 1000, "draft");
// An already-issued invoice numbers immediately; that's fine for the sum.
const issued = await createInvoice({
status: "issued",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
issue_date: dateStr,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
createdInvoiceIds.push(issued.id);
const next = await createInvoice({
status: "draft",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
issue_date: nextDateStr,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
createdInvoiceIds.push(next.id);
// Whole target month: both count -> 2 x 1210 = 2420.
const whole = await getInvoiceListTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(whole.totals, "CZK")).toBe(2420);
// Status filter narrows to the single 'issued' in the target month -> 1210.
const onlyIssued = await getInvoiceListTotals({
month: STATS_MONTH,
year: STATS_YEAR,
status: "issued",
});
expect(amountFor(onlyIssued.totals, "CZK")).toBe(1210);
// Next month sees only the one invoice there -> 1210.
const nextMonth = await getInvoiceListTotals({
month: STATS_MONTH + 1,
year: STATS_YEAR,
});
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
});
});
describe("received-invoices list-totals per-currency aggregation (GROSS)", () => {
// No service create for received invoices — rows are written directly. The
// list/totals `where` filters by the month/year COLUMNS (not issue_date).
// amount is GROSS, summed directly per currency.
const mkReceived = async (currency: string, amount: number) => {
const row = await prisma.received_invoices.create({
data: {
month: STATS_MONTH,
year: STATS_YEAR,
supplier_name: "Totals Test Supplier",
amount,
currency,
vat_rate: 21,
status: "unpaid",
},
});
createdReceivedIds.push(row.id);
return row.id;
};
it("sums GROSS amount per currency over the full filtered set", async () => {
// Two CZK (2420 + 605) + one EUR (363). Amounts are GROSS — summed as-is.
await mkReceived("CZK", 2420);
await mkReceived("CZK", 605);
await mkReceived("EUR", 363);
const { totals } = await getReceivedInvoiceListTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
});
it("respects the where: a different month and a search filter change the result", async () => {
await mkReceived("CZK", 1000);
// A row in the NEXT month must not count toward the target month's total.
const other = await prisma.received_invoices.create({
data: {
month: STATS_MONTH + 1,
year: STATS_YEAR,
supplier_name: "Totals Test Supplier",
amount: 9999,
currency: "CZK",
vat_rate: 21,
status: "unpaid",
},
});
createdReceivedIds.push(other.id);
const { totals } = await getReceivedInvoiceListTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(1000);
// Search on the supplier name still scopes to the target-month row.
const { totals: searched } = await getReceivedInvoiceListTotals({
month: STATS_MONTH,
year: STATS_YEAR,
search: "Totals Test Supplier",
});
expect(amountFor(searched, "CZK")).toBe(1000);
});
});

View File

@@ -0,0 +1,232 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createOrder, getOrderTotals } from "../services/orders.service";
import {
createIssuedOrder,
getIssuedOrderTotals,
} from "../services/issued-orders.service";
// Per-currency total aggregation for both order lists. These hit the real
// `app_test` DB via the service layer (suite convention) and prove the /stats
// endpoints sum order NET total per currency across the WHOLE filtered set
// (orders are not tax documents — no VAT anywhere), and that the where
// (month/year + status) is respected.
//
// A far-future month/year is used so seeded/other-test rows can't fall in the
// window and skew the deterministic sums (mirrors drafts-aggregation.test.ts).
const STATS_YEAR = 2097;
const STATS_MONTH = 8; // -> window [2097-08-01, 2097-09-01)
const createdOrderIds: number[] = [];
const createdIssuedIds: number[] = [];
const createdCustomerIds: number[] = [];
afterEach(async () => {
for (const id of createdOrderIds)
await prisma.orders.deleteMany({ where: { id } });
for (const id of createdIssuedIds)
await prisma.issued_orders.deleteMany({ where: { id } });
for (const id of createdCustomerIds)
await prisma.customers.deleteMany({ where: { id } });
createdOrderIds.length = 0;
createdIssuedIds.length = 0;
createdCustomerIds.length = 0;
// Finalized orders consume the shared/issued sequences — reset so we don't
// leak a consumed number into sibling test files.
await prisma.number_sequences.deleteMany({
where: { type: { in: ["shared", "issued_order", "project"] } },
});
});
function amountFor(
totals: { currency: string; amount: number }[],
currency: string,
): number | undefined {
return totals.find((t) => t.currency === currency)?.amount;
}
describe("getOrderTotals (received orders) per-currency aggregation", () => {
it("sums NET total per currency over the full filtered set", async () => {
// Two CZK orders + one EUR, all in the same far-future month. NET only
// (enrichOrder math — orders carry no VAT).
// CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 => EUR total 300
const mk = async (currency: string, qty: number, price: number) => {
const res = await createOrder({
status: "prijata",
currency,
language: "cs",
create_project: false,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
if (!("id" in res)) throw new Error(JSON.stringify(res));
createdOrderIds.push(res.id!);
// created_at is auto-managed; pin it into the stats window so the
// month/year filter is deterministic.
await prisma.orders.update({
where: { id: res.id },
data: {
created_at: new Date(STATS_YEAR, STATS_MONTH - 1, 15, 12, 0, 0),
},
});
return res.id!;
};
await mk("CZK", 2, 1000);
await mk("CZK", 1, 500);
await mk("EUR", 3, 100);
const { totals } = await getOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(300);
});
it("respects the where: a different month and a status filter change the result", async () => {
const mk = async (status: string, monthOffset: number) => {
const res = await createOrder({
status,
currency: "CZK",
language: "cs",
create_project: false,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if (!("id" in res)) throw new Error(JSON.stringify(res));
createdOrderIds.push(res.id!);
await prisma.orders.update({
where: { id: res.id },
data: {
created_at: new Date(
STATS_YEAR,
STATS_MONTH - 1 + monthOffset,
15,
12,
0,
0,
),
},
});
return res.id!;
};
// One 'prijata' in the target month, one 'stornovana' in the target month,
// one 'prijata' in the NEXT month.
await mk("prijata", 0);
await mk("stornovana", 0);
await mk("prijata", 1);
// Whole month: both same-month orders count -> 2 x 1000 = 2000 (net).
const whole = await getOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(whole.totals, "CZK")).toBe(2000);
// Status filter narrows to the single 'prijata' in the target month -> 1000.
const onlyPrijata = await getOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
status: "prijata",
});
expect(amountFor(onlyPrijata.totals, "CZK")).toBe(1000);
// Next month sees only the one 'prijata' there -> 1000.
const nextMonth = await getOrderTotals({
month: STATS_MONTH + 1,
year: STATS_YEAR,
});
expect(amountFor(nextMonth.totals, "CZK")).toBe(1000);
});
});
describe("getIssuedOrderTotals (issued orders) per-currency aggregation", () => {
it("sums NET total per currency over the full filtered set", async () => {
// order_date is settable at create, so no post-create pin needed. NET only
// (computeIssuedOrderTotals — issued orders carry no VAT).
// CZK #1: 2 x 1000 = 2000
// CZK #2: 1 x 500 = 500 => CZK total 2500
// EUR : 3 x 100 = 300 => EUR total 300
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const mk = async (currency: string, qty: number, price: number) => {
const o = await createIssuedOrder({
currency,
order_date: dateStr,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
if ("error" in o) throw new Error(`createIssuedOrder failed: ${o.error}`);
createdIssuedIds.push(o.id);
return o.id;
};
await mk("CZK", 2, 1000);
await mk("CZK", 1, 500);
await mk("EUR", 3, 100);
const { totals } = await getIssuedOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(2500);
expect(amountFor(totals, "EUR")).toBe(300);
});
it("respects the where: a different month and a status filter change the result", async () => {
const inMonth = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const nextMonth = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
// Target month: one draft, one already-sent (both 1000 net). Next month: one.
const draft = await createIssuedOrder({
currency: "CZK",
order_date: inMonth,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
createdIssuedIds.push(draft.id);
const sent = await createIssuedOrder({
status: "sent",
currency: "CZK",
order_date: inMonth,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in sent)
throw new Error(`createIssuedOrder failed: ${sent.error}`);
createdIssuedIds.push(sent.id);
const next = await createIssuedOrder({
currency: "CZK",
order_date: nextMonth,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in next)
throw new Error(`createIssuedOrder failed: ${next.error}`);
createdIssuedIds.push(next.id);
// Whole target month: both count -> 2 x 1000 = 2000 (net).
const whole = await getIssuedOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(whole.totals, "CZK")).toBe(2000);
// Status filter narrows to the single 'sent' in the target month -> 1000.
const onlySent = await getIssuedOrderTotals({
month: STATS_MONTH,
year: STATS_YEAR,
status: "sent",
});
expect(amountFor(onlySent.totals, "CZK")).toBe(1000);
// Next month sees only the one order there -> 1000.
const nextStats = await getIssuedOrderTotals({
month: STATS_MONTH + 1,
year: STATS_YEAR,
});
expect(amountFor(nextStats.totals, "CZK")).toBe(1000);
});
});

View File

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

View File

@@ -0,0 +1,147 @@
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";
// 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");
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[] = [];
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-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 } });
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ě");
});
});

View File

@@ -7,6 +7,7 @@ import prisma from "../config/database";
import { config } from "../config/env";
import { securityHeaders } from "../middleware/security";
import planRoutes from "../routes/admin/plan";
import { localDateStr } from "../utils/date";
import {
resolveCell,
resolveGrid,
@@ -31,6 +32,14 @@ import {
const N = "wh_plan_";
let adminUserId: number;
// A DEDICATED, distinct non-admin user for the service-level employee-scoping
// tests (and as the second employee in the multi-user bulk test). Creating our
// own user — instead of grabbing "the second user in the DB" — guarantees it is
// never accidentally the admin (which would make `toEqual([])` pass for the
// wrong reason on a single-user DB), and that it is genuinely distinct so the
// 2-employee aggregate assertion can't collapse to 1.
let scopeUserId: number;
let scopeRoleId: number;
let noPermUserId: number;
beforeAll(async () => {
@@ -43,11 +52,44 @@ beforeAll(async () => {
if (!admin) throw new Error("Test setup: admin user not found");
adminUserId = admin.id;
// For list-scoping tests: any non-admin user that is NOT the admin we use
// for create tests. Just pick the second user.
const allUsers = await prisma.users.findMany({ take: 2 });
noPermUserId =
allUsers.find((u) => u.id !== adminUserId)?.id ?? allUsers[0].id;
// Dedicated non-admin user + role for scoping/multi-user tests.
const stamp = Date.now();
const role = await prisma.roles.create({
data: {
name: `scope_plan_${stamp}`,
display_name: "Plan Scope Test",
},
});
scopeRoleId = role.id;
const user = await prisma.users.create({
data: {
username: `scope_plan_${stamp}`,
first_name: "Scope",
last_name: "User",
email: `scope_plan_${stamp}@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
role_id: role.id,
is_active: true,
},
});
scopeUserId = user.id;
if (scopeUserId === adminUserId)
throw new Error("scope user must be distinct from admin");
});
afterAll(async () => {
// Clean up the dedicated scope user/role created above. Wrapped in catch so a
// leftover FK (none expected — its rows are note-prefixed and removed) can't
// mask real failures.
if (scopeUserId)
await prisma.users
.deleteMany({ where: { id: scopeUserId } })
.catch(() => {});
if (scopeRoleId)
await prisma.roles
.deleteMany({ where: { id: scopeRoleId } })
.catch(() => {});
});
beforeEach(async () => {
@@ -355,6 +397,24 @@ describe("plan.service.assertNotPastDate", () => {
const result = assertNotPastDate("2000-01-01", true);
expect(result).toBeNull();
});
it("treats TODAY (local Prague date) as allowed — the boundary is inclusive", () => {
// Deterministic without mocking the clock: compute "today" exactly the way
// the service does (local-time YYYY-MM-DD; TZ is pinned to Europe/Prague in
// config/env.ts). The boundary is the bug-prone path — an off-by-one here
// would wrongly reject editing today's plan.
const today = localDateStr(new Date());
expect(assertNotPastDate(today, false)).toBeNull();
});
it("rejects YESTERDAY (local Prague date) as a past date", () => {
const d = new Date();
d.setDate(d.getDate() - 1); // local-time yesterday
const yesterday = localDateStr(d);
const result = assertNotPastDate(yesterday, false);
expect(result).not.toBeNull();
expect(result?.status).toBe(403);
});
});
// ---------------------------------------------------------------------------
@@ -521,7 +581,7 @@ describe("plan.service.updateEntry", () => {
);
expect("data" in updated).toBe(true);
if ("data" in updated) {
expect(updated.data.note).toBe(`${N}updated`);
expect((updated.data as any).note).toBe(`${N}updated`);
expect((updated.oldData as any).note).toBe(`${N}original`);
}
});
@@ -686,7 +746,7 @@ describe("plan.service.updateOverride", () => {
);
expect("data" in updated).toBe(true);
if ("data" in updated) {
expect(updated.data.note).toBe(`${N}updated`);
expect((updated.data as any).note).toBe(`${N}updated`);
expect((updated.oldData as any).note).toBe(`${N}original`);
}
});
@@ -761,9 +821,13 @@ describe("plan.service.listEntries (employee scoping)", () => {
);
const rows = await listEntries(
{ user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" },
noPermUserId,
scopeUserId, // dedicated distinct non-admin actor (never the admin)
false, // not admin
);
// A non-admin actor querying ANOTHER user's data is scoped to its own
// user_id, so it sees none of the admin's entries. This can only be
// [] for the right reason because scopeUserId !== adminUserId (asserted
// in beforeAll).
expect(rows).toEqual([]);
});
});
@@ -782,7 +846,7 @@ describe("plan.service.listOverrides (employee scoping)", () => {
);
const rows = await listOverrides(
{ user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" },
noPermUserId,
scopeUserId, // dedicated distinct non-admin actor (never the admin)
false,
);
expect(rows).toEqual([]);
@@ -875,9 +939,11 @@ describe("plan.service.bulkCreateEntries", () => {
});
it("aggregates across multiple employees", async () => {
// Two genuinely distinct employees (admin + the dedicated scope user) so
// `users` can't collapse to 1 if the DB happened to have a single user.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId, noPermUserId],
user_ids: [adminUserId, scopeUserId],
date_from: "2096-08-03", // Friday
date_to: "2096-08-03",
include_weekends: true,

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, afterAll } from "vitest";
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "../config/database";
import {
listPlanCategories,
@@ -12,10 +12,28 @@ import {
const created: number[] = [];
// Every test category's slug starts with "test_" AND contains "_zz" (labels all
// read "Test … ZZ"). Cleaning by this pattern — not just by in-memory ids —
// means a previous crashed run can't leave a `test_kategorie_zz` row behind that
// makes the dedupe test wrongly assert `_3`/`_4` instead of `_2`. The narrow
// pattern avoids touching real seed categories (work/other/…).
async function cleanupByPrefix() {
await prisma.plan_categories.deleteMany({
where: { key: { startsWith: "test_", contains: "_zz" } },
});
}
beforeAll(async () => {
await cleanupByPrefix();
});
afterAll(async () => {
if (created.length) {
await prisma.plan_categories.deleteMany({ where: { id: { in: created } } });
}
// Belt-and-suspenders: also remove anything matching the slug prefix in case
// a test threw before pushing its id into `created`.
await cleanupByPrefix();
await prisma.$disconnect();
});
@@ -24,6 +42,13 @@ describe("slugifyCategory", () => {
expect(slugifyCategory("Cesta / Montáž")).toBe("cesta_montaz");
expect(slugifyCategory("Příprava")).toBe("priprava");
});
it("collapses an all-symbol label to an empty slug", () => {
// No alphanumerics survive → empty string. createPlanCategory's uniqueKey
// then falls back to "kategorie" so the row is still creatable.
expect(slugifyCategory("***")).toBe("");
expect(slugifyCategory(" / ")).toBe("");
});
});
describe("plan category service", () => {

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import { vatFromGross } from "../routes/admin/received-invoices";
// `amount` on a received invoice is the GROSS total (VAT included). The VAT is
// the portion contained within it: gross * rate / (100 + rate).
describe("vatFromGross (VAT contained in a gross total)", () => {
it("derives VAT from a real gross total at 21%", () => {
// 22 542,91 @ 21% → 3 912,41 (base 18 630,50). User-confirmed figures.
expect(vatFromGross(22542.91, 21)).toBeCloseTo(3912.41, 2);
expect(22542.91 - vatFromGross(22542.91, 21)).toBeCloseTo(18630.5, 2);
});
it("derives VAT at other rates", () => {
expect(vatFromGross(1210, 21)).toBeCloseTo(210, 2); // base 1000
expect(vatFromGross(1100, 10)).toBeCloseTo(100, 2); // base 1000
expect(vatFromGross(1150, 15)).toBeCloseTo(150, 2); // base 1000
});
it("returns 0 when there is no VAT", () => {
expect(vatFromGross(5000, 0)).toBe(0);
});
it("returns 0 for a zero amount", () => {
expect(vatFromGross(0, 21)).toBe(0);
});
it("rounds the cent UP when the raw VAT's third decimal forces a carry", () => {
// 115.50 @ 21% -> raw 20.045454... the third decimal (5) rounds the cent
// up to 20.05. Pinned EXACTLY (not toBeCloseTo) so a truncate-instead-of-
// round regression (which would yield 20.04) fails.
expect(vatFromGross(115.5, 21)).toBe(20.05);
// 95.70 @ 21% -> raw 16.609090... rounds up to 16.61.
expect(vatFromGross(95.7, 21)).toBe(16.61);
});
it("rounds the cent DOWN when below the half-cent", () => {
// 100.40 @ 21% -> raw 17.424793... rounds down to 17.42.
expect(vatFromGross(100.4, 21)).toBe(17.42);
});
it("pins the result to the exact stored 2-dp value (not just close)", () => {
// The DB column is Decimal(_, 2). vatFromGross must already return a value
// with no third-decimal float drift. Strict equality + a 2-dp self-check,
// NOT toBeCloseTo, so a 3-dp regression (e.g. 210.0000001) would fail.
const v = vatFromGross(1210, 21);
expect(v).toBe(210); // exact, not ~210
expect(v).toBe(Math.round(v * 100) / 100); // genuinely 2-dp clean
// The real-world figure too: pinned exactly, replacing the toBeCloseTo above.
expect(vatFromGross(22542.91, 21)).toBe(3912.41);
});
});

View File

@@ -1,8 +1,21 @@
import { describe, it, expect } from "vitest";
import { CreateOrderSchema } from "../schemas/orders.schema";
import { CreateQuotationSchema } from "../schemas/offers.schema";
import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema";
import { CreateReceivedInvoiceSchema } from "../schemas/received-invoices.schema";
import {
ReceiptItemSchema,
CreateSupplierSchema,
} from "../schemas/warehouse.schema";
import { UpdateCompanySettingsSchema } from "../schemas/settings.schema";
describe("Zod NaN rejection", () => {
// These schemas now build on the shared form-coercion helpers in
// `src/schemas/common.ts` (numberFromForm / numberInRange / intIdFromForm / …).
// HTTP bodies arrive as strings (multipart) or numbers (JSON), so the helpers
// must BOTH (a) coerce a valid numeric STRING → number and (b) reject a NaN
// string. The original suite only asserted (b); these tests pin (a) as well.
describe("Zod form coercion + NaN rejection", () => {
describe("CreateOrderSchema", () => {
it("rejects NaN string in quantity", () => {
const result = CreateOrderSchema.safeParse({
@@ -27,25 +40,24 @@ describe("Zod NaN rejection", () => {
});
expect(result.success).toBe(true);
});
it("coerces a valid numeric STRING quantity/unit_price to a number", () => {
const result = CreateOrderSchema.safeParse({
customer_id: 1,
items: [{ quantity: "2", unit_price: "100.5" }],
});
expect(result.success).toBe(true);
if (result.success) {
const item = result.data.items![0];
expect(item.quantity).toBe(2);
expect(typeof item.quantity).toBe("number");
expect(item.unit_price).toBe(100.5);
expect(typeof item.unit_price).toBe("number");
}
});
});
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("rejects NaN string in item quantity", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
@@ -53,5 +65,215 @@ describe("Zod NaN rejection", () => {
});
expect(result.success).toBe(false);
});
it("coerces a valid numeric STRING item quantity/unit_price", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
items: [{ quantity: "3", unit_price: "250" }],
});
expect(result.success).toBe(true);
if (result.success) {
const item = result.data.items![0];
expect(item.quantity).toBe(3);
expect(item.unit_price).toBe(250);
}
});
});
// The remaining schemas were the ones flagged as accepting NaN before the
// shared helpers landed. Assert both the coercion and the rejection paths.
describe("CreateTripSchema", () => {
it("coerces numeric STRING vehicle_id / start_km / end_km to numbers", () => {
const result = CreateTripSchema.safeParse({
vehicle_id: "5",
trip_date: "2026-01-15",
start_km: "100",
end_km: "150.5",
route_from: "Praha",
route_to: "Brno",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.vehicle_id).toBe(5);
expect(result.data.start_km).toBe(100);
expect(result.data.end_km).toBe(150.5);
expect(typeof result.data.start_km).toBe("number");
}
});
it("rejects a NaN string in start_km", () => {
const result = CreateTripSchema.safeParse({
vehicle_id: 5,
trip_date: "2026-01-15",
start_km: "not-a-number",
end_km: 150,
route_from: "Praha",
route_to: "Brno",
});
expect(result.success).toBe(false);
});
it("rejects a NaN / non-positive string in vehicle_id (FK)", () => {
const result = CreateTripSchema.safeParse({
vehicle_id: "abc",
trip_date: "2026-01-15",
start_km: 100,
end_km: 150,
route_from: "Praha",
route_to: "Brno",
});
expect(result.success).toBe(false);
});
});
describe("CreateReceivedInvoiceSchema", () => {
it("coerces numeric STRING month / year / amount to numbers", () => {
const result = CreateReceivedInvoiceSchema.safeParse({
supplier_name: "ACME s.r.o.",
month: "6",
year: "2026",
amount: "1210.50",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.month).toBe(6);
expect(result.data.year).toBe(2026);
expect(result.data.amount).toBe(1210.5);
expect(typeof result.data.amount).toBe("number");
}
});
it("rejects a NaN string in amount", () => {
const result = CreateReceivedInvoiceSchema.safeParse({
supplier_name: "ACME s.r.o.",
month: 6,
year: 2026,
amount: "not-money",
});
expect(result.success).toBe(false);
});
it("rejects an out-of-range month (numberInRange 1-12)", () => {
const result = CreateReceivedInvoiceSchema.safeParse({
supplier_name: "ACME s.r.o.",
month: 13,
year: 2026,
});
expect(result.success).toBe(false);
});
it("rejects a NaN string in month", () => {
const result = CreateReceivedInvoiceSchema.safeParse({
supplier_name: "ACME s.r.o.",
month: "bad",
year: 2026,
});
expect(result.success).toBe(false);
});
});
describe("warehouse ReceiptItemSchema", () => {
it("coerces numeric STRING item_id / quantity / unit_price to numbers", () => {
const result = ReceiptItemSchema.safeParse({
item_id: "7",
quantity: "12.5",
unit_price: "99",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.item_id).toBe(7);
expect(result.data.quantity).toBe(12.5);
expect(result.data.unit_price).toBe(99);
expect(typeof result.data.quantity).toBe("number");
}
});
it("rejects a NaN string in quantity", () => {
const result = ReceiptItemSchema.safeParse({
item_id: 7,
quantity: "lots",
unit_price: 99,
});
expect(result.success).toBe(false);
});
it("rejects a non-positive quantity (positiveNumberFromForm)", () => {
const result = ReceiptItemSchema.safeParse({
item_id: 7,
quantity: 0,
unit_price: 99,
});
expect(result.success).toBe(false);
});
it("rejects a NaN / non-positive string in item_id (FK)", () => {
const result = ReceiptItemSchema.safeParse({
item_id: "abc",
quantity: 5,
unit_price: 99,
});
expect(result.success).toBe(false);
});
});
});
// Regression tests for the 2026-06-09 audit fix pass: the schema hardening
// must NOT reject input the app legitimately sends.
describe("schema hardening — does not reject previously-valid input", () => {
it("settings: optional email fields accept an empty string (un-filled)", () => {
const r = UpdateCompanySettingsSchema.safeParse({
invoice_alert_email: "",
leave_notify_email: "",
smtp_from: "",
});
expect(r.success).toBe(true);
});
it("settings: a valid email is accepted, a malformed one rejected", () => {
expect(
UpdateCompanySettingsSchema.safeParse({ smtp_from: "a@b.cz" }).success,
).toBe(true);
expect(
UpdateCompanySettingsSchema.safeParse({ smtp_from: "not-an-email" })
.success,
).toBe(false);
});
it("warehouse supplier: empty email accepted, valid accepted, bad rejected", () => {
expect(
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "" }).success,
).toBe(true);
expect(
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "x@y.cz" })
.success,
).toBe(true);
expect(
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "nope" })
.success,
).toBe(false);
});
it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {
const base = {
vehicle_id: 1,
start_km: 100,
end_km: 120,
route_from: "A",
route_to: "B",
};
// Plain date-only (from the date picker).
const a = CreateTripSchema.safeParse({ ...base, trip_date: "2026-06-09" });
expect(a.success).toBe(true);
// The edit form re-submits what the API serialized for a @db.Date column
// via the Date.toJSON override ("YYYY-MM-DDT00:00:00") — must still pass and
// normalize to the date-only value.
const b = UpdateTripSchema.safeParse({ trip_date: "2026-06-09T00:00:00" });
expect(b.success).toBe(true);
if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
// Genuinely malformed dates are still rejected.
expect(
CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success,
).toBe(false);
});
});

View File

@@ -1,2 +1,44 @@
import dotenv from "dotenv";
dotenv.config({ path: ".env.test" });
const ENV_TEST_PATH = ".env.test";
// vitest.config.ts already loads .env.test with override:true; we reload here so
// the setup file is self-contained when run directly.
dotenv.config({ path: ENV_TEST_PATH });
/**
* Safety guard: the suite hits a REAL MySQL database (no Prisma mocks) and
* MUTATES it, so it must never run against a dev/production DB. We require the
* configured database name to contain "test" (the committed `.env.test` points
* at the throwaway `app_test`); anything else is a HARD FAILURE before any test
* runs, so a stray DATABASE_URL can't silently corrupt dev/prod data.
*/
function extractDbName(url: string | undefined): string | null {
if (!url) return null;
try {
// mysql://user:pass@host:3306/dbname?params → "dbname"
const afterAuthority = url.replace(/^[a-z]+:\/\/[^/]+\//i, "");
const dbName = afterAuthority.split("?")[0].split("/")[0];
return dbName || null;
} catch {
return null;
}
}
const dbUrl = process.env.DATABASE_URL;
const dbName = extractDbName(dbUrl);
if (!dbUrl) {
throw new Error(
"[test-setup] DATABASE_URL is not set. The suite mutates a real database — " +
`point ${ENV_TEST_PATH} at a dedicated TEST database (e.g. app_test).`,
);
}
if (dbName == null || !/test/i.test(dbName)) {
throw new Error(
`[test-setup] Refusing to run: DATABASE_URL targets database "${dbName ?? "unknown"}", ` +
`whose name does not contain "test". The suite mutates the database — point ` +
`${ENV_TEST_PATH} at a throwaway TEST database (e.g. app_test), never dev/production.`,
);
}

View File

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

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

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

View File

@@ -1,4 +1,13 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import {
describe,
it,
expect,
beforeAll,
afterAll,
beforeEach,
afterEach,
vi,
} from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
@@ -6,6 +15,7 @@ import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import warehouseRoutes from "../routes/admin/warehouse";
import { nasInvoicesManager } from "../services/nas-financials-manager";
import { securityHeaders } from "../middleware/security";
// ---------------------------------------------------------------------------
@@ -627,19 +637,22 @@ describe("Item CRUD", () => {
expect(body.data.name).toBe(`${N}item1_updated`);
});
it("deactivates an item (soft delete)", async () => {
it("hard-deletes an item with no stock (row is gone, 404 afterwards)", async () => {
// The current API HARD-deletes the item — the route does
// `prisma.sklad_items.delete`, NOT an `is_active=false` soft toggle. The
// test name reflects that real behavior so a future intended switch to
// soft-delete would (correctly) make this test fail and prompt a review,
// rather than the old "soft delete" name masking the mismatch.
const res = await app.inject({
method: "DELETE",
url: `/api/admin/warehouse/items/${createdItemId}`,
headers: authHeader(adminToken),
});
// The current API hard-deletes the item (the route does
// `prisma.sklad_items.delete`, not an `is_active=false` toggle).
// Verify the row is gone rather than checking is_active.
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
// The row is actually gone — a subsequent GET 404s.
const checkRes = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items/${createdItemId}`,
@@ -876,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
// ---------------------------------------------------------------------------
@@ -1030,6 +1214,124 @@ describe("Issue flow", () => {
});
});
// ---------------------------------------------------------------------------
// 6b. FIFO ordering — the OLDEST batch is consumed first
// ---------------------------------------------------------------------------
describe("Issue FIFO ordering (oldest batch first)", () => {
it("consumes the batch with the earliest received_at first, not the lowest id", async () => {
// Build two batches for one item via two confirmed receipts.
const itemRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/items",
headers: authHeader(adminToken),
payload: { name: `${N}item_fifo`, unit: "ks" },
});
const fifoItemId = itemRes.json().data.id as number;
const locA = (
await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "fifo loc A" },
})
).json().data.id as number;
const locB = (
await app.inject({
method: "POST",
url: "/api/admin/warehouse/locations",
headers: authHeader(adminToken),
payload: { code: uniqueCode(), name: "fifo loc B" },
})
).json().data.id as number;
// Helper: create + confirm a receipt of `qty` at `locId`.
const confirmReceipt = async (qty: number, locId: number) => {
const r = await app.inject({
method: "POST",
url: "/api/admin/warehouse/receipts",
headers: authHeader(adminToken),
payload: {
notes: `${N}fifo_receipt`,
items: [
{
item_id: fifoItemId,
quantity: qty,
unit_price: 10,
location_id: locId,
},
],
},
});
const id = r.json().data.id as number;
await app.inject({
method: "POST",
url: `/api/admin/warehouse/receipts/${id}/confirm`,
headers: authHeader(adminToken),
});
};
// batchEarly is created FIRST (lower id); batchLate is created SECOND
// (higher id). We then flip their received_at so the HIGHER-id batch is the
// OLDEST — this is what lets the test prove FIFO sorts by received_at, not
// by insertion id.
await confirmReceipt(10, locA); // → batch with the lower id
await confirmReceipt(10, locB); // → batch with the higher id
const batchesRaw = await prisma.sklad_batches.findMany({
where: { item_id: fifoItemId },
orderBy: { id: "asc" },
});
expect(batchesRaw.length).toBe(2);
const lowerId = batchesRaw[0]; // created first
const higherId = batchesRaw[1]; // created second
// Make the HIGHER-id batch the OLDEST by received_at.
await prisma.sklad_batches.update({
where: { id: lowerId.id },
data: { received_at: new Date("2021-01-01T00:00:00Z") },
});
await prisma.sklad_batches.update({
where: { id: higherId.id },
data: { received_at: new Date("2020-01-01T00:00:00Z") }, // older
});
// Issue 5 with no explicit batch → FIFO must auto-select the OLDEST batch
// (the higher-id one we back-dated), partially depleting it.
const issueRes = await app.inject({
method: "POST",
url: "/api/admin/warehouse/issues",
headers: authHeader(adminToken),
payload: {
project_id: projectId,
notes: `${N}fifo_issue`,
items: [{ item_id: fifoItemId, quantity: 5 }],
},
});
expect(issueRes.statusCode).toBe(201);
const issueId = issueRes.json().data.id as number;
const confirmRes = await app.inject({
method: "POST",
url: `/api/admin/warehouse/issues/${issueId}/confirm`,
headers: authHeader(adminToken),
});
expect(confirmRes.statusCode).toBe(200);
// The OLDEST batch (higher id, received 2020) is depleted from 10 → 5;
// the newer batch (lower id, received 2021) is untouched at 10. If FIFO
// wrongly sorted by id it would have consumed the lower-id batch instead.
const oldest = await prisma.sklad_batches.findUnique({
where: { id: higherId.id },
});
const newest = await prisma.sklad_batches.findUnique({
where: { id: lowerId.id },
});
expect(Number(oldest!.quantity)).toBe(5);
expect(Number(newest!.quantity)).toBe(10);
});
});
// ---------------------------------------------------------------------------
// 7. Reservation flow
// ---------------------------------------------------------------------------

View File

@@ -12,6 +12,7 @@ import { LoadingState } from "./ui";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
const Odin = lazy(() => import("./pages/Odin"));
const Users = lazy(() => import("./pages/Users"));
const Attendance = lazy(() => import("./pages/Attendance"));
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory"));
@@ -32,6 +33,7 @@ const OffersCustomers = lazy(() => import("./pages/OffersCustomers"));
const OffersTemplates = lazy(() => import("./pages/OffersTemplates"));
const Orders = lazy(() => import("./pages/Orders"));
const OrderDetail = lazy(() => import("./pages/OrderDetail"));
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail"));
const Projects = lazy(() => import("./pages/Projects"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
const Invoices = lazy(() => import("./pages/Invoices"));
@@ -80,8 +82,18 @@ export default function AdminApp() {
{import.meta.env.DEV && UiKit && (
<Route path="ui-kit" element={<UiKit />} />
)}
{/*
Auth gating is delegated to <AppShell>: every authenticated
page is nested under this layout route, and AppShell
redirects to /login when there is no user (per-page
permission checks live in the pages themselves). NOTE: any
NEW top-level route added OUTSIDE this <AppShell> wrapper
(like `login`/`ui-kit` above) is PUBLIC by default — wrap it
in AppShell or add its own guard if it needs auth.
*/}
<Route element={<AppShell />}>
<Route index element={<Dashboard />} />
<Route path="odin" element={<Odin />} />
<Route path="users" element={<Users />} />
<Route path="attendance" element={<Attendance />} />
<Route
@@ -129,6 +141,14 @@ export default function AdminApp() {
element={<OffersTemplates />}
/>
<Route path="orders" element={<Orders />} />
<Route
path="orders/issued/new"
element={<IssuedOrderDetail />}
/>
<Route
path="orders/issued/:id"
element={<IssuedOrderDetail />}
/>
<Route path="orders/:id" element={<OrderDetail />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:id" element={<ProjectDetail />} />

View File

@@ -12,7 +12,10 @@ export default function AlertContainer() {
key={alert.id}
open
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
sx={{ bottom: { xs: 16 + index * 72 } }}
// Offset each stacked toast by its position in the list. Applied at
// every breakpoint (not just xs) so larger screens don't fall back to
// MUI's default bottom position and overlap with 3+ alerts.
sx={{ bottom: 16 + index * 72 }}
>
<MuiAlert
severity={alert.type}

View File

@@ -116,7 +116,10 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
}
return (
<StatusChip
key={log.id ?? i}
// Prefer the DB id; fall back to a project_id+index composite so
// logs without an id (e.g. active/unsaved rows) still get a key
// that's more stable than a bare index.
key={log.id ?? `${log.project_id}-${i}`}
color={isActive ? "info" : "default"}
label={`${log.project_name || `#${log.project_id}`} ${
durationValid

View File

@@ -96,7 +96,7 @@ export default function BulkAttendanceModal({
color: "primary.main",
}}
>
{form.user_ids.length === users.length
{users.length > 0 && form.user_ids.length === users.length
? "Odznačit vše"
: "Vybrat vše"}
</Typography>

View File

@@ -19,6 +19,9 @@ export default class ErrorBoundary extends Component<Props, State> {
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Logged to the console only — the app has no client telemetry/error
// reporting service today. This is the hook to forward to one (Sentry,
// etc.) if/when it's added.
console.error("ErrorBoundary caught:", error, info);
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
@@ -7,27 +7,34 @@ import { useTheme } from "@mui/material/styles";
import { Modal, Button, TextField, Field } from "../ui";
import { useAlert } from "../context/AlertContext";
// Editable line-item. quantity/unit_price are held as the raw typed string
// while editing (so a field can be cleared — empty renders fine in a
// type="number" input) and are coerced to numbers in handleEditGenerate before
// being handed to onGenerate (the parent posts them verbatim).
interface ConfirmationItem {
description: string;
quantity: string | number;
unit: string;
unit_price: string | number;
is_included_in_total: boolean;
}
// Numeric shape handed to the parent (the raw editing strings are coerced in
// handleEditGenerate). Distinct from the editable ConfirmationItem.
interface GeneratedItem {
description: string;
quantity: number;
unit: string;
unit_price: number;
is_included_in_total: boolean;
vat_rate: number;
}
interface OrderConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onGenerate: (
lang: string,
applyVat: boolean,
items?: ConfirmationItem[],
) => Promise<void>;
onGenerate: (lang: string, items?: GeneratedItem[]) => Promise<void>;
initialItems: ConfirmationItem[];
orderNumber: string;
defaultVatRate: number;
applyVat: boolean;
}
export default function OrderConfirmationModal({
@@ -36,43 +43,64 @@ export default function OrderConfirmationModal({
onGenerate,
initialItems,
orderNumber,
defaultVatRate,
applyVat,
}: OrderConfirmationModalProps) {
const alert = useAlert();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [step, setStep] = useState<"choose" | "edit">("choose");
const [lang, setLang] = useState<string>("cs");
const [applyVatState, setApplyVatState] = useState(applyVat);
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
const [loading, setLoading] = useState(false);
// The modal is permanently mounted and merely toggled via `isOpen`, so its
// local state survives between opens and the `useState(initialItems)` snapshot
// goes stale. Re-seed everything from the current props each time the modal
// (re)opens so a fresh open always reflects the latest order data and starts
// on the "choose" step.
useEffect(() => {
if (!isOpen) return;
setStep("choose");
setLang("cs");
setItems(initialItems);
// initialItems are captured at open time; intentionally not in the dep
// array so an unrelated parent re-render doesn't clobber the user's edits.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const handleUseExisting = async () => {
setLoading(true);
try {
await onGenerate(lang, applyVatState, undefined);
await onGenerate(lang, undefined);
// Only close on success — a generation error must keep the modal open.
onClose();
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally {
setLoading(false);
setStep("choose");
onClose();
}
};
const handleEditGenerate = async () => {
setLoading(true);
try {
await onGenerate(lang, applyVatState, items);
// Coerce the raw typed strings back to numbers so an empty/partial field
// never reaches the server as NaN (the parent posts these verbatim).
const coercedItems: GeneratedItem[] = 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,
}));
await onGenerate(lang, coercedItems);
// Only close on success — on error keep the user's edited items intact.
onClose();
} catch (err) {
console.error("Chyba při generování potvrzení:", err);
alert.error("Nepodařilo se vygenerovat potvrzení");
} finally {
setLoading(false);
setStep("choose");
onClose();
}
};
@@ -104,10 +132,9 @@ export default function OrderConfirmationModal({
unit: "ks",
unit_price: 0,
is_included_in_total: true,
vat_rate: defaultVatRate,
},
]);
}, [defaultVatRate]);
}, []);
return (
<Modal
@@ -145,27 +172,6 @@ export default function OrderConfirmationModal({
</Box>
</Field>
<Field label="DPH">
<Box sx={{ display: "flex", gap: 1 }}>
<Button
size="small"
variant={applyVatState ? "contained" : "outlined"}
color={applyVatState ? "primary" : "inherit"}
onClick={() => setApplyVatState(true)}
>
S DPH
</Button>
<Button
size="small"
variant={!applyVatState ? "contained" : "outlined"}
color={!applyVatState ? "primary" : "inherit"}
onClick={() => setApplyVatState(false)}
>
Bez DPH
</Button>
</Box>
</Field>
<Field label="Obsah potvrzení">
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Jak chcete připravit potvrzení objednávky?
@@ -250,16 +256,16 @@ export default function OrderConfirmationModal({
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gap: 1,
}}
>
<TextField
label="Množství"
type="number"
value={item.quantity}
value={item.quantity ?? ""}
onChange={(e) =>
updateItem(i, "quantity", Number(e.target.value) || 0)
updateItem(i, "quantity", e.target.value)
}
slotProps={{ htmlInput: { step: "0.001" } }}
/>
@@ -271,21 +277,12 @@ export default function OrderConfirmationModal({
<TextField
label="Cena"
type="number"
value={item.unit_price}
value={item.unit_price ?? ""}
onChange={(e) =>
updateItem(i, "unit_price", Number(e.target.value) || 0)
updateItem(i, "unit_price", e.target.value)
}
slotProps={{ htmlInput: { step: "0.01" } }}
/>
<TextField
label="%DPH"
type="number"
value={item.vat_rate}
onChange={(e) =>
updateItem(i, "vat_rate", Number(e.target.value) || 0)
}
slotProps={{ htmlInput: { step: "1" } }}
/>
</Box>
</Box>
))}
@@ -315,7 +312,6 @@ export default function OrderConfirmationModal({
<th>Mn.</th>
<th>Jedn.</th>
<th>Cena</th>
<th>%DPH</th>
<th style={{ width: "40px" }} />
</tr>
</thead>
@@ -334,15 +330,11 @@ export default function OrderConfirmationModal({
<td>
<TextField
type="number"
value={item.quantity}
value={item.quantity ?? ""}
onChange={(e) =>
updateItem(
i,
"quantity",
Number(e.target.value) || 0,
)
updateItem(i, "quantity", e.target.value)
}
sx={{ width: 90 }}
sx={{ width: 112 }}
slotProps={{ htmlInput: { step: "0.001" } }}
/>
</td>
@@ -358,33 +350,14 @@ export default function OrderConfirmationModal({
<td>
<TextField
type="number"
value={item.unit_price}
value={item.unit_price ?? ""}
onChange={(e) =>
updateItem(
i,
"unit_price",
Number(e.target.value) || 0,
)
updateItem(i, "unit_price", e.target.value)
}
sx={{ width: 110 }}
slotProps={{ htmlInput: { step: "0.01" } }}
/>
</td>
<td>
<TextField
type="number"
value={item.vat_rate}
onChange={(e) =>
updateItem(
i,
"vat_rate",
Number(e.target.value) || 0,
)
}
sx={{ width: 80 }}
slotProps={{ htmlInput: { step: "1" } }}
/>
</td>
<td>
<IconButton
size="small"

View File

@@ -145,6 +145,10 @@ export default function PlanCategoriesModal({
aria-label={`Barva ${c.label}`}
/>
<TextField
// Uncontrolled defaultValue won't update if the label changes
// externally (e.g. another tab renames it). Keying on c.label
// remounts the input so it re-seeds, mirroring the color input.
key={c.label}
defaultValue={c.label}
disabled={busy}
onBlur={(e) => {

View File

@@ -28,6 +28,35 @@ interface Project {
project_number?: string;
}
/** Fields shared by every plan-record save payload the modal emits. */
interface PlanRecordFields {
project_id: number | null;
category: string;
note: string;
}
/** Body for creating a plan entry (a single day or a multi-day range). */
export interface PlanEntryCreateBody extends PlanRecordFields {
user_id: number;
date_from: string;
date_to: string;
}
/** Body for updating an existing plan entry (the entry id is passed separately). */
export interface PlanEntryUpdateBody extends PlanRecordFields {
date_from: string;
date_to: string;
}
/** Body for creating a single-day override. */
export interface PlanOverrideCreateBody extends PlanRecordFields {
user_id: number;
shift_date: string;
}
/** Body for updating an existing override (the override id is passed separately). */
export type PlanOverrideUpdateBody = PlanRecordFields;
export type PlanCellModalMode =
| { kind: "closed" }
| { kind: "create"; userId: number; date: string }
@@ -74,11 +103,11 @@ interface Props {
projects: Project[];
categories: PlanCategory[];
onClose: () => void;
onSaveEntry: (body: any) => Promise<void>;
onUpdateEntry: (id: number, body: any) => Promise<void>;
onSaveEntry: (body: PlanEntryCreateBody) => Promise<void>;
onUpdateEntry: (id: number, body: PlanEntryUpdateBody) => Promise<void>;
onDeleteEntry: (id: number) => Promise<void>;
onSaveOverride: (body: any) => Promise<void>;
onUpdateOverride: (id: number, body: any) => Promise<void>;
onSaveOverride: (body: PlanOverrideCreateBody) => Promise<void>;
onUpdateOverride: (id: number, body: PlanOverrideUpdateBody) => Promise<void>;
onDeleteOverride: (id: number) => Promise<void>;
onCreateOverrideFromRange: (
entryId: number,
@@ -128,6 +157,71 @@ export default function PlanCellModal(props: Props) {
return <EditForm {...props} />;
}
interface EditFormInitial {
date_from: string;
date_to: string;
is_range: boolean;
project_id: number | null;
category: string;
note: string;
}
/**
* Initial form values for every mode kind. Unhandled kinds get a harmless
* default (EditForm renders null for them — but only AFTER its hooks have run).
* Keeping this a plain function (not an inline IIFE with early returns) lets
* EditForm call its useState hooks unconditionally, satisfying the Rules of
* Hooks.
*/
function computeEditFormInitial(
mode: PlanCellModalMode,
activeCategories: { key: string }[],
): EditFormInitial {
if (mode.kind === "create" || mode.kind === "create-override") {
return {
date_from: mode.date,
date_to: mode.date,
is_range: false,
project_id: null,
// Default to "work" when it's active, otherwise the first active
// category, so a new entry never starts on a retired key.
category: activeCategories.some((c) => c.key === "work")
? "work"
: (activeCategories[0]?.key ?? "work"),
note: "",
};
}
if (mode.kind === "edit-range") {
return {
date_from: mode.range.date_from,
date_to: mode.range.date_to,
is_range: mode.range.date_from !== mode.range.date_to,
project_id: mode.range.project_id,
category: mode.range.category,
note: mode.range.note,
};
}
if (mode.kind === "edit-override") {
return {
date_from: mode.date,
date_to: mode.date,
is_range: false,
project_id: mode.cell.project_id,
category: mode.cell.category,
note: mode.cell.note,
};
}
// Unhandled kinds: harmless defaults (EditForm returns null after its hooks).
return {
date_from: "",
date_to: "",
is_range: false,
project_id: null,
category: "work",
note: "",
};
}
function EditForm(props: Props) {
const {
mode,
@@ -140,13 +234,27 @@ function EditForm(props: Props) {
onDeleteOverride,
projects,
} = props;
// An override is a single-day record — both editing one ("edit-override")
// and creating one ("create-override") lock the date and hide the range UI.
const isOverride =
mode.kind === "edit-override" || mode.kind === "create-override";
const activeCategories = props.categories.filter((c) => c.is_active);
// Computed for ALL mode kinds (the helper has a fallback) so the hooks below
// are unconditional.
const initial = computeEditFormInitial(mode, activeCategories);
const [submitting, setSubmitting] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [dateFrom, setDateFrom] = useState(initial.date_from);
const [dateTo, setDateTo] = useState(initial.date_to);
const [isRange, setIsRange] = useState(initial.is_range);
const [projectId, setProjectId] = useState<number | null>(initial.project_id);
const [category, setCategory] = useState(initial.category);
const [note, setNote] = useState(initial.note);
// Narrow mode to the kinds EditForm actually handles. The call site in
// PlanCellModal only passes "create" | "create-override" | "edit-range" |
// "edit-override" but the parameter type is the full union, so we narrow
// explicitly here.
// Narrow mode to the kinds EditForm actually handles. The call site only ever
// passes the four editable kinds; we guard AFTER all hooks so the hook order
// is identical on every render (Rules of Hooks).
if (
mode.kind !== "create" &&
mode.kind !== "create-override" &&
@@ -156,55 +264,6 @@ function EditForm(props: Props) {
return null;
}
// An override is a single-day record — both editing one ("edit-override")
// and creating one ("create-override") lock the date and hide the range UI.
const isOverride =
mode.kind === "edit-override" || mode.kind === "create-override";
const activeCategories = props.categories.filter((c) => c.is_active);
const initial = (() => {
if (mode.kind === "create" || mode.kind === "create-override") {
return {
date_from: mode.date,
date_to: mode.date,
is_range: false,
project_id: null as number | null,
// Default to "work" when it's active, otherwise the first active
// category, so a new entry never starts on a retired key.
category: activeCategories.some((c) => c.key === "work")
? "work"
: (activeCategories[0]?.key ?? "work"),
note: "",
};
}
if (mode.kind === "edit-range") {
return {
date_from: mode.range.date_from,
date_to: mode.range.date_to,
is_range: mode.range.date_from !== mode.range.date_to,
project_id: mode.range.project_id,
category: mode.range.category,
note: mode.range.note,
};
}
// mode.kind === "edit-override" (narrowed above)
return {
date_from: mode.date,
date_to: mode.date,
is_range: false,
project_id: mode.cell.project_id,
category: mode.cell.category,
note: mode.cell.note,
};
})();
const [dateFrom, setDateFrom] = useState(initial.date_from);
const [dateTo, setDateTo] = useState(initial.date_to);
const [isRange, setIsRange] = useState(initial.is_range);
const [projectId, setProjectId] = useState<number | null>(initial.project_id);
const [category, setCategory] = useState(initial.category);
const [note, setNote] = useState(initial.note);
const title =
mode.kind === "create"
? "Nový záznam plánu"

View File

@@ -466,9 +466,23 @@ export default function PlanGrid({
}: Props) {
const today = useMemo(() => todayIso(), []);
const catMap = useMemo(() => categoryMap(categories), [categories]);
// Index projects by id once per projects change instead of a linear
// projects.find() inside the per-cell render loop (which was
// O(days * users * cells * projects)). Hooks must run before the early
// return below, so this is computed unconditionally.
const projectMap = useMemo(() => {
const m = new Map<number, Project>();
for (const p of projects) m.set(p.id, p);
return m;
}, [projects]);
// Memoize the day list so it isn't rebuilt on every render (only when the
// visible range changes). Stable identity also keeps the row map cheap.
const days = useMemo(
() => (data ? eachDay(data.date_from, data.date_to) : []),
[data?.date_from, data?.date_to],
);
if (!data) return <LoadingState />;
const days = eachDay(data.date_from, data.date_to);
const users = data.users;
// pulseKey?.nonce is included in the data-pulse attribute so a second
// mutation on the same cell re-triggers the animation (CSS animations
@@ -610,12 +624,9 @@ export default function PlanGrid({
cell={c}
project={
c.project_id
? (projects.find(
(p) => p.id === c.project_id,
) ?? null)
? (projectMap.get(c.project_id) ?? null)
: null
}
readonly={!canEdit}
categoryLabel={planCategoryLabel(
c.category,
catMap,

View File

@@ -4,17 +4,18 @@ import type { Project } from "../lib/queries/projects";
interface Props {
cell: ResolvedCell | null;
project: Project | null;
readonly?: boolean;
categoryLabel: string;
}
// Purely presentational — renders the category/project chips and note for a
// single resolved plan cell. It has no interactive handlers of its own (the
// click target is the cell <button> in PlanGrid, which already applies the
// read-only styling/behaviour), so there is no read-only state to gate here.
export default function PlanRangeChips({
cell,
project,
readonly,
categoryLabel,
}: Props) {
void readonly;
if (!cell) return null;
// Prefer the server-embedded project label (always present). Fall back to
// the looked-up `project` prop only if the cell predates the embed (stale

View File

@@ -250,6 +250,11 @@ export default function ProjectFileManager({
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isCancelling = useRef(false);
// dragenter/dragleave fire on every child boundary crossed during a drag.
// Counting enter/leave events keeps the overlay steady instead of flickering
// as the cursor moves over nested elements; the overlay only hides when the
// depth returns to 0 (the cursor truly left the drop zone).
const dragDepth = useRef(0);
const [currentPath, setCurrentPath] = useState("");
@@ -333,7 +338,8 @@ export default function ProjectFileManager({
} else {
errorMsg = data.error || "Chyba při nahrávání";
}
} catch {
} catch (e) {
console.error("ProjectFileManager: upload failed", e);
errorMsg = "Chyba připojení";
}
}
@@ -362,21 +368,32 @@ export default function ProjectFileManager({
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
dragDepth.current = 0;
setDragOver(false);
if (!canManage) return;
handleUpload(e.dataTransfer.files);
};
const handleDragOver = (e: React.DragEvent) => {
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
if (!canManage) return;
dragDepth.current += 1;
setDragOver(true);
};
const handleDragOver = (e: React.DragEvent) => {
// Must preventDefault on dragover too, otherwise the browser rejects the
// drop. The overlay state is driven by enter/leave (see dragDepth).
e.preventDefault();
if (canManage) {
setDragOver(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (!canManage) return;
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) {
setDragOver(false);
}
};
const handleCreateFolder = async () => {
@@ -405,7 +422,8 @@ export default function ProjectFileManager({
} else {
alert.error(data.error || "Nepodařilo se vytvořit složku");
}
} catch {
} catch (e) {
console.error("ProjectFileManager: create folder failed", e);
alert.error("Chyba připojení");
} finally {
setCreatingFolder(false);
@@ -435,7 +453,8 @@ export default function ProjectFileManager({
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
} catch (e) {
console.error("ProjectFileManager: download failed", e);
alert.error("Chyba připojení");
}
};
@@ -468,7 +487,8 @@ export default function ProjectFileManager({
} else {
alert.error(data.error || "Nepodařilo se smazat");
}
} catch {
} catch (e) {
console.error("ProjectFileManager: delete failed", e);
alert.error("Chyba připojení");
} finally {
setDeleting(false);
@@ -505,7 +525,8 @@ export default function ProjectFileManager({
} else {
alert.error(data.error || "Nepodařilo se přejmenovat");
}
} catch {
} catch (e) {
console.error("ProjectFileManager: rename failed", e);
alert.error("Chyba připojení");
} finally {
setRenamingItem(null);
@@ -887,6 +908,7 @@ export default function ProjectFileManager({
{/* Drop zone + table */}
<Box
onDrop={handleDrop}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
sx={{

View File

@@ -88,7 +88,9 @@ export default function RichEditor({
);
const handleChange = useCallback(
(content: string, _delta: any, source: string) => {
// `_delta` is react-quill-new's loosely-typed Delta object; we never read
// it, so `unknown` is enough and avoids an `any` escape hatch.
(content: string, _delta: unknown, source: string) => {
if (source !== "user") return;
if (content === lastValueRef.current) return;
lastValueRef.current = content;

View File

@@ -1,3 +1,4 @@
import { useRef } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
@@ -17,8 +18,6 @@ import {
formatDate,
} from "../utils/attendanceHelpers";
let _logKeyCounter = 0;
// ---------- Shared types ----------
export interface ShiftFormData {
@@ -226,6 +225,11 @@ export default function ShiftFormModal({
const isCreate = mode === "create";
const isWorkType = form.leave_type === "work";
// Per-instance counter for stable React keys on newly-added project rows.
// (Was a module-level mutable `let`, which is shared across every modal
// instance and fragile under StrictMode / concurrent instances.)
const logKeyCounter = useRef(0);
const updateField = (field: keyof ShiftFormData, value: string | number) => {
setForm({ ...form, [field]: value });
};
@@ -244,7 +248,7 @@ export default function ShiftFormModal({
setProjectLogs([
...projectLogs,
{
_key: `log-${++_logKeyCounter}`,
_key: `log-${++logKeyCounter.current}`,
project_id: "",
hours: "",
minutes: "",
@@ -327,7 +331,9 @@ export default function ShiftFormModal({
inputMode="decimal"
value={form.leave_hours}
onChange={(e) =>
updateField("leave_hours", parseFloat(e.target.value))
// Empty input -> parseFloat returns NaN, which serializes to an
// invalid value; fall back to 0 like the Number(...)||0 pattern.
updateField("leave_hours", Number(e.target.value) || 0)
}
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
/>
@@ -431,7 +437,10 @@ export default function ShiftFormModal({
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
{projectLogs.map((log, i) => (
<ProjectLogRow
key={log._key || i}
// Stable key: client-added rows carry `_key`; server-loaded rows
// carry a DB `id`. Fall back to the index only when neither is
// present (shouldn't happen, but avoids a key collision).
key={log._key ?? (log.id != null ? `id-${log.id}` : i)}
log={log}
index={i}
projectList={projectList}

View File

@@ -1,3 +1,7 @@
// Intentional no-op: this component is still mounted by AppShell.tsx as a
// placeholder for a future keyboard-shortcuts help overlay. It renders nothing
// today. Keep the harmless `null` return (and the AppShell import) until the
// overlay is actually built — do not delete the file while AppShell references it.
export default function ShortcutsHelp() {
return null;
}

View File

@@ -1,4 +1,4 @@
import { motion } from "framer-motion";
import { motion, useReducedMotion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { StatCard, type StatCardColor } from "../../ui";
@@ -121,6 +121,7 @@ const KPI_COLOR_MAP: Record<string, StatCardColor> = {
};
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
const reduce = useReducedMotion();
const kpiCards = buildKpiCards(dashData);
if (kpiCards.length === 0) {
return null;
@@ -129,8 +130,8 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
return (
<Box
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
sx={{
display: "grid",

View File

@@ -1,5 +1,7 @@
import { useState, useRef } from "react";
import { motion } from "framer-motion";
import { motion, useReducedMotion } from "framer-motion";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import QRCode from "qrcode";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
@@ -16,6 +18,35 @@ import useDialogScrollLock from "../../ui/useDialogScrollLock";
const API_BASE = "/api/admin";
// Copy helper. navigator.clipboard is SECURE-CONTEXT ONLY (undefined over plain
// HTTP on a LAN), so we fall back to the legacy execCommand path — which works
// in insecure contexts — and report whether the copy actually succeeded. The
// caller MUST gate its success toast on the returned boolean (otherwise it lies
// to the user about copying 2FA codes / the TOTP secret).
async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// fall through to the legacy path
}
try {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}
interface DashProfileProps {
totpEnabled: boolean;
totpLoading: boolean;
@@ -96,12 +127,36 @@ export default function DashProfile({
}: DashProfileProps) {
const { user, updateUser } = useAuth();
const alert = useAlert();
const queryClient = useQueryClient();
const reduce = useReducedMotion();
const totpSetupRef = useRef<HTMLInputElement>(null);
// The 2FA setup dialog is bespoke (multi-step: setup → backup codes) and
// locks <html> scroll like the kit dialogs.
useDialogScrollLock(show2FASetup);
// Generate the enrollment QR LOCALLY from the otpauth URI. Never send the
// URI to an external QR service: the production CSP (img-src) blocks it and
// it would leak the TOTP secret to a third party. A data: URL is allowed by
// the CSP. Derived via useQuery (not an effect) per repo conventions.
const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({
queryKey: ["totp", "qr", totpQrUri],
enabled: !!totpQrUri,
staleTime: Infinity,
// The URI embeds the TOTP secret — drop it from the cache as soon as the
// enrollment UI unmounts instead of keeping it for the default 5-min GC.
gcTime: 0,
retry: false,
queryFn: async () => {
try {
return await QRCode.toDataURL(totpQrUri!, { width: 200, margin: 2 });
} catch (err) {
console.error("DashProfile: generování QR kódu selhalo", err);
throw err;
}
},
});
const [showModal, setShowModal] = useState(false);
const [formData, setFormData] = useState<ProfileFormData>({
username: "",
@@ -127,21 +182,30 @@ export default function DashProfile({
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
const dataToSave = { ...formData };
if (dataToSave.new_password && !dataToSave.current_password) {
if (formData.new_password && !formData.current_password) {
alert.error("Pro změnu hesla zadejte aktuální heslo");
return;
}
if (dataToSave.current_password && !dataToSave.new_password) {
if (formData.current_password && !formData.new_password) {
alert.error("Pro změnu hesla zadejte nové heslo");
return;
}
// Strip empty password fields so Zod doesn't reject ""
if (!dataToSave.current_password)
delete (dataToSave as any).current_password;
if (!dataToSave.new_password) delete (dataToSave as any).new_password;
// Build the payload with the password fields optional so empty ones can be
// omitted (Zod rejects ""), without resorting to `as any` deletes.
const dataToSave: Partial<
Pick<ProfileFormData, "current_password" | "new_password">
> &
Omit<ProfileFormData, "current_password" | "new_password"> = {
username: formData.username,
email: formData.email,
first_name: formData.first_name,
last_name: formData.last_name,
};
if (formData.current_password)
dataToSave.current_password = formData.current_password;
if (formData.new_password) dataToSave.new_password = formData.new_password;
try {
const response = await apiFetch(`${API_BASE}/profile`, {
@@ -156,13 +220,21 @@ export default function DashProfile({
email: dataToSave.email,
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
});
// Refresh anything keyed on the current user's data so stale views
// (dashboard widgets, user lists) pick up the edited profile.
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
setShowModal(false);
// The 300ms wait is load-bearing: it lets the modal's close fade finish
// before the success toast appears, so the toast doesn't flash over the
// still-fading dialog.
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success("Profil byl upraven");
} else {
alert.error(data.error || "Nepodařilo se uložit profil");
}
} catch {
} catch (err) {
console.error("DashProfile: uložení profilu selhalo", err);
alert.error("Chyba připojení");
}
};
@@ -187,8 +259,8 @@ export default function DashProfile({
return (
<>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<Card>
@@ -480,9 +552,10 @@ export default function DashProfile({
</Box>
<Box sx={{ mt: 1.5 }}>
<Button
onClick={() => {
navigator.clipboard?.writeText(backupCodes.join("\n"));
alert.success("Kódy zkopírovány");
onClick={async () => {
const ok = await copyToClipboard(backupCodes.join("\n"));
if (ok) alert.success("Kódy zkopírovány");
else alert.error("Kopírování selhalo zkopírujte ručně");
}}
variant="outlined"
color="inherit"
@@ -499,11 +572,11 @@ export default function DashProfile({
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
Authy, Microsoft Authenticator apod.)
</Typography>
{totpQrUri && (
{totpQrUri && totpQrDataUrl && (
<Box sx={{ textAlign: "center", mb: 2 }}>
<Box
component="img"
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
src={totpQrDataUrl}
alt="TOTP QR Code"
sx={{
width: 200,
@@ -511,10 +584,23 @@ export default function DashProfile({
borderRadius: 2,
border: 1,
borderColor: "divider",
// The QR must stay scannable in dark mode — keep it on a
// white tile instead of inheriting the dark paper bg.
bgcolor: "common.white",
}}
/>
</Box>
)}
{totpQrUri && totpQrFailed && (
<Typography
variant="body2"
color="warning.main"
sx={{ mb: 2, textAlign: "center" }}
>
QR kód se nepodařilo vygenerovat. Zadejte prosím klíč do
aplikace ručně.
</Typography>
)}
{totpSecret && (
<Box sx={{ mb: 2 }}>
<Typography
@@ -543,9 +629,11 @@ export default function DashProfile({
>
<span>{totpSecret}</span>
<IconButton
onClick={() => {
navigator.clipboard?.writeText(totpSecret);
alert.success("Klíč zkopírován");
onClick={async () => {
const ok = await copyToClipboard(totpSecret);
if (ok) alert.success("Klíč zkopírován");
else
alert.error("Kopírování selhalo zkopírujte ručně");
}}
size="small"
title="Kopírovat"

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
import { motion, useReducedMotion } from "framer-motion";
import { useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext";
@@ -16,6 +17,14 @@ interface Vehicle {
name: string;
}
// Standard { success, data, error, message } envelope returned by the API.
interface ApiResult<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
interface TripForm {
vehicle_id: string;
trip_date: string;
@@ -61,6 +70,8 @@ export default function DashQuickActions({
}: DashQuickActionsProps) {
const { hasPermission } = useAuth();
const alert = useAlert();
const queryClient = useQueryClient();
const reduce = useReducedMotion();
const [showTripModal, setShowTripModal] = useState(false);
const [tripSubmitting, setTripSubmitting] = useState(false);
@@ -93,16 +104,17 @@ export default function DashQuickActions({
try {
const response = await apiFetch(`${API_BASE}/vehicles`);
const result = await response.json();
const result: ApiResult<Vehicle[] | { vehicles?: Vehicle[] }> =
await response.json();
if (result.success) {
setTripVehicles(
Array.isArray(result.data)
? result.data
: result.data?.vehicles || [],
: (result.data?.vehicles ?? []),
);
}
} catch {
// vozidla se nenacetla
} catch (e) {
console.error("DashQuickActions: nepodařilo se načíst vozidla", e);
}
};
@@ -113,12 +125,16 @@ export default function DashQuickActions({
}
try {
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
const result = await response.json();
if (result.success) {
setTripForm((prev) => ({ ...prev, start_km: result.data.last_km }));
const result: ApiResult<{ last_km: number | string }> =
await response.json();
if (result.success && result.data) {
setTripForm((prev) => ({
...prev,
start_km: String(result.data!.last_km ?? ""),
}));
}
} catch {
// last_km se nenacetlo
} catch (e) {
console.error("DashQuickActions: nepodařilo se načíst poslední km", e);
}
};
@@ -139,7 +155,7 @@ export default function DashQuickActions({
if (
tripForm.start_km &&
tripForm.end_km &&
parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)
parseInt(tripForm.end_km, 10) <= parseInt(tripForm.start_km, 10)
) {
errs.end_km = "Musí být větší než počáteční";
}
@@ -161,14 +177,19 @@ export default function DashQuickActions({
headers: { "Content-Type": "application/json" },
body: JSON.stringify(tripForm),
});
const result = await response.json();
const result: ApiResult<unknown> = await response.json();
if (result.success) {
// A new trip changes the trip list and the dashboard vehicle widgets —
// invalidate the broad domains (prefix-matching covers sub-queries).
queryClient.invalidateQueries({ queryKey: ["trips"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
setShowTripModal(false);
alert.success(result.message);
alert.success(result.message ?? "Jízda uložena");
} else {
alert.error(result.error);
alert.error(result.error ?? "Uložení jízdy selhalo");
}
} catch {
} catch (e) {
console.error("DashQuickActions: uložení jízdy selhalo", e);
alert.error("Chyba připojení");
} finally {
setTripSubmitting(false);
@@ -176,8 +197,8 @@ export default function DashQuickActions({
};
const tripDistance = (): number => {
const s = parseInt(tripForm.start_km) || 0;
const e = parseInt(tripForm.end_km) || 0;
const s = parseInt(tripForm.start_km, 10) || 0;
const e = parseInt(tripForm.end_km, 10) || 0;
return e > s ? e - s : 0;
};
@@ -294,8 +315,8 @@ export default function DashQuickActions({
<>
<Box
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
sx={{
display: "grid",

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { motion, useReducedMotion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
@@ -69,6 +69,7 @@ function getDeviceIcon(iconType?: string) {
export default function DashSessions() {
const alert = useAlert();
const queryClient = useQueryClient();
const reduce = useReducedMotion();
const { data: sessions = [], isPending: sessionsLoading } =
useQuery(sessionsOptions());
@@ -128,8 +129,8 @@ export default function DashSessions() {
return (
<>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<Card sx={{ display: "flex", flexDirection: "column" }}>

View File

@@ -0,0 +1,121 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip";
import { Card, Button, TextField } from "../../ui";
import type { ReviewInvoice, EditableField } from "./types";
interface InvoiceReviewCardProps {
inv: ReviewInvoice;
busy: boolean;
canSave: boolean;
onChange: (uid: string, field: EditableField, value: string) => void;
onSave: (inv: ReviewInvoice) => void;
onDiscard: (uid: string) => void;
}
const fieldCols = { xs: "1fr", sm: "1fr 1fr" };
export default function InvoiceReviewCard({
inv,
busy,
canSave,
onChange,
onSave,
onDiscard,
}: InvoiceReviewCardProps) {
return (
<Card variant="outlined" sx={{ p: 1.5 }}>
{/* Header: chip + filename */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
<Chip label="Faktura" size="small" color="primary" />
<Typography
variant="caption"
color="text.secondary"
noWrap
title={inv.file_name}
>
{inv.file_name}
</Typography>
</Box>
{/* 2-column field grid */}
<Box
sx={{
display: "grid",
gridTemplateColumns: fieldCols,
gap: 1,
}}
>
<TextField
label="Dodavatel"
value={inv.supplier_name}
disabled={busy}
onChange={(e) => onChange(inv.uid, "supplier_name", e.target.value)}
/>
<TextField
label="Číslo faktury"
value={inv.invoice_number}
disabled={busy}
onChange={(e) => onChange(inv.uid, "invoice_number", e.target.value)}
/>
<TextField
label="Částka s DPH"
value={inv.amount}
disabled={busy}
onChange={(e) => onChange(inv.uid, "amount", e.target.value)}
/>
<TextField
label="Měna"
value={inv.currency}
disabled={busy}
onChange={(e) => onChange(inv.uid, "currency", e.target.value)}
/>
<TextField
label="Sazba DPH %"
value={inv.vat_rate}
disabled={busy}
onChange={(e) => onChange(inv.uid, "vat_rate", e.target.value)}
/>
<TextField
label="Datum vystavení"
value={inv.issue_date}
disabled={busy}
onChange={(e) => onChange(inv.uid, "issue_date", e.target.value)}
/>
<TextField
label="Datum splatnosti"
value={inv.due_date}
disabled={busy}
onChange={(e) => onChange(inv.uid, "due_date", e.target.value)}
/>
<TextField
label="Popis"
value={inv.description}
disabled={busy}
onChange={(e) => onChange(inv.uid, "description", e.target.value)}
/>
</Box>
{/* Actions */}
<Box sx={{ display: "flex", gap: 1, mt: 1.5 }}>
<Button
onClick={() => onSave(inv)}
disabled={busy || !canSave}
title={
!canSave ? "Nemáte oprávnění k uložení přijatých faktur" : undefined
}
>
Uložit
</Button>
<Button
variant="outlined"
color="inherit"
onClick={() => onDiscard(inv.uid)}
disabled={busy}
>
Zahodit
</Button>
</Box>
</Card>
);
}

View File

@@ -0,0 +1,548 @@
import { useState, useRef, useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import useMediaQuery from "@mui/material/useMediaQuery";
import apiFetch from "../../utils/api";
import { useAlert } from "../../context/AlertContext";
import { useAuth } from "../../context/AuthContext";
import {
aiUsageOptions,
aiConversationsOptions,
aiConversationMessagesOptions,
type ExtractedInvoice,
} from "../../lib/queries/ai";
import type {
ChatTurn,
ReviewInvoice,
EditableField,
StagedFile,
} from "./types";
import OdinSidebar from "./OdinSidebar";
import OdinThread from "./OdinThread";
import InvoiceReviewCard from "./InvoiceReviewCard";
import OdinComposer from "./OdinComposer";
// Stable, monotonic id (NOT crypto.randomUUID — undefined over plain HTTP).
let uidSeq = 0;
const nextUid = () => `ai-${(uidSeq += 1)}`;
const MODEL_CONTEXT = 20; // recent turns sent to the model
const MAX_STORE = 8000; // server caps a stored message at 8000 chars
const plural = (n: number) =>
n === 1 ? "fakturu" : n >= 2 && n <= 4 ? "faktury" : "faktur";
function summaryNote(reviews: ReviewInvoice[]): string {
if (reviews.length === 0) return "V přílohách jsem nenašel žádnou fakturu.";
const suppliers = [
...new Set(reviews.map((r) => r.supplier_name).filter(Boolean)),
];
const supTxt =
suppliers.length === 1
? ` od ${suppliers[0]}`
: suppliers.length > 1
? ` od ${suppliers[0]} a dalších`
: "";
const totals: Record<string, number> = {};
for (const r of reviews) {
const cur = r.currency || "CZK";
totals[cur] = (totals[cur] || 0) + (Number(r.amount) || 0);
}
const totalsTxt = Object.entries(totals)
.map(
([cur, sum]) =>
`${sum.toLocaleString("cs-CZ", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} ${cur}`,
)
.join(" + ");
return `Načetl jsem ${reviews.length} ${plural(reviews.length)}${supTxt}, celkem ${totalsTxt}. Zkontrolujte údaje a uložte.`;
}
export default function OdinChat() {
const alert = useAlert();
const qc = useQueryClient();
const { user, hasPermission } = useAuth();
// Saving an extracted invoice hits /received-invoices (needs invoices.create);
// Odin itself only needs ai.use, so the Save affordance is gated separately.
const canSave = hasPermission("invoices.create");
// Time-based Czech greeting + the user's first name (client-local time).
const greetingHour = new Date().getHours();
const greetingPhrase =
greetingHour < 9
? "Dobré ráno"
: greetingHour < 12
? "Dobrý den"
: greetingHour < 18
? "Dobré odpoledne"
: "Dobrý večer";
const userName = (user?.fullName || user?.username || "")
.trim()
.split(/\s+/)[0];
const { data: usage } = useQuery(aiUsageOptions());
const { data: convData, isPending: convPending } = useQuery(
aiConversationsOptions(),
);
const conversations = convData?.conversations ?? [];
const [activeId, setActiveId] = useState<number | null>(null);
const { data: messagesData } = useQuery({
...aiConversationMessagesOptions(activeId ?? 0),
enabled: activeId != null,
});
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [turns, setTurns] = useState<ChatTurn[]>([]);
const [review, setReview] = useState<ReviewInvoice[]>([]);
const [attachments, setAttachments] = useState<StagedFile[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
const threadRef = useRef<HTMLDivElement>(null);
const seededId = useRef<number | null>(null);
// Below md the conversation list collapses into a slide-in drawer.
const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), {
noSsr: true,
});
const [sidebarOpen, setSidebarOpen] = useState(false);
// No auto-select: like claude.ai, we land on a fresh "new chat" (activeId
// null) and the sidebar lists existing conversations to open. A conversation
// row in the DB is created lazily on the first message (see submit), so
// pressing "Nová konverzace" repeatedly never spawns empty conversations.
// Seed the thread from the active conversation's messages, once per id.
useEffect(() => {
if (activeId != null && messagesData && seededId.current !== activeId) {
seededId.current = activeId;
setTurns(
messagesData.messages.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
);
setReview([]);
}
}, [activeId, messagesData]);
// Keep the thread scrolled to the latest message.
useEffect(() => {
const el = threadRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [turns, busy]);
// AI is hidden entirely when the backend isn't configured.
if (usage && usage.configured === false) return null;
const messagesLoading =
activeId != null && !messagesData && seededId.current !== activeId;
const persist = (convId: number, msgs: ChatTurn[]) => {
apiFetch(`/api/admin/ai/conversations/${convId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: msgs.map((m) => ({
role: m.role,
content: m.content.slice(0, MAX_STORE),
})),
}),
})
.then((r) => {
if (!r.ok) return r.json().then((b) => Promise.reject(b?.error));
// The append applies the server-side auto-title and bumps updated_at,
// so refresh the list now (a new conversation gets its real title here).
qc.invalidateQueries({ queryKey: ["ai", "conversations"] });
})
.catch((e) => console.error("[odin] history persist failed", e));
};
// Attaching only stages files; nothing is sent until Odeslat.
const onFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
setAttachments((a) => [
...a,
...Array.from(files).map((file) => ({ id: nextUid(), file })),
]);
if (fileRef.current) fileRef.current.value = "";
};
const removeAttachment = (id: string) =>
setAttachments((a) => a.filter((s) => s.id !== id));
const onSelect = (id: number) => {
setSidebarOpen(false);
if (id === activeId || busy) return;
setActiveId(id);
setTurns([]);
setReview([]);
seededId.current = null;
};
// Returns the active conversation id, creating one if none is selected yet.
const ensureActive = async (): Promise<number | null> => {
if (activeId != null) return activeId;
const res = await apiFetch("/api/admin/ai/conversations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const b = await res.json();
if (!res.ok || !b?.data?.conversation) return null;
const id = b.data.conversation.id as number;
seededId.current = id;
setActiveId(id);
// Don't refresh the list yet — the conversation is still the default
// "Nová konverzace" here; persist() invalidates after the auto-title lands.
return id;
};
const canSubmit =
!convPending && !busy && (!!input.trim() || attachments.length > 0);
const submit = async () => {
if (busy) return;
const text = input.trim();
const files = attachments.map((a) => a.file);
if (!text && files.length === 0) return;
// Guard: Odin is scoped to invoice processing only (for now). A text-only
// message makes NO AI call — it gets a canned reply locally, so open-ended
// chat can't burn API credits. The invoice path (attachments) runs normally.
// (Removing this guard re-enables the general /chat path below for Phase 2.)
if (files.length === 0) {
setTurns((t) => [
...t,
{ role: "user", content: text },
{
role: "assistant",
content:
"Momentálně umím zpracovat pouze přijaté faktury. Přiložte prosím fakturu (PDF) a já z ní načtu údaje k uložení.",
},
]);
setInput("");
return;
}
setBusy(true);
const prevTurns = turns;
const userContent = [
text,
files.length > 0 ? `📎 ${files.map((f) => f.name).join(", ")}` : "",
]
.filter(Boolean)
.join("\n");
const optimistic: ChatTurn[] = [
...turns,
{ role: "user", content: userContent },
];
setTurns(optimistic);
try {
if (files.length > 0) {
const fd = new FormData();
files.forEach((f) => fd.append("files", f));
const res = await apiFetch("/api/admin/ai/extract-invoices", {
method: "POST",
body: fd,
});
const body = await res.json();
if (!res.ok) throw new Error(body?.error || "Chyba čtení faktur");
const reviews: ReviewInvoice[] = (
body.data.invoices as Array<{
file_name: string;
fields?: ExtractedInvoice;
error?: string;
}>
)
.map((r, i): ReviewInvoice | null => {
const f = r.fields;
return f
? {
uid: nextUid(),
supplier_name: f.supplier_name ?? "",
invoice_number: f.invoice_number ?? "",
amount: f.amount != null ? String(f.amount) : "",
currency: f.currency ?? "",
vat_rate: f.vat_rate != null ? String(f.vat_rate) : "",
issue_date: f.issue_date ?? "",
due_date: f.due_date ?? "",
description: f.description ?? "",
file_name: r.file_name,
file: files[i],
}
: null;
})
.filter((x): x is ReviewInvoice => x !== null);
setReview((prev) => [...prev, ...reviews]);
const note = summaryNote(reviews);
setTurns((t) => [...t, { role: "assistant", content: note }]);
setInput("");
setAttachments([]);
// Create the conversation now (first successful message) and persist.
const convId = await ensureActive();
if (convId != null)
persist(convId, [
{ role: "user", content: userContent },
{ role: "assistant", content: note },
]);
qc.invalidateQueries({ queryKey: ["ai", "usage"] });
} else {
let ctx = optimistic.slice(-MODEL_CONTEXT);
while (ctx.length && ctx[0].role !== "user") ctx = ctx.slice(1);
const res = await apiFetch("/api/admin/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: ctx }),
});
const body = await res.json();
if (!res.ok) throw new Error(body?.error || "Chyba AI");
const reply: string = body.data.reply;
setTurns((t) => [...t, { role: "assistant", content: reply }]);
setInput("");
// Create the conversation now (first successful message) and persist.
const convId = await ensureActive();
if (convId != null)
persist(convId, [
{ role: "user", content: userContent },
{ role: "assistant", content: reply },
]);
qc.invalidateQueries({ queryKey: ["ai", "usage"] });
}
} catch (e) {
setTurns(prevTurns);
const fallback = files.length > 0 ? "Chyba čtení faktur" : "Chyba AI";
alert.error(e instanceof Error ? e.message : fallback);
} finally {
setBusy(false);
}
};
const saveInvoice = async (inv: ReviewInvoice) => {
if (busy) return;
setBusy(true);
try {
const fd = new FormData();
fd.append("files", inv.file, inv.file_name);
fd.append(
"invoices",
JSON.stringify([
{
supplier_name: inv.supplier_name,
invoice_number: inv.invoice_number || undefined,
description: inv.description || undefined,
amount: inv.amount === "" ? undefined : Number(inv.amount),
currency: inv.currency,
vat_rate: inv.vat_rate === "" ? undefined : Number(inv.vat_rate),
issue_date: inv.issue_date || undefined,
due_date: inv.due_date || undefined,
},
]),
);
const res = await apiFetch("/api/admin/received-invoices", {
method: "POST",
body: fd,
});
const body = await res.json();
if (!res.ok) throw new Error(body?.error || "Uložení selhalo");
alert.success("Faktura uložena");
setReview((r) => r.filter((x) => x.uid !== inv.uid));
qc.invalidateQueries({ queryKey: ["invoices"] });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Uložení selhalo");
} finally {
setBusy(false);
}
};
// "Nová konverzace" just opens a fresh, empty composer — no DB row is created
// until the first message, so repeated clicks never spawn empty conversations.
const onNew = () => {
setSidebarOpen(false);
if (busy) return;
setActiveId(null);
setTurns([]);
setReview([]);
setInput("");
setAttachments([]);
seededId.current = null;
};
const onRename = async (id: number, title: string) => {
const res = await apiFetch(`/api/admin/ai/conversations/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (res.ok) qc.invalidateQueries({ queryKey: ["ai", "conversations"] });
else alert.error("Přejmenování selhalo");
};
const onDelete = async (id: number) => {
const res = await apiFetch(`/api/admin/ai/conversations/${id}`, {
method: "DELETE",
});
if (!res.ok) {
alert.error("Smazání selhalo");
return;
}
if (id === activeId) {
// Deleting the open conversation returns to a fresh new chat.
setActiveId(null);
setTurns([]);
setReview([]);
seededId.current = null;
}
qc.invalidateQueries({ queryKey: ["ai", "conversations"] });
};
const patch = (uid: string, field: EditableField, value: string) =>
setReview((r) =>
r.map((inv) => (inv.uid === uid ? { ...inv, [field]: value } : inv)),
);
const activeTitle =
conversations.find((c) => c.id === activeId)?.title ?? "Nová konverzace";
const sidebar = (
<OdinSidebar
conversations={conversations}
activeId={activeId}
busy={busy}
onSelect={onSelect}
onNew={onNew}
onRename={onRename}
onDelete={onDelete}
/>
);
return (
<Box
sx={{
height: "calc(100dvh - 100px)",
display: "flex",
border: 1,
borderColor: "divider",
borderRadius: 3,
bgcolor: "background.paper",
overflow: "hidden",
}}
>
{isMobile ? (
<Drawer
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
ModalProps={{ keepMounted: true }}
>
{sidebar}
</Drawer>
) : (
sidebar
)}
{/* Chat column */}
<Box
sx={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
gap: 1.5,
p: 2,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{isMobile && (
<IconButton
onClick={() => setSidebarOpen(true)}
aria-label="Konverzace"
size="small"
sx={{ ml: -0.5, flexShrink: 0 }}
>
<Box
component="svg"
viewBox="0 0 24 24"
sx={{ width: 22, height: 22 }}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</Box>
</IconButton>
)}
<Typography
variant="subtitle1"
noWrap
sx={{ fontWeight: 600, minWidth: 0, flex: 1 }}
>
{activeTitle}
</Typography>
{usage && (
<Typography
variant="caption"
color="text.secondary"
sx={{ flexShrink: 0, display: { xs: "none", sm: "block" } }}
>
Utraceno: ${usage.month_spend_usd.toFixed(2)} / $
{usage.budget_usd.toFixed(2)}
</Typography>
)}
</Box>
<OdinThread
turns={turns}
busy={busy}
loading={messagesLoading}
greetingPhrase={greetingPhrase}
userName={userName}
threadRef={threadRef}
/>
{review.length > 0 && (
<Box
sx={{
maxHeight: "35vh",
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
{review.map((inv) => (
<InvoiceReviewCard
key={inv.uid}
inv={inv}
busy={busy}
canSave={canSave}
onChange={patch}
onSave={saveInvoice}
onDiscard={(uid) =>
setReview((r) => r.filter((x) => x.uid !== uid))
}
/>
))}
</Box>
)}
<OdinComposer
input={input}
attachments={attachments}
busy={busy}
canSubmit={canSubmit}
fileRef={fileRef}
onInput={setInput}
onFiles={onFiles}
onRemoveAttachment={removeAttachment}
onSubmit={submit}
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,147 @@
import Box from "@mui/material/Box";
import Chip from "@mui/material/Chip";
import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField";
import type { StagedFile } from "./types";
interface OdinComposerProps {
input: string;
attachments: StagedFile[];
busy: boolean;
canSubmit: boolean;
fileRef: React.RefObject<HTMLInputElement | null>;
onInput: (v: string) => void;
onFiles: (files: FileList | null) => void;
onRemoveAttachment: (id: string) => void;
onSubmit: () => void;
}
export default function OdinComposer({
input,
attachments,
busy,
canSubmit,
fileRef,
onInput,
onFiles,
onRemoveAttachment,
onSubmit,
}: OdinComposerProps) {
return (
<Box>
{/* Staged attachment chips */}
{attachments.length > 0 && (
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mb: 1 }}>
{attachments.map((s) => (
<Chip
key={s.id}
label={s.file.name}
size="small"
variant="outlined"
onDelete={busy ? undefined : () => onRemoveAttachment(s.id)}
/>
))}
</Box>
)}
<input
ref={fileRef as React.RefObject<HTMLInputElement>}
type="file"
accept="application/pdf,image/*"
multiple
hidden
onChange={(e) => onFiles(e.target.files)}
/>
{/* Composer pill — full-width growing input with attach + send icons */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
border: 1,
borderColor: "divider",
borderRadius: 3.5,
bgcolor: "background.default",
pl: 0.75,
pr: 0.75,
py: 0.5,
transition: "border-color 0.15s ease",
"&:focus-within": { borderColor: "primary.main" },
}}
>
<IconButton
onClick={() => fileRef.current?.click()}
disabled={busy}
aria-label="Přiložit fakturu"
title="Přiložit fakturu (PDF)"
sx={{ flexShrink: 0, color: "text.secondary" }}
>
<Box
component="svg"
viewBox="0 0 24 24"
sx={{ width: 21, height: 21 }}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</Box>
</IconButton>
<TextField
multiline
maxRows={8}
variant="standard"
placeholder="Napište zprávu…"
value={input}
onChange={(e) => onInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
}}
fullWidth
slotProps={{ input: { disableUnderline: true } }}
sx={{ flex: 1, "& textarea": { py: 0.75, lineHeight: 1.5 } }}
/>
<IconButton
onClick={onSubmit}
disabled={!canSubmit}
aria-label="Odeslat"
title="Odeslat (Enter)"
sx={{
flexShrink: 0,
width: 36,
height: 36,
color: "common.white",
bgcolor: "primary.main",
"&:hover": { bgcolor: "primary.dark" },
"&.Mui-disabled": {
bgcolor: "action.disabledBackground",
color: "action.disabled",
},
}}
>
<Box
component="svg"
viewBox="0 0 24 24"
sx={{ width: 18, height: 18 }}
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</Box>
</IconButton>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,119 @@
import { keyframes, styled } from "@mui/material/styles";
import Box from "@mui/material/Box";
import { useReducedMotion } from "framer-motion";
/**
* Odin's animated brand mark: an AI "spark" (Odin) with a data node orbiting a
* ring — the assistant at the centre of the business system, in BOHA red. The
* spark slow-rotates and breathes; the node orbits. In the "thinking" state
* everything speeds up and the tile glows. Honours prefers-reduced-motion.
*
* The animations live in a `styled("svg")` (not inline style): Emotion only
* injects the @keyframes rule when the keyframes object is used inside
* sx/css/styled, and the child selectors keep the SVG elements plain/typed.
*/
const breathe = keyframes`
0%, 100% { transform: scale(0.9); opacity: 0.9; }
50% { transform: scale(1.08); opacity: 1; }
`;
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
// Reduce-motion safe: opacity only (no transforms / no travel).
const glow = keyframes`
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
`;
const Glyph = styled("svg", {
shouldForwardProp: (p) => p !== "thinking" && p !== "reduce",
})<{ thinking: boolean; reduce: boolean }>(({ thinking, reduce }) => ({
// !important so an ancestor `& svg { width }` rule (e.g. SidebarNav forces
// 18px) can't override the mark's own sizing — `size` stays authoritative.
width: "70% !important",
height: "70% !important",
overflow: "visible",
display: "block",
...(reduce
? {
// Reduce-motion: keep a calm opacity glow (no spin/orbit/travel). The
// `!important` + class specificity intentionally overrides the app-wide
// reduced-motion reset in GlobalStyles for this one small mark, since an
// opacity pulse is accessibility-safe (it isn't vestibular motion).
"& .odin-spark": {
animation: `${glow} ${thinking ? "1.1s" : "3s"} ease-in-out infinite !important`,
},
}
: {
"& .odin-orbit": {
transformBox: "view-box",
transformOrigin: "12px 12px",
animation: `${spin} ${thinking ? "1.6s" : "5.5s"} linear infinite`,
},
"& .odin-spin": {
transformBox: "view-box",
transformOrigin: "12px 12px",
animation: `${spin} ${thinking ? "4s" : "11s"} linear infinite`,
},
"& .odin-spark": {
transformBox: "fill-box",
transformOrigin: "center",
animation: `${breathe} ${thinking ? "1s" : "2.8s"} ease-in-out infinite`,
},
}),
}));
export interface OdinMarkProps {
size?: number;
state?: "idle" | "thinking";
}
export default function OdinMark({ size = 28, state = "idle" }: OdinMarkProps) {
const reduce = !!useReducedMotion();
const thinking = state === "thinking";
return (
<Box
sx={(t) => ({
width: size,
height: size,
borderRadius: "32%",
flexShrink: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
background: `linear-gradient(135deg, ${t.vars!.palette.primary.main}, ${t.vars!.palette.primary.dark})`,
boxShadow: thinking
? `0 0 ${Math.round(size * 0.5)}px 0 rgba(${t.vars!.palette.primary.mainChannel} / 0.55)`
: "none",
transition: "box-shadow 0.3s ease",
})}
>
<Glyph viewBox="0 0 24 24" thinking={thinking} reduce={reduce}>
{/* orbit ring */}
<circle
cx="12"
cy="12"
r="9.2"
fill="none"
stroke="#fff"
strokeOpacity="0.28"
strokeWidth="1"
/>
{/* orbiting data node */}
<g className="odin-orbit">
<circle cx="12" cy="2.8" r="1.4" fill="#fff" />
</g>
{/* spark — group slow-rotates, the star breathes about its own centre */}
<g className="odin-spin">
<path
className="odin-spark"
d="M12 4.5 C12.5 9, 13 9.5, 17.5 12 C13 14.5, 12.5 15, 12 19.5 C11.5 15, 11 14.5, 6.5 12 C11 9.5, 11.5 9, 12 4.5 Z"
fill="#fff"
/>
</g>
</Glyph>
</Box>
);
}

View File

@@ -0,0 +1,234 @@
import { useState } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton";
import { Button, TextField, Modal, ConfirmDialog } from "../../ui";
interface OdinSidebarProps {
conversations: { id: number; title: string }[];
activeId: number | null;
busy: boolean;
onSelect: (id: number) => void;
onNew: () => void;
onRename: (id: number, title: string) => void;
onDelete: (id: number) => void;
}
export default function OdinSidebar({
conversations,
activeId,
busy,
onSelect,
onNew,
onRename,
onDelete,
}: OdinSidebarProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [menuConv, setMenuConv] = useState<{
id: number;
title: string;
} | null>(null);
const [renameOpen, setRenameOpen] = useState(false);
const [renameDraft, setRenameDraft] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
const openMenu = (
e: React.MouseEvent<HTMLElement>,
conv: { id: number; title: string },
) => {
e.stopPropagation();
setMenuConv(conv);
setAnchorEl(e.currentTarget);
};
const closeMenu = () => setAnchorEl(null);
const handleRenameOpen = () => {
closeMenu();
setRenameDraft(menuConv?.title ?? "");
setRenameOpen(true);
};
const handleRenameSubmit = () => {
if (menuConv) onRename(menuConv.id, renameDraft.trim() || menuConv.title);
setRenameOpen(false);
};
const handleDeleteOpen = () => {
closeMenu();
setDeleteOpen(true);
};
const handleDeleteConfirm = () => {
if (menuConv) onDelete(menuConv.id);
setDeleteOpen(false);
};
return (
<Box
sx={{
width: { xs: 280, md: 264 },
flexShrink: 0,
height: "100%",
display: "flex",
flexDirection: "column",
borderRight: 1,
borderColor: "divider",
bgcolor: "action.hover",
}}
>
{/* Brand + New chat */}
<Box sx={{ p: 1.5, display: "flex", flexDirection: "column", gap: 1 }}>
<Box
component="span"
sx={(t) => ({
px: 0.5,
fontSize: "1.7rem",
fontWeight: 700,
lineHeight: 1.2,
letterSpacing: "-0.02em",
backgroundImage: `linear-gradient(120deg, ${t.vars!.palette.primary.light}, ${t.vars!.palette.primary.main})`,
WebkitBackgroundClip: "text",
backgroundClip: "text",
WebkitTextFillColor: "transparent",
})}
>
Odin
</Box>
<Button fullWidth disabled={busy} onClick={onNew}>
+ Nová konverzace
</Button>
</Box>
{/* Conversation list */}
<Box
sx={{
flex: 1,
minHeight: 0,
overflowY: "auto",
px: 1,
pb: 1,
display: "flex",
flexDirection: "column",
gap: 0.25,
}}
>
{conversations.length === 0 && (
<Typography
variant="caption"
color="text.secondary"
sx={{ px: 1, py: 1 }}
>
Zatím žádné konverzace.
</Typography>
)}
{conversations.map((conv) => {
const isActive = conv.id === activeId;
return (
<Box
key={conv.id}
role="button"
tabIndex={busy ? -1 : 0}
aria-pressed={isActive}
onClick={() => !busy && onSelect(conv.id)}
onKeyDown={(e) => {
// Activate the row on Enter/Space like a native button. The
// nested kebab IconButton stays a real <button> and handles its
// own keys (its onClick stopPropagation prevents row selection).
if (!busy && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onSelect(conv.id);
}
}}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 1,
py: 0.75,
borderRadius: 1.5,
cursor: busy ? "default" : "pointer",
bgcolor: isActive ? "action.selected" : "transparent",
"&:hover": {
bgcolor: isActive ? "action.selected" : "action.hover",
},
"&:hover .odin-conv-menu": { opacity: 1 },
"&:focus-visible .odin-conv-menu": { opacity: 1 },
}}
>
<Typography
variant="body2"
noWrap
title={conv.title}
sx={{
flex: 1,
minWidth: 0,
color: "text.primary",
fontWeight: isActive ? 600 : 400,
}}
>
{conv.title}
</Typography>
<IconButton
className="odin-conv-menu"
size="small"
disabled={busy}
onClick={(e) => openMenu(e, conv)}
aria-label="Možnosti konverzace"
title="Možnosti konverzace"
sx={{
flexShrink: 0,
opacity: { xs: 1, md: 0 },
transition: "opacity 0.15s",
}}
>
</IconButton>
</Box>
);
})}
</Box>
{/* Per-conversation menu */}
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu}>
<MenuItem onClick={handleRenameOpen}>Přejmenovat</MenuItem>
<MenuItem onClick={handleDeleteOpen}>Smazat</MenuItem>
</Menu>
{/* Rename modal */}
<Modal
isOpen={renameOpen}
onClose={() => setRenameOpen(false)}
onSubmit={handleRenameSubmit}
title="Přejmenovat konverzaci"
submitText="Přejmenovat"
maxWidth="xs"
>
<Box sx={{ pt: 1 }}>
<TextField
label="Název"
fullWidth
value={renameDraft}
onChange={(e) => setRenameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleRenameSubmit();
}
}}
autoFocus
/>
</Box>
</Modal>
{/* Delete confirm */}
<ConfirmDialog
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={handleDeleteConfirm}
title="Smazat konverzaci"
message={`Opravdu chcete smazat konverzaci „${menuConv?.title ?? ""}"? Tuto akci nelze vrátit.`}
confirmText="Smazat"
confirmVariant="danger"
/>
</Box>
);
}

View File

@@ -0,0 +1,238 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import type { ChatTurn } from "./types";
import OdinMark from "./OdinMark";
const MotionBox = motion.create(Box);
// Staggered hero entrance (skipped under prefers-reduced-motion).
const heroContainer: Variants = {
hidden: {},
show: { transition: { staggerChildren: 0.12, delayChildren: 0.06 } },
};
const heroItem: Variants = {
hidden: { opacity: 0, y: 14 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.55, ease: [0.16, 1, 0.3, 1] },
},
};
interface OdinThreadProps {
turns: ChatTurn[];
busy: boolean;
loading: boolean;
greetingPhrase?: string;
userName?: string;
threadRef: React.RefObject<HTMLDivElement | null>;
}
/** Small Odin avatar shown to the left of assistant bubbles. */
function OdinAvatar({ size = 28 }: { size?: number }) {
return (
<Box
sx={{
width: size,
height: size,
borderRadius: "50%",
bgcolor: "primary.main",
color: "common.white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontSize: size * 0.5,
flexShrink: 0,
}}
>
O
</Box>
);
}
export default function OdinThread({
turns,
busy,
loading,
greetingPhrase,
userName,
threadRef,
}: OdinThreadProps) {
const reduce = useReducedMotion();
const containerMotion = reduce
? {}
: { variants: heroContainer, initial: "hidden", animate: "show" };
const itemMotion = reduce ? {} : { variants: heroItem };
return (
<Box
ref={threadRef}
sx={{
flex: 1,
minHeight: 0,
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 1.5,
p: 2,
bgcolor: "action.hover",
borderRadius: 2,
}}
>
{/* Loading state — history is being fetched */}
{loading && (
<Box
sx={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Načítám
</Typography>
</Box>
)}
{/* Empty state */}
{turns.length === 0 && !busy && !loading && (
<MotionBox
{...containerMotion}
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1.75,
textAlign: "center",
px: 3,
}}
>
<MotionBox {...itemMotion}>
<Typography
component="h1"
sx={{
fontFamily: "'Newsreader', Georgia, 'Times New Roman', serif",
fontOpticalSizing: "auto",
fontWeight: 500,
fontSize: "clamp(2.2rem, 1.3rem + 2.6vw, 3.3rem)",
lineHeight: 1.05,
letterSpacing: "-0.01em",
color: "text.primary",
m: 0,
}}
>
{greetingPhrase || "Dobrý den"}
{userName && (
<>
,{" "}
<Box
component="span"
sx={(t) => ({
fontStyle: "italic",
backgroundImage: `linear-gradient(120deg, ${t.vars!.palette.primary.light}, ${t.vars!.palette.primary.main})`,
WebkitBackgroundClip: "text",
backgroundClip: "text",
WebkitTextFillColor: "transparent",
})}
>
{userName}
</Box>
</>
)}
</Typography>
</MotionBox>
<MotionBox {...itemMotion}>
<Typography
component="p"
sx={{
fontFamily: "'Newsreader', Georgia, serif",
fontStyle: "italic",
fontWeight: 400,
fontSize: "clamp(1.05rem, 0.95rem + 0.45vw, 1.3rem)",
lineHeight: 1.55,
color: "text.secondary",
maxWidth: 540,
m: 0,
}}
>
Zeptejte se na cokoli, nebo přiložte fakturu Odin ji přečte a
připraví k uložení.
</Typography>
</MotionBox>
</MotionBox>
)}
{/* Message bubbles */}
{turns.map((t, i) => {
const isUser = t.role === "user";
return (
<MotionBox
key={i}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reduce ? 0.3 : 0.34,
ease: [0.16, 1, 0.3, 1],
}}
sx={{
display: "flex",
flexDirection: "row",
alignItems: "flex-end",
gap: 1,
alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "80%",
}}
>
{!isUser && <OdinAvatar size={28} />}
<Box
sx={{
px: 1.5,
py: 1,
borderRadius: 2,
boxShadow: 1,
bgcolor: isUser ? "primary.main" : "background.paper",
}}
>
{/* Color MUST sit on the Typography: GlobalStyles pins `p` to
text.secondary, which beats a color merely inherited from the
Box. An sx class on the element wins over that element rule. */}
<Typography
variant="body2"
sx={{
whiteSpace: "pre-wrap",
color: isUser ? "common.white" : "text.primary",
}}
>
{t.content}
</Typography>
</Box>
</MotionBox>
);
})}
{/* Busy indicator */}
{busy && (
<Box
sx={{
alignSelf: "flex-start",
display: "flex",
alignItems: "center",
gap: 1,
px: 1.5,
py: 1,
color: "text.secondary",
}}
>
<OdinMark size={22} state="thinking" />
<Typography variant="caption" sx={{ color: "inherit" }}>
Pracuji
</Typography>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,33 @@
export interface ChatTurn {
role: "user" | "assistant";
content: string;
}
export interface ReviewInvoice {
uid: string;
supplier_name: string;
invoice_number: string;
amount: string;
currency: string;
vat_rate: string;
issue_date: string;
due_date: string;
description: string;
file_name: string;
file: File;
}
export type EditableField =
| "supplier_name"
| "invoice_number"
| "amount"
| "currency"
| "vat_rate"
| "issue_date"
| "due_date"
| "description";
export interface StagedFile {
id: string;
file: File;
}

View File

@@ -5,6 +5,7 @@ import TextField from "@mui/material/TextField";
import CircularProgress from "@mui/material/CircularProgress";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import useDebounce from "../../hooks/useDebounce";
import {
warehouseItemListOptions,
type WarehouseItem,
@@ -26,9 +27,11 @@ export default function ItemPicker({
itemName,
}: ItemPickerProps) {
const [inputValue, setInputValue] = useState(itemName ?? "");
// Debounce the search so the list query doesn't fire on every keystroke.
const debouncedSearch = useDebounce(inputValue, 300);
const { data, isFetching } = useQuery(
warehouseItemListOptions({ search: inputValue, perPage: 20 }),
warehouseItemListOptions({ search: debouncedSearch, perPage: 20 }),
);
const options: ItemOption[] = data?.data ?? [];

View File

@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import MenuItem from "@mui/material/MenuItem";
import { Select } from "../../ui";
@@ -16,7 +17,7 @@ export default function ReservationPicker({
value,
onChange,
}: ReservationPickerProps) {
const { data: result } = useQuery(
const { data: result, isFetching } = useQuery(
warehouseReservationListOptions({
item_id: itemId ?? undefined,
project_id: projectId ?? undefined,
@@ -27,6 +28,22 @@ export default function ReservationPicker({
const reservations = result?.data ?? [];
// When item/project change, the loaded reservation list changes. If the
// currently-selected reservation is no longer available, clear it and notify
// the parent so it doesn't keep holding a stale reservationId. Only act once
// the query has settled (result present, not fetching) to avoid resetting
// while the new list is still loading.
useEffect(() => {
if (
value != null &&
result !== undefined &&
!isFetching &&
!reservations.some((r) => r.id === value)
) {
onChange(null, 0);
}
}, [value, result, isFetching, reservations, onChange]);
return (
<Select
value={value == null ? "" : String(value)}
@@ -38,7 +55,7 @@ export default function ReservationPicker({
const reservationId = Number(val);
const reservation = reservations.find((r) => r.id === reservationId);
if (reservation) {
onChange(reservationId, Number(reservation.remaining_qty));
onChange(reservationId, reservation.remaining_qty);
}
}}
>
@@ -46,7 +63,7 @@ export default function ReservationPicker({
{reservations.map((r) => (
<MenuItem key={r.id} value={String(r.id)}>
R{r.id} {r.project?.name ?? `Projekt #${r.project_id}`} zbývá:{" "}
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
{r.remaining_qty} {r.item?.unit ?? "ks"}
</MenuItem>
))}
</Select>

View File

@@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react";
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
import { queryClient } from "../lib/queryClient";
const API_BASE = "/api/admin";
@@ -122,7 +123,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
},
[],
); // eslint-disable-line react-hooks/exhaustive-deps
);
const silentRefresh = useCallback(async (): Promise<boolean> => {
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
@@ -232,6 +233,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
remember,
};
}
// Query keys are user-agnostic (["dashboard"], ["attendance"], …),
// so anything cached from a previously logged-in user would be
// served to this one — drop the whole cache on every login.
queryClient.clear();
setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user);
@@ -260,19 +265,37 @@ export function AuthProvider({ children }: { children: ReactNode }) {
) => {
setError(null);
try {
const response = await fetch(`${API_BASE}/login/totp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
login_token: loginToken,
totp_code: code,
remember_me: remember,
isBackup,
}),
});
// Backup codes have their own endpoint + schema (8-char codes would
// fail /login/totp's strict 6-digit TotpVerifySchema). Both endpoints
// complete the same login flow: tokens issued + refresh cookie set.
const response = await fetch(
isBackup
? `${API_BASE}/totp/backup-verify`
: `${API_BASE}/login/totp`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(
isBackup
? {
login_token: loginToken,
backup_code: code,
remember_me: remember,
}
: {
login_token: loginToken,
totp_code: code,
remember_me: remember,
},
),
},
);
const data = await response.json();
if (data.success) {
// Same reason as in login(): the cache may hold the previous
// user's data under user-agnostic keys.
queryClient.clear();
setAccessTokenFn(data.data.access_token, data.data.expires_in);
setUser(mapUser(data.data.user));
cachedUserRef.current = mapUser(data.data.user);
@@ -312,9 +335,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
clearTimeout(refreshTimeoutRef.current);
refreshTimeoutRef.current = null;
}
// The React Query cache outlives the session — without this, the next
// user to log in on this browser sees the previous user's cached
// dashboards/lists until each query refetches.
queryClient.clear();
}
}, [getAccessTokenFn]);
// NOTE: this is a SECOND 401-refresh-and-retry path that parallels
// `apiFetch` in src/admin/utils/api.ts (which most pages use via the query
// helpers). The two diverge: `apiRequest` ignores AbortSignals and does NOT
// set the session-expired flag, whereas `apiFetch` dedupes concurrent
// refreshes, honors abort, and surfaces the 499 session-expired sentinel.
// Consolidating onto `apiFetch` is a tracked follow-up but is deferred here
// because it touches the core auth/refresh flow (high regression risk). If
// you change refresh/retry semantics in one, mirror it in the other.
const apiRequest = useCallback(
async (endpoint: string, options: RequestInit = {}) => {
let token = getAccessTokenFn();

View File

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

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