Compare commits

..

79 Commits

Author SHA1 Message Date
BOHA
719d1f2659 feat(documents): per-item Sleva (% discount) on offers + invoices
Adds an optional per-line-item percentage discount ("Sleva") to offers and
issued invoices, on both the detail-page editor and the PDF.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:37:44 +02:00
BOHA
1f2aff8325 chore(release): v2.4.31 - full audit fix pass
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:00:27 +02:00
BOHA
e11765bf0e fix: resolve all 28 findings from the 2026-06-12 full audit (TDD-pinned)
Critical (data integrity):
- warehouse inventory confirm: throw (not return) inside $transaction so a
  failed deficit line rolls back the surplus corrective receipt — retries
  no longer accumulate phantom stock
- warehouse issue confirm: validate batches against the COMBINED quantity
  of all lines (duplicate FIFO-resolved lines drove batches negative)
- attendance delete: restore vacation_used/sick_used for the deleted day
  (in-transaction, clamped at 0)

High:
- auth refresh: terminated sessions (replaced_at only) get a plain 401 —
  the theft branch (family revocation) now fires only on replaced_by_hash
- POST /users strips role_id for non-admin callers (mirrors PUT guard)
- issued-order transition flushes unsaved edits via the full save payload
  when dirty; server contract (items+status in one PUT) pinned
- received-invoices list: usePaginatedQuery + pager (rows 26+ unreachable)
- received-invoice dates: nullableIsoDateString + NaN guard before NAS save
  (Czech-format dates corrupted month/year, orphaned NAS files)
- leave approval skips Czech public holidays and books each calendar year's
  hours against its own balance (mirrors createLeave)

Medium/Low (classes):
- 52 Zod caps aligned to DB column widths across 7 schemas (over-cap input
  500ed at Prisma instead of a Czech 400)
- FK pre-validation: projects update + warehouse receipts/issues return
  Czech 400s instead of P2003 500s
- invoice PDF degrades gracefully when the CNB rate is unavailable
  (recap omitted instead of 500 + lost NAS archival)
- date boundaries: local-day filters (warehouse lists/reports, audit-log),
  @db.Date coercion on invoice dates
- plan updateEntry re-checks the per-cell cap (self-excluding)
- {id} tiebreaks on customers/received-invoices/warehouse-items sorts;
  /items honors the client sort param
- htmlToPdf relaunches once when the shared browser died mid-render
- offer number release parses the year from the document number (cross-year
  finalize+delete left permanent sequence gaps)
- trips/vehicles km fields integer-coerced; AI budget regated to
  settings.company|settings.system; Settings System tab no longer clobbers
  Firma numbering patterns; draft invoices hide the dead PDF button;
  dashboard quick-trip invalidates ["vehicles"]; TOTP secret cap 64;
  audit-log + invoice month buckets day-shift fixes

Docs: corrected the stale "Chromium has no CSS margin-box footers" claim
(html-to-pdf.ts + CLAUDE.md — margin boxes render since Chrome 131); audit
report M3 withdrawn accordingly.

~65 new pinning tests; every finding reproduced RED against the real test
DB before its fix. Suite: 58 files / 634 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:00:19 +02:00
BOHA
8f74efba97 chore(release): v2.4.30 - unified tab animations
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:04:39 +02:00
BOHA
b9103c59f0 feat(ui): unified tab-switch animation - enter-only fade in TabPanel
One 0.2s enter-only fade (opacity + 6px rise) in the kit TabPanel covers
every tab switch (M3 clean-fade pattern; CSS animation so the global
reduced-motion kill switch applies). Orders and Invoices view switchers
now render through TabPanel (also fixes their dangling aria-controls).
Removed the inconsistent bespoke framer entrances inside tab content
(CompanySettings cascade, ReceivedInvoices card). Filter-style status
tabs (Projects/Offers/Invoices) deliberately keep the refetch dim only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:04:32 +02:00
BOHA
bf33d0a543 chore(release): v2.4.29 - editable project notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:26:38 +02:00
BOHA
64bb7f9c37 feat(projects): editable notes with edit stamp + NoteCard bubble component
- author-only note editing (PUT /projects/:id/notes/:noteId, strict schema,
  audit with old/new content); edits stamped via new edited_at column
  (migration 20260612160000) and shown as '· upraveno <datum>'
- note deletion tightened to admin-only server-side (was any projects.edit
  holder via direct API)
- new NoteCard component: chat-bubble note row (initials avatar, wrapping
  header, inline editor, actions in the bubble corner so they don't squeeze
  content on mobile), table-matching edit/delete icons
- Odin project detail carries the edited_at stamp; route-level tests cover
  author-edit, foreign-edit 403, validation, admin-only delete

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:26:38 +02:00
BOHA
6f3f1c40e7 fix(ui): zmeneny text - text - v select boxu 2026-06-12 14:56:52 +02:00
BOHA
164ce54adf chore(release): v2.4.28 - Odin expense totals
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:34:51 +02:00
BOHA
6877919133 feat(odin): received-invoices expense totals with CZK conversion
get_document_totals gains type=received_invoices: whole-set gross sums per
currency + total_czk via the same toCzk logic as the stats route (unknown
currencies skipped + reported), month/year via the literal columns (bare
year supported), unpaid/paid filter, period echo. invoices.view joined the
coarse gate (the per-type re-check keeps other families locked); the tool
description routes 'kolik jsme utratili' questions here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:34:51 +02:00
BOHA
5ec70599aa chore(release): v2.4.27 - rich-text blank lines
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:35:46 +02:00
BOHA
7e4469b29a fix(richtext): blank lines survive into PDFs and read-only previews
Quill 2's semantic HTML saves a blank line as a truly empty <p></p> (no
<br> placeholder) — verified in the DB (invoice_sections hold <p></p>,
zero <p><br>) — which collapses to nothing outside the editor.
cleanQuillHtml (PDF path) and the new restoreQuillBlankLines (order/
invoice read-only previews) restore the placeholder at render time, so
existing stored content is covered retroactively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:35:46 +02:00
BOHA
08af4cd0ae chore(release): v2.4.26 - PDF font unification + rich-text colors
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:27:47 +02:00
BOHA
eb279a87a9 fix(pdf): Segoe UI everywhere + rich-text colors survive sanitization
- section/Quill overrides, footers and the shared header now lead with
  'Segoe UI' (installed on prod) instead of Tahoma (not installed — prod
  silently rendered sections and footers in Noto Sans, mismatching the
  Segoe UI body; dev previews showed real Tahoma and never matched prod)
- cleanQuillHtml no longer deletes ALL style attributes: color and
  background-color survive with strictly validated literal values (hex,
  rgb/rgba, keyword) so editor font colors finally reach the PDF; url(),
  expression() and every other declaration are still stripped — new test
  file pins both the kept colors and the attack vectors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:27:47 +02:00
BOHA
ea3b595b32 chore(release): v2.4.25 - autosize item descriptions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:16:43 +02:00
BOHA
e9b432bec6 feat(documents): autosize item descriptions (minRows 2, maxRows 16)
The 'Podrobny popis' textareas in the shared items editor and the invoice
page now grow with content like the rich-text editor (fixed rows={2} ->
minRows/maxRows autosize), capped at 16 rows; the manual vertical resize
handle stays.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:16:43 +02:00
BOHA
741c8109fa chore(release): v2.4.24 - Odin lookup normalization + aggregations
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:07:58 +02:00
BOHA
07d9c8d9f7 feat(odin): forgiving lookups + per-customer aggregation + count awareness
- SPZ matching normalized on both sides (4SY7039 finds '4SY 7039') in
  list_vehicles and the list_trips vehicle filter; JS-side name matching
  diacritic-folded to keep utf8mb4_unicode_ci parity; ICO typed with spaces
  matches the space-less stored value (suppliers + customers)
- get_top_customers: whole-table per-customer counts of invoices (sans
  drafts) / orders / projects, top 20 + customers_with_records, optional
  year, per-type permission re-checks — answers 'pro koho pracujeme nejvic'
  exactly instead of refusing
- find_employee, find_supplier and stock search now return total_matching
  (stock search also gained its missing deterministic orderBy); system
  prompt teaches the model to answer counts from total_matching and use
  aggregation tools instead of summing 20-row list pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:07:58 +02:00
BOHA
0928b3636e chore(release): v2.4.23 - Odin HR/fleet/totals tools
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:27:35 +02:00
BOHA
738f852168 feat(odin): HR, suppliers, vehicles, document-totals and received-invoice tools
Seven read-only tools: leave balance (own/attendance.balances parity), work
fund + overtime (getWorkfund), project hours report (attendance.manage),
supplier lookup (inactive only for warehouse.manage — picker-route parity),
vehicles with the route's odometer rule, whole-set document totals per
currency (fixes the sum-of-20-list-rows trap; bare month defaults the year
and the applied period is echoed — adversarial review caught the silent
all-time-as-monthly figure), and received-invoice detail with match count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:27:34 +02:00
BOHA
aa7b97689b chore(release): v2.4.22 - Odin document detail tools
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:56:25 +02:00
BOHA
5cd5a9e37d feat(odin): document detail tools — line items for offers, orders, invoices, projects
Five read-only detail tools (offer, order, issued order, invoice, project)
looked up by document number (partial match). They call the same getX(id)
services the detail routes use under the same .view permissions (parity
verified route-by-route), and return compact payloads: line items capped at
50 rows with full totals, is_included_in_total respected, rich text
stripped to plain excerpts, invoice VAT broken down per the editor rule
(per-item rate + apply_vat).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:56:25 +02:00
BOHA
27b734f6d0 chore(release): v2.4.21 - kill PTR on immersive Odin
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:37:55 +02:00
BOHA
91072548d4 fix(odin): disable pull-to-refresh on the immersive mobile page
A PTR reload lands while the standalone-PWA viewport is still settling and
re-races the --app-height measurement (Chromium settles without firing
resize), bringing the scroll room back ~50% of the time. The immersive page
is fixed-viewport, so overscroll-behavior-y: none on html+body removes the
gesture (and the race trigger) entirely; restored on route leave.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:37:55 +02:00
BOHA
5b380863cf chore(release): v2.4.20 - standalone PWA viewport fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:48:57 +02:00
BOHA
1293da7f25 fix(odin): measured --app-height for standalone-PWA viewports
Installed PWAs (MIUI especially) compute svh/dvh against a viewport that
includes system UI the layout viewport doesn't have, so the immersive page
still scrolled in standalone mode. AppShell now measures window.innerHeight
into --app-height (refreshed on resize/visualViewport, covering the
minimize-restore stale-viewport Chrome bug and the keyboard), and the
immersive shell + chat size from var(--app-height, 100svh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:48:57 +02:00
BOHA
d9cf8f53e8 chore(release): v2.4.19 - immersive mobile scroll lock
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:42:04 +02:00
BOHA
f509e24942 fix(odin): kill body scroll on immersive mobile (MIUI dvh quirk)
Xiaomi Chrome keeps 100dvh at the large-viewport size while the URL bar
overlays the page, so the shell's 100dvh min-height left the document one
bar-height taller than the screen (scrollable + a strip under the
composer). The immersive route now sizes the document to exactly 100svh
with hidden overflow down the chain — body scroll is impossible regardless
of the browser's dvh interpretation. Other routes unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:42:04 +02:00
BOHA
4159ae57a4 chore(release): v2.4.18 - Odin animations + mobile fullscreen
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:31:28 +02:00
BOHA
4c8ed39d85 feat(odin): chat animations + immersive fullscreen mobile view
- bubbles spring in anchored at their avatar corner; thinking state is a
  typing bubble (three bouncing dots) that springs in/out where the reply
  lands; reduced-motion falls back to opacity fades
- mobile /odin is immersive: AppShell hides its header and main padding
  below md, the chat owns 100svh full-bleed with safe-area insets and a
  back arrow (history back, home on deep links)
- desktop card border kept divider-colored via longhand border props (a
  responsive border shorthand reset the color to black)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:31:28 +02:00
BOHA
06519d521f chore(release): v2.4.17 - Odin avatar polish
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:20:15 +02:00
BOHA
3c6d175857 fix(odin): brand mark as bubble avatar + aligned thinking indicator
Assistant bubbles use the animated OdinMark instead of the plain 'O'
circle; the 'Pracuji…' row drops its horizontal padding and matches the
28px mark size, so the thinking mark sits exactly in the avatar column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:20:15 +02:00
BOHA
7582cf88f3 chore(release): v2.4.16 - Odin explicit permission denials
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:07:52 +02:00
BOHA
4deabbfcc0 feat(odin): say 'no permission' instead of 'missing feature' for denied areas
The system prompt now lists the data areas the caller's permissions filtered
out, so the model tells the user explicitly they lack rights to that module
(and can ask the admin) instead of guessing the feature does not exist or
pointing them at a module they cannot open. Tool-less users get the same
explicit phrasing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:07:52 +02:00
BOHA
302a0f8b3f chore(release): v2.4.15 - Odin tool-chip dedupe
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:20:44 +02:00
BOHA
78f39e9f82 fix(odin): dedupe tool-trace chips — one per tool, warning only if all attempts failed
A failed call followed by a successful retry showed two identical chips
(one orange). Retries are loop mechanics: the trace now carries one entry
per tool with ok = delivered-at-least-once, for live chips and persisted
history alike (also keeps meta.tools under its 30-entry cap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:20:43 +02:00
BOHA
949bf6c86f chore(release): v2.4.14 - Odin caller identity
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:16:06 +02:00
BOHA
c8fc662552 chore(release): v2.4.13 - Odin composer + selection fixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:11:32 +02:00
BOHA
10c8454c9b fix(odin): clear composer on send + readable selection in user bubble
- the sent message now leaves the input the moment the optimistic bubble
  appears (restored along with attachments if the request fails)
- text selected in the primary-filled user bubble inverts to
  white-on-primary instead of vanishing into the global ::selection style

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:11:31 +02:00
BOHA
9496944ad2 chore(release): v2.4.12 - Odin people tools
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:03:39 +02:00
BOHA
608ebb02bd feat(odin): people tools - employee lookup, attendance hours, trips, work plan
- find_employee: name -> user_id with route-parity population scoping
  (attendance perms see plan users, trips perms see drivers sans usernames,
  full directory only with users.view; trips.history unlocks nothing)
- get_attendance_summary: day-range detail with worked hours (shared
  calcWorkedHours, break subtracted) + totals; attendance.manage also
  unlocks the tool
- list_trips: kniha jizd with route-identical non-manager self-scoping and
  the stats-endpoint km coalesce; bare month defaults to current year
- get_work_plan: resolved plan grid rows; off-grid users need
  attendance.manage (grid-route parity)
- executeTool flags handler {error} results not-ok (consistent denial chips)
- system prompt: resolve employee names via find_employee first (gated on
  the tool being present)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 00:03:39 +02:00
BOHA
3a3485d029 chore(release): v2.4.11 - Odin Phase 2a (read-only agentic tools)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:31:58 +02:00
BOHA
674b44d047 feat(odin): Phase 2a read-only agentic assistant + tool-trace UI
- agentic chat loop (max 6 round-trips, budget re-checked between iterations)
  over 10 read-only, permission-delegated tools in src/services/ai-tools.ts
- persist assistant tool trace in ai_chat_messages.content_json (+ migration)
- consulted-tools chips in OdinThread; header now shows month spend only
  (budget cap hidden from the UI, server-side 402 guard unchanged)
- seed: ai.use permission; Settings exposes the ai permission module
- docs: REVIEW consolidation; deployment-guide.md superseded by docs/release.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:31:50 +02:00
BOHA
2dbacc3bec chore(release): v2.4.10 - mobile UX fixes (plan grid, Odin, toolbar)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:54:36 +02:00
BOHA
c21f02e5d1 fix(mobile): plan landscape collapse + person picker, Odin page scroll, plan toolbar stacking
- PlanGrid maxHeight gets a 360px floor: calc(100dvh - 240px) left
  <160px in phone landscape and the rows vanished under the sticky
  header
- Phones get a person picker above the plan grid (one column at a
  time, defaults to the logged-in user) - the multi-person grid never
  fit a portrait viewport; desktop unchanged
- Odin chat height uses 100svh instead of 100dvh: dvh grows live as
  Android Chrome collapses its address bar, so the page always allowed
  a chrome-height scroll; svh sizes for the bar-visible viewport
  (verified zero overflow at 390x844 via Playwright)
- Plan toolbar stacks into full-width rows on xs (headerActionsSx
  pattern from the detail pages); Dnes/arrows and Tyden/Mesic stay
  paired on one row each

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:53:52 +02:00
BOHA
f87e110359 chore(release): v2.4.9 - cache-header hotfix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:34:37 +02:00
BOHA
f1b1329c1b fix(spa): cacheControl:false so custom cache headers actually apply
@fastify/static's default Cache-Control management overwrote the
setHeaders values with 'public, max-age=0' (verified on prod after the
v2.4.8 deploy) - the README requires disabling it for custom headers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:34:31 +02:00
BOHA
88d5d43448 chore(release): v2.4.8 - deploy-skew self-healing (stale chunk auto-reload + cache headers)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:32:49 +02:00
BOHA
4745af3639 fix(spa): self-heal stale clients after deploys (vite:preloadError + cache headers)
After a release the old hashed chunks are deleted, so an open tab from
the previous build failed its next lazy-page import until a manual
refresh. Three-part fix per the Vite deploy guide:

- vite:preloadError listener reloads the page once (10s sessionStorage
  guard against loops) so stale tabs heal themselves
- static cache headers: /assets/* immutable for a year (content-hashed
  names), index.html no-cache so new visits always see fresh chunk refs
- missing /assets/* now 404 instead of getting the SPA index.html
  fallback (HTML-as-JS masked the failure)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:31:59 +02:00
BOHA
4258699b73 chore(release): v2.4.7 - mobile detail-header overflow fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:22:24 +02:00
BOHA
bfd2c59ad3 fix(ui): detail-form headers no longer overflow on mobile
Two causes: theme h4 had no responsive size (desktop 2.125rem on
phones - now 1.5rem under 600px, applies to all page headlines), and
the Zpet+title header row had no flexWrap so long document titles
pushed past the viewport (issued orders, offers, both invoice views).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Invoices and received invoices keep their VAT handling unchanged.

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:40:06 +02:00
203 changed files with 32652 additions and 10203 deletions

334
AUDIT-2026-06-12.md Normal file
View File

@@ -0,0 +1,334 @@
# Codebase Audit — boha-app-ts — 2026-06-12
## Summary
| Severity | Count |
| ------------ | ----: |
| **Critical** | 3 |
| **High** | 6 |
| **Medium** | 11 |
| **Low** | 8 |
| **Total** | 28 |
> 2026-06-12: M3 (offer PDF footer) withdrawn as a false positive after user
> verification — see the struck section below. Counts updated 12→11 / 29→28.
## Methodology
- **Fan-out discovery:** 27 independent finder passes swept the codebase across domain modules (auth/sessions, attendance/leave, documents — offers/orders/issued-orders/invoices, warehouse, projects, trips/vehicles, plan, users/roles, AI/Odin, settings) and cross-cutting dimensions (Zod-cap-vs-DB-column alignment, date/timezone boundaries, pagination determinism, React Query cache invalidation, transaction atomicity, FK-error→4xx mapping, privilege boundaries).
- **Dedupe vs prior backlog:** candidate findings were de-duplicated against the existing audit backlog (`docs/codebase-audit-findings-2026-06-06.md`, `docs/cross-module-consistency-audit-2026-06-09.md`, `REVIEW_FINDINGS.md`). Already-fixed items (e.g. the 2026-06-06 sessions audit-logging finding) and already-tracked duplicates were dropped; only net-new defects survived.
- **3-lens adversarial verification:** every surviving finding was independently re-checked through three lenses — (1) **trace-to-refute** (follow the full code path end-to-end and try to break each step), (2) **convention veto** (confirm it genuinely VIOLATES a documented `CLAUDE.md` convention and is NOT one of the deliberate "looks-like-a-bug" decisions: local-time `toJSON`, local-noon `@db.Date` writes, lenient date schemas, net-only documents, gross `received_invoices.amount`, shared `orders.*` permission family), and (3) **executable repro** (a concrete, developer-runnable reproduction with observable wrong output). A finding ships only with three independent `real` verdicts. Findings the convention lens vetoed (e.g. one warehouse over-issue variant whose exact numbers are blocked by an upstream aggregate guard — kept only after a corrected, reachable repro was supplied) were re-scoped, not waved through.
Order below is by severity, then by blast radius within a severity.
---
## CRITICAL
### C1. Deleting a vacation/sick attendance record never restores the leave balance — `vacation_used` stays permanently inflated
- **Severity:** Critical
- **File:** `src/services/attendance.service.ts:1751`
- **Failure scenario:** `createLeave` increments `leave_balances.vacation_used`/`sick_used` when booking leave (lines 13041331), but `deleteAttendance` (17511772) only runs `prisma.attendance.delete` — it never decrements the balance. `getStatus` (line 246) and `getBalances` (line 530) derive `vacation_remaining = vacation_total - vacation_used` from the stored counter, so cancelling a booked leave by deleting its rows permanently removes entitlement the employee never actually took. The leave-request approval path (`leave-requests.ts`) has the identical asymmetric increment-on-approve / no-decrement-on-delete.
- **Repro:**
1. As an admin (`attendance.manage`), `POST /api/admin/attendance?action=leave` for user 5, `date_from=2026-07-06`, `date_to=2026-07-10`, `leave_type=vacation`, `leave_hours=8` → creates 5 rows and adds 40h to `vacation_used`.
2. Read balances: `vacation_used=40`, `vacation_remaining = total-40`.
3. `DELETE /api/admin/attendance/:id` for each of the 5 rows.
4. Read balances again: rows are gone but `vacation_used` is still 40 and `vacation_remaining` is still down 40h. Only a manual admin `handleBalances` reset fixes it.
- **Pinning test:** Book a 5-day vacation, assert `vacation_used` rose by 40h, delete all 5 attendance rows, then assert `vacation_used` returned to its pre-booking value (fails today: stays 40).
### C2. `confirmIssue`: duplicate issue lines on the same batch pass per-line validation but decrement cumulatively, driving batch quantity negative (silent over-issue)
- **Severity:** Critical
- **File:** `src/services/warehouse.service.ts:626`
- **Failure scenario:** The confirm path validates and decrements stock **per line**, re-reading the batch fresh each iteration with no cross-line running tally and no `if (newBatchQty < 0) throw` guard. Two issue lines for the same item that resolve to the same batch each pass the independent `batch.quantity >= line.quantity` check, then the decrement loop accumulates: line A leaves the batch at 3, line B re-reads 3 and writes `3-5 = -2`, flagging `is_consumed=true`. The negative/consumed batch drops out of `getItemTotalStock` (`is_consumed:false` filter), so the over-issue is silently hidden from all stock totals. No `(issue_id, batch_id)` unique constraint exists on `sklad_issue_lines`.
- **Repro (corrected — the single-batch case is blocked by the per-item aggregate guard in `validateIssueReservationRules`; use two batches so the per-item total check passes while one batch still goes negative):**
1. Seed item X with two unconsumed batches B1(qty 8, older) and B2(qty 8). Total stock = 16.
2. `POST /issues` with two lines, both `item_id=X`, no `batch_id`, each `quantity=5`. `selectFifoBatches` is called once per line against the live DB with nothing persisted, so both resolve to B1; the per-item availability check (16 < 10 is false) passes. Draft stores two lines both `batch_id=B1`.
3. `POST /issues/:id/confirm` → 200. B1 validated 8≥5 twice (both see original 8), then decremented to 3, then to `-2` with `is_consumed=true`.
4. 10 units issued from a batch that held 8; B1 persists at `quantity=-2`, drops from stock totals — 2 units leave with no accounting trail and FIFO is broken, with no error raised.
- **Pinning test:** Confirm an issue containing two same-item lines whose combined quantity exceeds a single resolved batch; assert the confirm is rejected (400) and no batch row is left with `quantity < 0`.
### C3. `confirmInventorySession` swallows an in-transaction throw with `return` instead of `throw`, committing partial stock writes the route reports as failed
- **Severity:** Critical
- **File:** `src/services/warehouse.service.ts:1275`
- **Failure scenario:** The function body is `return prisma.$transaction(async (tx) => { ... })` (line 1108). A single loop over `session.items` interleaves real writes with a throwing op: the surplus branch (diff>0) creates a corrective receipt, receipt line, batch, and upserts `sklad_item_locations`; a later deficit line makes `selectFifoBatches` throw. The catch at line 1275 **returns** `{ error, status: 400 }` from inside the transaction callback. In an interactive Prisma transaction, returning a value (rather than throwing) **commits** — so the surplus item's writes persist while the session→CONFIRMED update (line 1293) is skipped, leaving the session DRAFT. The route sees `"error" in result` and returns HTTP 400 ("nothing changed"). Re-confirming the still-DRAFT session re-runs the loop and re-creates the same surplus batch — repeatable phantom inventory and corrupted valuation on every retry.
- **Repro:**
1. ItemA exists; ItemB has a location row `quantity=10` but fewer unconsumed batches (documented location/batch drift at lines 12441250 — also reproducible by consuming batches between session create and confirm).
2. `POST /admin/inventory-sessions` with items ordered `[{ItemA surplus, actual_qty 5}, {ItemB deficit, actual_qty 0}]` (ItemA's line gets the lower id, processed first).
3. `POST /admin/inventory-sessions/<id>/confirm` → HTTP 400 "Nedostatečné množství na skladě", BUT ItemA now has a permanent +5 corrective batch + bumped `item_location`, and the session is still DRAFT.
4. Re-confirm → another +5 for ItemA, fails on ItemB again. Each retry adds +5 phantom stock while the API reports failure.
- **Pinning test:** Confirm an inventory session whose item list has a valid surplus line before an over-deficit line; assert the response is an error AND no `sklad_receipts`/`sklad_batches` rows were created (transaction rolled back) and the session is unchanged.
---
## HIGH
### H1. Terminating one session force-logs-out ALL of the user's sessions on the terminated device's next refresh
- **Severity:** High
- **File:** `src/services/auth.ts:280`
- **Failure scenario:** Session termination (`sessions.ts:94-97` DELETE `/:id`, `sessions.ts:124-131` action=all, `profile.ts:98-101` password-change) sets ONLY `replaced_at` (no `replaced_by_hash`, row not deleted). Legitimate rotation (`auth.ts:315-318`) sets BOTH. The reuse-detection condition at `auth.ts:280-282` is `storedToken.replaced_at || storedToken.replaced_by_hash`, evaluated BEFORE the expiry check at line 292 — so a manually-terminated token presented on a later refresh is misclassified as theft and runs `tx.refresh_tokens.deleteMany({ where: { user_id } })` (line 285), wiping every session for that user, including the laptop the user is actively on, plus a false "reuse detected" warning.
- **Repro:**
1. Log in from two devices → Session A (laptop), Session B (phone), both `replaced_at=null`.
2. From the laptop, `DELETE /api/admin/sessions/<B.id>` → B gets `replaced_at` set only.
3. After B's access token expires (~15 min), the phone calls `POST /api/admin/refresh` with B's still-present cookie.
4. `refreshAccessToken` hits the reuse branch (B's `replaced_at` truthy), deletes ALL of the user's tokens including A. Laptop is silently logged out on its next refresh.
- **Pinning test:** Create two refresh-token sessions, terminate one via the sessions DELETE route, present the terminated token to `refreshAccessToken`; assert the OTHER session's row still exists (fails today: it is deleted).
### H2. `order_items.description` Zod cap (8000) far exceeds DB column `VarChar(500)` → Prisma 500 on long descriptions
- **Severity:** High
- **File:** `src/schemas/orders.schema.ts:12`
- **Failure scenario:** `OrderItemSchema.description = z.string().max(8000)` but `order_items.description` is `@db.VarChar(500)` (`schema.prisma:327`). `createOrder`/`updateOrder` map the value straight into `tx.order_items.createMany` with no truncation. A 5018000 char description passes Zod, overflows the column under MySQL strict mode (P2000), and `createOrder`'s catch only re-maps errors carrying a `status` property — so it surfaces as HTTP 500 "Interní chyba serveru" instead of a clean Czech 400, with the whole order transaction rolled back.
- **Repro:** `POST /api/admin/orders` (Content-Type application/json so the manual-JSON branch runs), `items:[{ description: "x".repeat(600), quantity:1, unit_price:100 }]` → HTTP 500 instead of 400; no order persisted. Same on `PUT /api/admin/orders/:id` for an order in `prijata`/`v_realizaci`.
- **Pinning test:** `parseBody(OrderItemSchema, { description: "x".repeat(600), ... })` should fail Zod with a 400-class error (fails today: passes, then 500s at Prisma). Fix: `.max(500)`.
### H3. Issued-order status transition silently discards unsaved item/section edits and marks them clean
- **Severity:** High
- **File:** `src/admin/pages/IssuedOrderDetail.tsx:524`
- **Failure scenario:** A `sent` issued order is both editable AND has valid transitions. `handleStatusChange` (line 516) sends ONLY `{ status: newStatus }`; the server's `replaceItems = editable && Array.isArray(body.items)` is false for a status-only payload, so edited items/sections are never persisted. Worse, line 524 calls `markClean({ form, items, sections })` with the in-memory UNSAVED edits, re-baselining the unsaved-changes guard so `isDirty` flips false and the `beforeunload` warning never fires. After the transition the order is `confirmed` → read-only, so the lost edits cannot be re-applied. (Contrast: `OfferDetail`/`InvoiceDetail` do NOT call `markClean` on transition.)
- **Repro:** Open a `sent` issued order with `orders.edit`, edit a line item / section / supplier (form dirty), click "Potvrdit" and confirm. The transition succeeds, the edits are silently dropped, and on refetch the items/sections revert to stored values with no warning.
- **Pinning test:** Render `IssuedOrderDetail` for a `sent` order, mutate an item, invoke a status transition; assert the PUT payload includes the edited `items` (or that the guard stays dirty / a warning is shown) — fails today.
### H4. Received-invoices list is paginated server-side (25/page) but the UI sends no `page` param and renders no Pagination control — rows 26+ are permanently hidden
- **Severity:** High
- **File:** `src/admin/pages/ReceivedInvoices.tsx:265`
- **Failure scenario:** `receivedInvoiceListOptions` hits `/received-invoices`, which applies `skip`/`take` with `parsePagination`'s default limit of 25. The component builds the query with no `page`/`perPage`, reads only `listQuery.data?.data` (the first 25 rows), and renders NO `<Pagination>`. The "Celkem" footer is sourced from the separate `/received-invoices/list-totals` over the FULL filtered set, so with 26+ rows the displayed total visibly disagrees with the on-screen rows. The sibling issued-invoices tab (`Invoices.tsx`) correctly uses `usePaginatedQuery` + `<Pagination>`.
- **Repro:** Seed 26 CZK received invoices in one month. `GET /received-invoices?month=6&year=2026` returns `data.length=25`, `pagination.total=26`; `GET /received-invoices/list-totals?...` returns 26000 CZK. In the UI (Faktury → Přijaté, June 2026) the table shows 25 rows summing to 25000 while the footer shows 26000 — and the 26th invoice is unreachable.
- **Pinning test:** With 26 received invoices in a month, assert the page requests page 2 (or renders a pager) and that all 26 are reachable — fails today.
### H5. Odin invoice import: Czech-format dates silently corrupt month/year (wrong month or NaN) in `received_invoices`
- **Severity:** High
- **File:** `src/admin/components/odin/InvoiceReviewCard.tsx:79`
- **Failure scenario:** The review card exposes "Datum vystavení"/"Datum splatnosti" as free-text fields; `OdinChat.saveInvoice` sends the raw string. The multipart schema validates `issue_date` only as `z.string().max(255).nullish()`, so it passes. The route derives `invoiceMonth = new Date(issue_date).getMonth()+1` / `getFullYear()` with no validity guard. `new Date("15.03.2026")` → Invalid Date → `NaN` (day>12); `new Date("12.6.2026")` → December 2026 (wrong month). NAS save (line 410) runs BEFORE the DB create (line 420), so for the NaN case Prisma rejects into the `UnsignedTinyInt`/`UnsignedSmallInt` columns → HTTP 500 with an orphaned NAS file; for day≤12 it silently files under the wrong month/year.
- **Repro:** In Odin, edit an extracted invoice's issue date to `15.03.2026` and Save → 500 + orphaned NAS file. Use `12.6.2026` → invoice silently filed under month 12. Equivalent: `POST /api/admin/received-invoices` (multipart) with `issue_date:"15.03.2026"`.
- **Pinning test:** `POST /received-invoices` with `issue_date="15.03.2026"` should return a 400 (or normalize) — fails today (500 + NaN write). Fix: use `isoDateString` coercion / validity guard.
### H6. Leave-request approval charges vacation/sick balance for Czech public holidays that fall inside the range
- **Severity:** High
- **File:** `src/routes/admin/leave-requests.ts:222`
- **Failure scenario:** Both the request-creation loop (lines 9097) and the approval loop (lines 222244) count business days with a weekend-only filter (`dow !== 0 && dow !== 6`); the file never imports `isHoliday`. So weekday Czech public holidays inside the range are counted as charged leave days, and approval increments `vacation_used`/`sick_used` by `totalBusinessDays * 8` including them. The sibling `attendance.service.createLeave` (line 1270) correctly skips `isHoliday(...)`, and `getBusinessDaysInMonth` excludes holidays from the work fund — so the deduction is a double charge for a day already paid/free.
- **Repro:** Employee `POST /api/admin/leave-requests` `{leave_type:"vacation", date_from:"2026-05-01", date_to:"2026-05-09"}` (1.5. and 8.5. are weekday holidays in 2026) → `total_hours=48`. Approver `PUT /.../{id}` `{status:"approved"}``vacation_used` grows by 48h instead of 32h; holiday dates are recorded as consumed vacation in `attendance`.
- **Pinning test:** Approve a vacation request spanning a weekday public holiday; assert `vacation_used` increased only by the non-holiday business hours (fails today: includes the holiday).
---
## MEDIUM
### M1. Invoice date fields accept arbitrary strings and write a day early to `@db.Date` when a datetime value is submitted
- **Severity:** Medium
- **File:** `src/services/invoices.service.ts:533`
- **Failure scenario:** `CreateInvoiceSchema`/`UpdateInvoiceSchema` type `issue_date`/`due_date`/`tax_date`/`paid_date` as plain `z.string().max(255).nullish()` (not `isoDateString`). `createInvoice`/`updateInvoice` do `new Date(String(...))` straight into the `@db.Date` columns. A round-tripped API value like `"2025-03-15T00:00:00"` (the `toJSON` override emits local time, no `Z`) parses as local Prague → `2025-03-14 22:00 UTC` → Prisma truncates to `2025-03-14`. The invoice then also lands in the wrong month bucket in `getInvoiceStats`/`buildInvoiceWhere`. Every other `@db.Date`-writing module uses `isoDateString` (which slices the time off); invoices is the lone outlier.
- **Repro:** `POST /api/admin/invoices` with `issue_date:"2025-03-01T00:00:00"` → DB stores `2025-02-28`; `GET .../stats?month=3&year=2025` excludes it and inflates February.
- **Pinning test:** Create an invoice with `issue_date="2025-03-01T00:00:00"`; assert the stored date is `2025-03-01` (fails today: `2025-02-28`). Fix: `isoDateString.nullish()`.
### M2. `order_items.unit` Zod cap (255) exceeds DB column `VarChar(20)` → Prisma 500 on long unit strings
- **Severity:** Medium
- **File:** `src/schemas/orders.schema.ts:15`
- **Failure scenario:** `unit: z.string().max(255)` vs `order_items.unit @db.VarChar(20)` (`schema.prisma:330`). A 21255 char unit passes Zod, overflows the column (P2000), and surfaces as 500 (the create catch only re-maps `status`-bearing errors; update has no try/catch). The sibling offers/issued-orders schemas correctly use `.max(20)`.
- **Repro:** `POST /api/admin/orders` (JSON) `items:[{ description:"Test", quantity:1, unit_price:100, unit:"abcdefghijklmnopqrstuvwxyz" }]` → HTTP 500 instead of 400. (The MUI form enforces `maxLength:20` client-side, so this is an API/non-browser vector.)
- **Pinning test:** `parseBody(OrderItemSchema, { unit: "x".repeat(21), ... })` should 400 at Zod — fails today. Fix: `.max(20)`.
### ~~M3. Offer PDF page footer ("Strana X z Y") never renders~~ — WITHDRAWN 2026-06-12 (false positive)
- **Status:** **Withdrawn** — user verified the footer DOES render in production.
- **Why the audit got it wrong:** the finding (and all three verification lenses) trusted the repo's own documentation — the `html-to-pdf.ts:68-74` comment and the CLAUDE.md line "Chromium has no CSS margin-box footers". That fact has been stale since **Chrome 131 (Nov 2024), which implemented CSS `@page` margin boxes**; the project runs Puppeteer 24.40 (bundled Chrome 146 locally) and prod renders with system Chromium 149, both far past 131, so the offer's `@bottom-center { counter(page) }` footer renders fine. No lens rendered an actual PDF — documentation poisoning, not a code bug.
- **Real follow-up (doc-only, Low):** correct the outdated claim in `html-to-pdf.ts` and CLAUDE.md so future work doesn't repeat this; the `footerTemplate` mechanism and the CSS margin-box mechanism are now BOTH valid, coexisting approaches.
### M4. `customer_order_number` Zod cap (255) exceeds DB column `VarChar(100)` → Prisma 500
- **Severity:** Medium
- **File:** `src/schemas/orders.schema.ts:54`
- **Failure scenario:** `customer_order_number: z.string().max(255)` vs `orders.customer_order_number @db.VarChar(100)` (`schema.prisma:356`). A 101255 char value passes Zod, overflows the column (P2000 under strict mode), and surfaces as 500 instead of 400 (no P2003/P2000 mapping; create catch only handles `status`-bearing errors).
- **Repro:** `POST /api/admin/orders` (JSON) `{ customer_order_number: "A".repeat(150), currency:"CZK", language:"cs", status:"prijata" }` → HTTP 500; no order created. Same on `PUT /api/admin/orders/:id`.
- **Pinning test:** `parseBody(CreateOrderSchema, { customer_order_number: "A".repeat(150), ... })` should 400 — fails today. Fix: `.max(100)` on both schemas.
### M5. Foreign-currency invoice PDF returns HTTP 500 (and fails to archive) when the CNB rate lookup throws
- **Severity:** Medium
- **File:** `src/routes/admin/invoices-pdf.ts:411`
- **Failure scenario:** For a EUR invoice, `const cnbRate = isForeign ? await getRate(currency, issueDateStr) : 1.0` is unguarded. When CNB is unreachable and no cached entry exists for the issue date, `getRate` throws "Nepodařilo se získat aktuální kurzy z ČNB", which propagates to the route's catch and returns the 500 error page — blocking BOTH preview and NAS archival, even though the rate only feeds the cosmetic CZK-conversion footnote. Sibling consumers (`received-invoices` stats, `ai-tools`) wrap `toCzk` and degrade per-row; the PDF path has no fallback.
- **Repro:** EUR invoice with a back-dated/never-fetched issue date, CNB unreachable → `GET /api/admin/invoices-pdf/:id` (preview) or `?save=1` (archive) returns 500; the `save=1` archival is silently swallowed client-side, leaving no NAS copy.
- **Pinning test:** Mock `getRate` to throw, render a foreign-currency invoice PDF; assert a non-error response (PDF rendered with the conversion line omitted) — fails today (500).
### M6. `updateProject` sets FK fields with no existence check → FK violation 500
- **Severity:** Medium
- **File:** `src/services/projects.service.ts:115`
- **Failure scenario:** `updateProject` writes `customer_id`/`responsible_user_id`/`quotation_id`/`order_id` straight into `prisma.projects.update` with only int coercion and no `findUnique`. The FKs are `onDelete: Restrict`, so a non-existent id raises P2003 → generic 500. `createProject` validates `customer_id`/`responsible_user_id` and returns clean Czech 400s; the update path is the inconsistent gap.
- **Repro:** `PUT /api/admin/projects/1` `{ "customer_id": 999999 }` → HTTP 500 "Interní chyba serveru" instead of "Zákazník nenalezen" 400. Same for the other three FK fields.
- **Pinning test:** `PUT /projects/:id` with a non-existent `customer_id`; assert a 400 with a Czech "nenalezen" message — fails today (500). Fix: mirror `createProject`'s existence checks.
### M7. Date-range filters/reports exclude the whole `date_to` day (`lte` against UTC-midnight) — inclusive end date silently drops same-day records
- **Severity:** Medium
- **File:** `src/routes/admin/warehouse.ts:1038`
- **Failure scenario:** Receipts/issues filters and the project-consumption / movement-log reports build `created_at: { lte: new Date(date_to) }`. `new Date("2026-06-12")` parses as UTC midnight = 02:00 Prague; `created_at` is `@db.DateTime(0)` storing local-time instants, so a receipt at 09:00 Prague (07:00Z) is `> 00:00Z` and excluded. The entire requested end day disappears from filtered lists and report totals.
- **Repro:** Confirm a receipt today at 09:00 Prague. `GET /admin/warehouse/receipts?date_from=2026-06-12&date_to=2026-06-12` returns zero rows. Reports for a period ending 2026-06-12 under-report the last day.
- **Pinning test:** Create a same-day receipt at 09:00 local, filter `date_to` = that date; assert the receipt is returned — fails today. Fix: half-open `lt utcMidnightOfLocalDay(date_to + 1 day)`.
### M8. POST/PUT receipts & issues 500 on nonexistent or deleted FK ids instead of a clean Czech 400
- **Severity:** Medium
- **File:** `src/routes/admin/warehouse.ts:1117`
- **Failure scenario:** The receipts/issues create+update handlers write rows directly with no FK-existence pre-check and no try/catch; schemas only int-coerce ids. A deleted/nonexistent `supplier_id`/`location_id`/`item_id`/`project_id`/`batch_id` raises P2003/P2025 → generic 500. The document modules (offers/orders/issued-orders) and `trips.ts` all pre-validate FK existence precisely to avoid this; warehouse omits the guard.
- **Repro:** `POST /receipts` `{ items:[{ item_id: 99999999, quantity:1, unit_price:10 }] }` → 500. `POST /issues` `{ project_id: 99999999, items:[{ item_id:<in-stock>, quantity:1 }] }` → 500. `POST /issues` with a valid item + nonexistent explicit `batch_id` → 500.
- **Pinning test:** `POST /receipts` with a nonexistent `item_id`; assert a 400 ("Položka nenalezena") — fails today (500).
### M9. `updateEntry` never re-checks the per-cell cap, so expanding an entry's range pushes a day past `MAX_RECORDS_PER_CELL` and silently hides existing records
- **Severity:** Medium
- **File:** `src/services/plan.service.ts:597`
- **Failure scenario:** `createEntry`/`bulkCreateEntries`/`createOverride` all guard the per-cell cap of 3 via `assertEntryCapAvailable`, but `updateEntry` does not. Expanding an entry's `date_from`/`date_to` onto a day that already holds 3 active entries succeeds, producing 4. `resolveCell`/`resolveGrid` cap display at 3 (newest-first), so the oldest entry silently vanishes from the grid while remaining active in the DB.
- **Repro:** Create 3 active entries on 2099-07-01 for user 5; create a 4th on 2099-07-02; `PATCH /plan/entries/<4th>` `{ date_from:"2099-07-01", date_to:"2099-07-01" }` → 200; the grid for that day now shows only 3 of the 4 entries.
- **Pinning test:** Fill a day to the cap, then `PATCH` another entry to overlap that day; assert the update is rejected with the cap error — fails today.
### M10. `POST /users` does not strip `role_id` for non-admins — a `users.create` holder can grant any non-admin role
- **Severity:** Medium
- **File:** `src/routes/admin/users.ts:63`
- **Failure scenario:** The POST handler forwards `role_id` straight to `createUser`; `createUser` only rejects the literal `admin` role. The PUT handler explicitly strips `role_id`/`is_active` for non-admin callers ("Privilege-escalation guard"), so create is an inconsistent escalation surface: a `users.create` holder can mint an account on any high-privilege non-admin role (e.g. one bundling `settings.roles` or `users.edit`) the caller itself lacks. Violates the documented "Role-management writes are admin-only and re-checked" rule.
- **Repro:** As account M with only `users.create`, `POST /api/admin/users` `{ ..., role_id: N }` where N is a non-admin role bundling `settings.roles`/`users.edit` → 201; the new account holds permissions M never had.
- **Pinning test:** As a non-admin `users.create` caller, POST a user with a privileged `role_id`; assert the created user's role is NOT the privileged one (role stripped, as on PUT) — fails today.
### M11. `route_from`/`route_to` Zod cap (255) exceeds DB column `VarChar(100)` — 500 or silent truncation
- **Severity:** Medium
- **File:** `src/schemas/trips.schema.ts:17`
- **Failure scenario:** `route_from`/`route_to` cap at `.max(255)` (create lines 1718, update 2930) vs `trips.route_from/route_to @db.VarChar(100)` (`schema.prisma:668-669`). The route writes `String(body.route_from)` directly. A 101255 char route passes Zod, then either 500s (P2000 strict mode, no global Prisma mapping) or silently truncates to 100 chars (data loss). The frontend TextFields have no `maxLength`, so the UI triggers it too.
- **Repro:** `POST /api/admin/trips` with a 150-char `route_from` → 500 (or truncated 201). Same on `PUT`.
- **Pinning test:** `parseBody(CreateTripSchema, { route_from: "x".repeat(101), ... })` should 400 — fails today. Fix: `.max(100)`.
### M12. Shared Puppeteer browser disconnecting mid-render fails concurrent PDF requests (no re-acquire/retry)
- **Severity:** Medium
- **File:** `src/utils/html-to-pdf.ts:92`
- **Failure scenario:** `getBrowser()` returns the cached module-level browser after a point-in-time `connected` check; `htmlToPdf` immediately does `b.newPage()` with no re-check. Two concurrent renders capture the same handle; if Chromium crashes (OOM/killed) after the guard, both reject. A's finally nulls the shared handle out from under B. Both return 500 even though an immediate retry would relaunch and succeed — there is no re-acquire/retry wrapper.
- **Repro:** Fire two simultaneous `GET /api/admin/orders-pdf/5` (always renders, no NAS short-circuit), then `pkill -9 chrome` during the render window → both requests 500; the next request succeeds.
- **Pinning test:** Stub the cached browser so `connected` is true at guard time and false at `newPage` time; assert `htmlToPdf` re-acquires and resolves (or both concurrent calls succeed on retry) — fails today.
---
## LOW
### L1. Client-supplied TOTP secret up to 255 chars overflows `VarChar(255)` after encryption, yielding a 500 instead of a 400
- **Severity:** Low
- **File:** `src/schemas/auth.schema.ts:30`
- **Failure scenario:** `TotpEnableSchema.secret` caps at `.max(255)` on the plaintext, but the stored value is the AES-256-GCM ciphertext (`base64(IV[12] + ciphertext + tag[16])`), which exceeds 255 chars for any plaintext over ~164 chars. `users.totp_secret` is `@db.VarChar(255)`. The pre-write `totp.validate` does not bound secret length, and the attacker controls both `secret` and `code`, so a long secret reaches the write → P2000 → 500 (strict mode) or silent ciphertext truncation that permanently locks the account out.
- **Repro:** Authenticated, 2FA-not-enabled user: generate a 250-char base32 secret + matching TOTP code, `POST /api/admin/totp/enable` with the correct password → 500 (or corrupted secret).
- **Pinning test:** Enable TOTP with a 250-char secret; assert a 400 (not 500) — fails today. Fix: `.max(64)` (real secrets are 1632 chars).
### L2. Offer number sequence is never released on delete when created and finalized in different calendar years
- **Severity:** Low
- **File:** `src/services/offers.service.ts:522`
- **Failure scenario:** `generateOfferNumber` keys on `new Date().getFullYear()` (finalize year), assigning e.g. `2026/NA/001`. `deleteOffer` derives the release year from `existing.created_at.getFullYear()` (2025) and calls `releaseOfferNumber(2025, "2026/NA/001")`; `releaseSequence` reconstructs the highest as `2025/NA/<n>`, which never equals `2026/NA/001`, so the `deletedNumber === highestNumber` guard is false and the 2026 counter keeps a permanent gap. `invoices.service.ts` solves the same problem correctly by parsing the year out of the document number.
- **Repro:** Create a draft `2025-12-30`, finalize it `2026-01-02``2026/NA/001`. Delete it → next finalized 2026 offer is `2026/NA/002` (gap; no `2026/NA/001`).
- **Pinning test:** Create a draft in year Y, finalize+delete it in year Y+1; assert the Y+1 sequence counter was released — fails today. Fix: parse the year from the assigned number.
### L3. `customers` list paginates with non-unique sort columns and no `id` tiebreak → unstable pagination
- **Severity:** Low
- **File:** `src/routes/admin/customers.ts:54`
- **Failure scenario:** `orderBy: { [sortField]: order }` over allow-listed fields (`name`/`city`/`country`/`company_id`, all non-unique) with no `{ id }` tiebreak, used with offset pagination. Rows sharing a sort-key value that straddle a page boundary can reorder between the page-1 and page-2 queries, duplicating one and skipping another. Violates the documented determinism rule; siblings (`issued-orders.ts`, `trips.ts`) use compound `[..., { id }]` orderings. (The same defect exists at `received-invoices.ts:144`.)
- **Repro:** Seed customers with a shared `city` straddling a `limit=2` boundary; page through `?sort=city` and observe a customer appearing on two pages while another is skipped.
- **Pinning test:** Paginate a sort over duplicate-`city` customers; assert the concatenated pages contain every customer exactly once — fails today. Fix: `orderBy: [{ [sortField]: order }, { id: order }]`.
### L4. Invoice month navigation does not reset the page index → switching to a sparser month shows an empty table
- **Severity:** Low
- **File:** `src/admin/pages/Invoices.tsx:244`
- **Failure scenario:** `prevMonth`/`nextMonth` mutate `statsMonth`/`statsYear` but never `setPage(1)` (unlike the status/search handlers). The list query keys on `page` + month, and the server never clamps page to `total_pages`, so paging to page 3 of a busy month then switching to a 1-page month requests `?page=3&month=...` and gets an empty `data` array — empty table for a populated month, with the pager reading "3 of 1".
- **Repro:** Open a month with 60+ invoices, go to page 3, click the previous-month chevron to a month with a handful → empty "Zatím nejsou žádné faktury" table. API: `GET /api/admin/invoices?page=3&month=<sparse>&year=<y>` returns `data:[]` with non-zero `total`.
- **Pinning test (component):** With `page=3`, fire `nextMonth`; assert `page` resets to 1 — fails today.
### L5. Issued-orders sub-list keeps its page index when the parent Orders page changes month → empty list for sparser months
- **Severity:** Low
- **File:** `src/admin/pages/IssuedOrders.tsx:135`
- **Failure scenario:** `IssuedOrders` receives `month`/`year` as props and holds `page` in local state with no effect/key resetting it on prop change (the child is not keyed by month/year, so it doesn't remount). Paging to page 2 of a busy month then moving the parent to a sparse month requests page 2 of the new month → empty `data` → "Zatím žádné vydané objednávky." for a month that has orders on page 1.
- **Repro:** Objednávky → Vydané: navigate to a month with ≥26 issued orders, click page 2, then use the parent month chevron to a month with 125 orders → empty sub-list. API equivalent: `GET /api/admin/issued-orders?page=2&month=<1..25 orders>&year=<y>``data:[]` with non-zero `total`.
- **Pinning test (component):** With `page=2`, change the `month` prop; assert `page` resets to 1 (or is clamped to `total_pages`) — fails today.
### L6. Settings "System" tab save clobbers freshly-saved "Firma" numbering patterns with stale values
- **Severity:** Low
- **File:** `src/admin/pages/Settings.tsx:297`
- **Failure scenario:** Both tabs edit the same singleton `company_settings` row via `PUT /company-settings`. `sysForm` carries `offer_number_pattern`/`order_number_pattern`/`invoice_number_pattern` (populated once, gated by a never-reset `sysFormInitialized` flag). After editing numbering in the Firma tab (which invalidates `["company-settings"]`), `sysForm` stays stale; saving the System tab sends the whole stale form, and the backend applies every `!== undefined` field, silently reverting the Firma-tab change.
- **Repro:** Init the System tab; in the Firma tab change `offer_number_pattern` and save; return to System and save any unrelated field → `offer_number_pattern` reverts to its pre-edit value, with a success toast.
- **Pinning test:** PUT the offer pattern, then PUT a stale sysForm carrying the old pattern; assert the offer pattern is NOT reverted (or that System-tab saves omit the numbering fields) — fails today.
### L7. `start_km`/`end_km` accept decimals but DB columns are `Int` — non-integer odometer reading errors or truncates
- **Severity:** Low
- **File:** `src/schemas/trips.schema.ts:15`
- **Failure scenario:** `start_km`/`end_km` use `nonNegativeNumberFromForm` (only `Number.isFinite && >=0`, no integer refine), but the columns are `Int`. A decimal passes Zod, reaches the `Int` column → Prisma validation 500 or MySQL truncation (corrupting the reading and derived distance, propagated into `vehicles.actual_km`). Same mismatch for `vehicles.initial_km`/`actual_km`. The UI's `parseInt` is used only for the distance widget, not the submitted payload, so the browser is vulnerable too.
- **Repro:** `POST /api/admin/trips` `{ vehicle_id, trip_date:"2098-01-15", start_km:100.5, end_km:1200.7, route_from:"A", route_to:"B" }` → 500 (or truncated values).
- **Pinning test:** `parseBody(CreateTripSchema, { start_km: 100.5, ... })` should 400 — fails today. Fix: `nonNegativeIntFromForm` for the four km fields.
### L8. AI monthly budget control: severity-mismatched permission, date asymmetry, dead UI button, currency overflow, audit-log boundary (grouped low-severity cluster)
These five independently-confirmed low-severity findings share the "minor blast radius / narrow trigger" profile. Each is real and pinnable.
- **L8a. Company-wide AI monthly budget is mutable by any `ai.use` holder (not admin/settings).** `src/routes/admin/ai.ts:58`. `PUT /api/admin/ai/budget` is gated only by `requirePermission("ai.use")`, an ordinary grantable permission. Setting `budget_usd=0` makes `assertBudgetAvailable` return 402 for everyone (admins included) → company-wide AI DoS; setting `1000000` removes the cost cap. The parallel `company-settings` writes are gated behind `settings.company`/`settings.system`.
- **Repro:** As any `ai.use` holder, `PUT /api/admin/ai/budget {"budget_usd":0}` → all `ai/chat` and `extract-invoices` calls 402.
- **Pinning test:** Call `PUT /ai/budget` as a non-admin `ai.use`-only user; assert 403 — fails today. Fix: gate behind `settings.company`/admin.
- **L8b. Audit-log `date_from` filter boundary shifted ~2h vs `date_to` (UTC vs local parsing).** `src/routes/admin/audit-log.ts:46`. `from = new Date(date_from)` parses as UTC midnight (02:00 Prague), while `to = new Date(date_to + "T23:59:59")` parses as local — so events between 00:00 and 02:00 Prague on the from-date are silently excluded.
- **Repro:** `GET /api/admin/audit-log?date_from=2026-06-12&date_to=2026-06-12` omits a row logged at 01:30 Prague that day.
- **Pinning test:** Insert an audit row at 01:30 local, filter for that day; assert it's returned — fails today.
- **L8c. Draft invoice shows a non-functional "Zobrazit fakturu" PDF button that always errors.** `src/admin/pages/InvoiceDetail.tsx:1805`. The button render guard lacks the `status !== "draft"` check that `OfferDetail`/`IssuedOrderDetail` have; clicking it on a draft opens a blank tab, 404s `/file`, closes the tab, and toasts an error.
- **Repro:** Open a draft invoice, click "Zobrazit fakturu" → blank tab flash + "PDF soubor nenalezen" toast.
- **Pinning test:** Render `InvoiceDetail` for a draft; assert the PDF button is not rendered — fails today. Fix: add `&& invoice.invoice_number` (or non-draft) to the guard.
- **L8d. Odin "Měna" / received-invoice currency over 3 chars 500s the save (`VarChar(3)` vs schema `max(20)`).** `src/components/odin/InvoiceReviewCard.tsx:67` / `src/schemas/received-invoices.schema.ts:11`. A free-text currency like "Koruna" passes Zod (`max(20)`) but overflows `received_invoices.currency @db.VarChar(3)` (P2000 → 500), AND because the NAS write precedes the DB create, the uploaded file is orphaned. The same module's `description` (`max(8000)` vs `VarChar(500)`) and `invoice_number` (`max(255)` vs `VarChar(100)`) are also misaligned.
- **Repro:** Save an Odin invoice with `currency:"Koruna"` → 500 + orphaned NAS file. Equivalent: `POST /received-invoices` multipart with `currency:"Koruna"`.
- **Pinning test:** `parseBody(CreateReceivedInvoiceSchema, { currency:"Koruna", ... })` should 400 — fails today. Fix: `currency .max(3)`, `description .max(500)`, `invoice_number .max(100)`.
- **L8e. Warehouse supplier `ico`/`dic` Zod caps (255) exceed `VarChar(20)` columns.** `src/schemas/warehouse.schema.ts:34`. A >20-char `ico`/`dic` (bad paste) passes Zod and 500s at Prisma. Low realism (real IČO=8 digits) but a genuine 500-vs-400 defect; the customers schema (`company_id`/`vat_id`) shares it.
- **Repro:** `POST /api/admin/warehouse/suppliers` `{ name:"Test", ico:"123456789012345678901" }` → 500.
- **Pinning test:** `parseBody(CreateSupplierSchema, { ico:"x".repeat(21), ... })` should 400 — fails today. Fix: `.max(20)`.
> **Adjacent uncited Zod-cap mismatches confirmed during verification** (same bug class, fix alongside the above so the gap is closed once): invoice line-item `description` (`max(8000)` vs `VarChar(500)`) and `unit` (`max(255)` vs `VarChar(20)`) — `src/schemas/invoices.schema.ts:13,16`, **High** blast radius (whole invoice save rolls back); invoice bank/payment fields `payment_method`/`constant_symbol`/`bank_swift`/`bank_iban`/`bank_account` (caps 255 vs `VarChar(50)/VarChar(20)`) — `src/schemas/invoices.schema.ts:30-35`, **Medium**; warehouse `/items` list ignores the client `sort` param and sorts by non-unique `name` with no `id` tiebreak — `src/routes/admin/warehouse.ts:662`, **Low**; year-spanning leave request books all hours against the start year's balance only — `src/routes/admin/leave-requests.ts:195`, **Medium**; Dashboard "Přidat jízdu" quick action omits `["vehicles"]` invalidation — `src/admin/components/dashboard/DashQuickActions.tsx:184`, **Low**. These were verified `real` but fall outside the 29-item cited set; treat them as a fast-follow batch.
---
## Recommended Fix Order (dependencies considered)
**Phase 0 — Data-integrity criticals first (financial/stock corruption, silent & cumulative).** These corrupt persisted state and worsen on retry; fix before anything cosmetic.
1. **C3** `confirmInventorySession` `return``throw` (one-line change; stops repeatable phantom inventory). Trivial, highest leverage.
2. **C2** `confirmIssue` cross-line over-issue guard (add a running per-batch tally / up-front availability pass, mirroring the existing `confirmIssue` validation-first pattern; consider a `(issue_id, batch_id)` unique constraint as a backstop).
3. **C1** leave-balance restore on attendance delete — but **do C1 together with H6 and the year-spanning leave finding**, since all three live in the leave/attendance balance code and a single corrected helper (per-day, holiday-aware, per-year accumulation, with an inverse on delete) resolves them coherently. Fix the holiday-counting (H6) and per-year split first, then route delete through the same helper.
**Phase 1 — Security / privilege & session correctness.** 4. **H1** session-reuse misclassification (gate the reuse branch on `replaced_by_hash` alone; reorder vs the expiry check). Self-contained; high user impact. 5. **M10** `POST /users` role-strip for non-admins; **L8a** AI budget permission gate. Both are permission-boundary one-liners.
**Phase 2 — The Zod-cap-vs-DB-column batch (single coordinated sweep).** All of **H2, M2, M4, M11, L1, L8d, L8e** plus the adjacent invoice item/bank caps share one mechanism and one fix shape (`.max()` alignment + optionally a global P2000→400 mapping in `server.ts`). Do them in one pass with a shared "cap-must-fit-column" test helper so the class is closed, not whacked one at a time. Highest blast radius within the batch (whole-save rollback): invoice item caps, then H2.
> **Phase 2 follow-ups (2026-06-12, deliberately deferred):** (1) the optional
> global P2000→400 mapping in the `server.ts` error handler was NOT added —
> `server.ts` exports no app builder, so the mapping would be untestable
> without first extracting an exported `buildApp()`; do the refactor + backstop
> together. (2) Frontend `maxLength` attributes on the trips (odkud/kam) and
> invoice bank/payment form fields — pure UX polish now that Zod 400s
> over-length input with a Czech message; add when next touching those forms.
**Phase 3 — FK-existence & error-mapping consistency.** **M6** (projects update), **M8** (warehouse receipts/issues), **M5** (CNB graceful degradation). These align the lagging modules with the established offers/orders pre-validation pattern; a shared P2003→Czech-400 mapping (added in Phase 2 if you choose the global-handler route) reduces the per-site work.
**Phase 4 — Date/timezone boundaries.** **M1** (invoice date `isoDateString`), **M7** (warehouse `date_to` half-open), **H5** (Odin Czech-date), **L8b** (audit-log `from`). Group because they share the documented date-boundary helpers (`isoDateString`, `utcMidnightOfLocalDay`); fixing M1/H5 also removes a day-shift class.
**Phase 5 — Pagination determinism & frontend state.** **H4** (received-invoices pager — add the missing `usePaginatedQuery`/`<Pagination>`), **L3** (customers + received-invoices `id` tiebreak), **L4/L5** (page-reset on month change), **M9** (plan update cap), **H3** (issued-order transition flush edits + don't `markClean` stale state). H4 is the largest UI change; the rest are small, independent guards.
**Phase 6 — Cosmetic / low-blast cleanup (parallelizable, no dependencies).** ~~M3~~ (withdrawn; only the stale Chromium-footer comment in `html-to-pdf.ts`/CLAUDE.md remains as a doc fix), **M12** (Puppeteer re-acquire/retry), **L2** (offer number release year), **L6** (Settings System/Firma clobber), **L7** (km integer coercion), **L8c** (draft PDF button guard), warehouse `/items` sort param + tiebreak, and the Dashboard `["vehicles"]` invalidation.
Rationale for ordering: persisted-data corruption (Phase 0) cannot be allowed to accumulate; security boundaries (Phase 1) are cheap and high-impact; the cap batch (Phase 2) and FK/error mapping (Phase 3) are most efficient done as coordinated sweeps that share a fix shape and tests; date boundaries (Phase 4) share helpers; UI/pagination (Phase 5) depend on nothing earlier but benefit from the cleaner backend; everything in Phase 6 is independent and can be parallelized across contributors.

767
CLAUDE.md
View File

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

237
REVIEW.md
View File

@@ -1,81 +1,102 @@
# Codebase Audit — REVIEW.md # Codebase Audit Checklist
**Date:** 2026-06-08 **Date:** 2026-06-09 | **Total files:** 304 | Scope: full project (source + config). Excluded: docs/*.md, prisma/migrations/*.sql (generated SQL), package-lock.json, binaries.
**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 ## (root)
--- - [x] .env.example
- [x] .gitignore
- [x] .prettierignore
- [x] .prettierrc.json
- [x] boneyard.config.json
- [x] CLAUDE.md
- [x] eslint.config.mjs
- [x] index.html
- [x] package.json
- [x] prisma.config.ts
- [x] tsconfig.app.json
- [x] tsconfig.json
- [x] tsconfig.server.json
- [x] tsconfig.test.json
- [x] vite.config.ts
- [x] vitest.config.ts
## .claude
## .claude/hooks/
- [x] .claude/hooks/block-env.js - [x] .claude/hooks/block-env.js
- [x] .claude/hooks/format-on-save.js - [x] .claude/hooks/format-on-save.js
## .claude/
- [x] .claude/settings.json - [x] .claude/settings.json
- [x] .claude/settings.local.json - [x] .claude/settings.local.json
## ./ ## prisma
- [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 - [x] prisma/seed.ts
- [x] prisma/schema.prisma
## scripts/ ## scripts
- [x] scripts/check-roles.mjs
- [x] scripts/migrate-received-invoices-to-nas.ts - [x] scripts/migrate-received-invoices-to-nas.ts
- [x] scripts/rotate-totp-key.ts - [x] scripts/rotate-totp-key.ts
- [x] scripts/seed-test-users.mjs
## src/ ## src/__tests__
- [x] src/App.tsx
## src/__tests__/
- [x] src/__tests__/ai.test.ts - [x] src/__tests__/ai.test.ts
- [x] src/__tests__/auth.service.test.ts - [x] src/__tests__/auth.service.test.ts
- [x] src/__tests__/auth.test.ts - [x] src/__tests__/auth.test.ts
- [x] src/__tests__/bank-accounts.test.ts
- [x] src/__tests__/company-settings-numbering.test.ts
- [x] src/__tests__/customers.schema.test.ts - [x] src/__tests__/customers.schema.test.ts
- [x] src/__tests__/drafts-aggregation.test.ts
- [x] src/__tests__/drafts-deferred-numbering.test.ts
- [x] src/__tests__/env.test.ts - [x] src/__tests__/env.test.ts
- [x] src/__tests__/exchange-rates.test.ts - [x] src/__tests__/exchange-rates.test.ts
- [x] src/__tests__/helpers.ts - [x] src/__tests__/helpers.ts
- [x] src/__tests__/invoices.service.test.ts - [x] src/__tests__/invoices.service.test.ts
- [x] src/__tests__/issued-orders.test.ts
- [x] src/__tests__/manual-create.test.ts - [x] src/__tests__/manual-create.test.ts
- [x] src/__tests__/nas-document-manager.test.ts
- [x] src/__tests__/nas-file-manager.test.ts - [x] src/__tests__/nas-file-manager.test.ts
- [x] src/__tests__/nas-project-folder.test.ts - [x] src/__tests__/nas-project-folder.test.ts
- [x] src/__tests__/numbering.test.ts - [x] src/__tests__/numbering.test.ts
- [x] src/__tests__/offer-invoice-totals.test.ts
- [x] src/__tests__/orders-list.test.ts
- [x] src/__tests__/order-totals.test.ts
- [x] src/__tests__/plan.test.ts - [x] src/__tests__/plan.test.ts
- [x] src/__tests__/planAuditDescription.test.ts - [x] src/__tests__/planAuditDescription.test.ts
- [x] src/__tests__/planCategory.test.ts - [x] src/__tests__/planCategory.test.ts
- [x] src/__tests__/received-invoices-vat.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__/setup.ts
- [x] src/__tests__/schema-nan.test.ts
- [x] src/__tests__/warehouse.test.ts - [x] src/__tests__/warehouse.test.ts
## src/admin/ ## src/admin (AdminApp.tsx)
- [x] src/admin/AdminApp.tsx - [x] src/admin/AdminApp.tsx
- [x] src/admin/GlobalStyles.tsx
## src/admin/components/ ## src/admin (components)
- [x] src/admin/components/AlertContainer.tsx - [x] src/admin/components/AlertContainer.tsx
- [x] src/admin/components/AttendanceShiftTable.tsx - [x] src/admin/components/AttendanceShiftTable.tsx
- [x] src/admin/components/BulkAttendanceModal.tsx - [x] src/admin/components/BulkAttendanceModal.tsx
- [x] src/admin/components/BulkPlanModal.tsx - [x] src/admin/components/BulkPlanModal.tsx
- [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
- [x] src/admin/components/ErrorBoundary.tsx - [x] src/admin/components/ErrorBoundary.tsx
- [x] src/admin/components/Forbidden.tsx - [x] src/admin/components/Forbidden.tsx
- [x] src/admin/components/odin/InvoiceReviewCard.tsx
- [x] src/admin/components/odin/OdinComposer.tsx
- [x] src/admin/components/odin/OdinChat.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
- [x] src/admin/components/OrderConfirmationModal.tsx - [x] src/admin/components/OrderConfirmationModal.tsx
- [x] src/admin/components/PlanCategoriesModal.tsx - [x] src/admin/components/PlanCategoriesModal.tsx
- [x] src/admin/components/PlanCellModal.tsx - [x] src/admin/components/PlanCellModal.tsx
@@ -85,38 +106,19 @@
- [x] src/admin/components/RichEditor.tsx - [x] src/admin/components/RichEditor.tsx
- [x] src/admin/components/ShiftFormModal.tsx - [x] src/admin/components/ShiftFormModal.tsx
- [x] src/admin/components/ShortcutsHelp.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/ItemPicker.tsx
- [x] src/admin/components/warehouse/ReservationPicker.tsx - [x] src/admin/components/warehouse/ReservationPicker.tsx
## src/admin/context/ ## src/admin (context)
- [x] src/admin/context/AlertContext.tsx - [x] src/admin/context/AlertContext.tsx
- [x] src/admin/context/AuthContext.tsx - [x] src/admin/context/AuthContext.tsx
## src/admin/hooks/ ## src/admin (GlobalStyles.tsx)
- [x] src/admin/GlobalStyles.tsx
## src/admin (hooks)
- [x] src/admin/hooks/useAttendanceAdmin.ts - [x] src/admin/hooks/useAttendanceAdmin.ts
- [x] src/admin/hooks/useDebounce.ts - [x] src/admin/hooks/useDebounce.ts
@@ -126,19 +128,18 @@
- [x] src/admin/hooks/useReducedMotion.ts - [x] src/admin/hooks/useReducedMotion.ts
- [x] src/admin/hooks/useTableSort.ts - [x] src/admin/hooks/useTableSort.ts
## src/admin/lib/ ## src/admin (lib)
- [x] src/admin/lib/apiAdapter.ts - [x] src/admin/lib/apiAdapter.ts
- [x] src/admin/lib/documentStatus.ts
- [x] src/admin/lib/entityTypeLabels.ts - [x] src/admin/lib/entityTypeLabels.ts
## src/admin/lib/queries/
- [x] src/admin/lib/queries/ai.ts - [x] src/admin/lib/queries/ai.ts
- [x] src/admin/lib/queries/attendance.ts - [x] src/admin/lib/queries/attendance.ts
- [x] src/admin/lib/queries/auditLog.ts - [x] src/admin/lib/queries/auditLog.ts
- [x] src/admin/lib/queries/common.ts - [x] src/admin/lib/queries/common.ts
- [x] src/admin/lib/queries/dashboard.ts - [x] src/admin/lib/queries/dashboard.ts
- [x] src/admin/lib/queries/invoices.ts - [x] src/admin/lib/queries/invoices.ts
- [x] src/admin/lib/queries/issued-orders.ts
- [x] src/admin/lib/queries/leave.ts - [x] src/admin/lib/queries/leave.ts
- [x] src/admin/lib/queries/mutations.ts - [x] src/admin/lib/queries/mutations.ts
- [x] src/admin/lib/queries/offers.ts - [x] src/admin/lib/queries/offers.ts
@@ -150,12 +151,9 @@
- [x] src/admin/lib/queries/users.ts - [x] src/admin/lib/queries/users.ts
- [x] src/admin/lib/queries/vehicles.ts - [x] src/admin/lib/queries/vehicles.ts
- [x] src/admin/lib/queries/warehouse.ts - [x] src/admin/lib/queries/warehouse.ts
## src/admin/lib/
- [x] src/admin/lib/queryClient.ts - [x] src/admin/lib/queryClient.ts
## src/admin/pages/ ## src/admin (pages)
- [x] src/admin/pages/Attendance.tsx - [x] src/admin/pages/Attendance.tsx
- [x] src/admin/pages/AttendanceAdmin.tsx - [x] src/admin/pages/AttendanceAdmin.tsx
@@ -168,6 +166,8 @@
- [x] src/admin/pages/Dashboard.tsx - [x] src/admin/pages/Dashboard.tsx
- [x] src/admin/pages/InvoiceDetail.tsx - [x] src/admin/pages/InvoiceDetail.tsx
- [x] src/admin/pages/Invoices.tsx - [x] src/admin/pages/Invoices.tsx
- [x] src/admin/pages/IssuedOrderDetail.tsx
- [x] src/admin/pages/IssuedOrders.tsx
- [x] src/admin/pages/LeaveApproval.tsx - [x] src/admin/pages/LeaveApproval.tsx
- [x] src/admin/pages/LeaveRequests.tsx - [x] src/admin/pages/LeaveRequests.tsx
- [x] src/admin/pages/Login.tsx - [x] src/admin/pages/Login.tsx
@@ -183,6 +183,7 @@
- [x] src/admin/pages/ProjectDetail.tsx - [x] src/admin/pages/ProjectDetail.tsx
- [x] src/admin/pages/Projects.tsx - [x] src/admin/pages/Projects.tsx
- [x] src/admin/pages/ReceivedInvoices.tsx - [x] src/admin/pages/ReceivedInvoices.tsx
- [x] src/admin/pages/ReceivedOrders.tsx
- [x] src/admin/pages/Settings.tsx - [x] src/admin/pages/Settings.tsx
- [x] src/admin/pages/Trips.tsx - [x] src/admin/pages/Trips.tsx
- [x] src/admin/pages/TripsAdmin.tsx - [x] src/admin/pages/TripsAdmin.tsx
@@ -208,29 +209,35 @@
- [x] src/admin/pages/WarehouseReservations.tsx - [x] src/admin/pages/WarehouseReservations.tsx
- [x] src/admin/pages/WarehouseSuppliers.tsx - [x] src/admin/pages/WarehouseSuppliers.tsx
## src/admin/ ## src/admin (theme.test.ts)
- [x] src/admin/theme.test.ts - [x] src/admin/theme.test.ts
## src/admin (theme.ts)
- [x] src/admin/theme.ts - [x] src/admin/theme.ts
## src/admin/ui/ ## src/admin (ui)
- [x] src/admin/ui/Alert.tsx - [x] src/admin/ui/Alert.tsx
- [x] src/admin/ui/AppShell.tsx - [x] src/admin/ui/AppShell.tsx
- [x] src/admin/ui/Button.tsx - [x] src/admin/ui/Button.tsx
- [x] src/admin/ui/Card.tsx - [x] src/admin/ui/Card.tsx
- [x] src/admin/ui/Checkbox.tsx
- [x] src/admin/ui/ConfirmDialog.tsx - [x] src/admin/ui/ConfirmDialog.tsx
- [x] src/admin/ui/CustomerPicker.tsx
- [x] src/admin/ui/DataTable.tsx - [x] src/admin/ui/DataTable.tsx
- [x] src/admin/ui/DateField.tsx - [x] src/admin/ui/DateField.tsx
- [x] src/admin/ui/EmptyState.tsx - [x] src/admin/ui/EmptyState.tsx
- [x] src/admin/ui/Field.tsx - [x] src/admin/ui/Field.tsx
- [x] src/admin/ui/FileUpload.tsx - [x] src/admin/ui/FileUpload.tsx
- [x] src/admin/ui/FilterBar.tsx - [x] src/admin/ui/FilterBar.tsx
- [x] src/admin/ui/Checkbox.tsx
- [x] src/admin/ui/index.ts
- [x] src/admin/ui/LoadingState.tsx - [x] src/admin/ui/LoadingState.tsx
- [x] src/admin/ui/Modal.tsx - [x] src/admin/ui/Modal.tsx
- [x] src/admin/ui/MonthField.tsx - [x] src/admin/ui/MonthField.tsx
- [x] src/admin/ui/MuiProvider.tsx - [x] src/admin/ui/MuiProvider.tsx
- [x] src/admin/ui/navData.tsx
- [x] src/admin/ui/PageEnter.tsx - [x] src/admin/ui/PageEnter.tsx
- [x] src/admin/ui/PageHeader.tsx - [x] src/admin/ui/PageHeader.tsx
- [x] src/admin/ui/Pagination.tsx - [x] src/admin/ui/Pagination.tsx
@@ -244,36 +251,38 @@
- [x] src/admin/ui/TextField.tsx - [x] src/admin/ui/TextField.tsx
- [x] src/admin/ui/ThemeToggle.tsx - [x] src/admin/ui/ThemeToggle.tsx
- [x] src/admin/ui/TimeField.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 - [x] src/admin/ui/useDialogScrollLock.ts
## src/admin/utils/ ## src/admin (utils)
- [x] src/admin/utils/api.ts - [x] src/admin/utils/api.ts
- [x] src/admin/utils/attendanceHelpers.ts - [x] src/admin/utils/attendanceHelpers.ts
- [x] src/admin/utils/dashboardHelpers.ts - [x] src/admin/utils/dashboardHelpers.ts
- [x] src/admin/utils/formatters.ts - [x] src/admin/utils/formatters.ts
## src/config/ ## src/App.tsx
- [x] src/App.tsx
## src/config
- [x] src/config/database.ts - [x] src/config/database.ts
- [x] src/config/env.ts - [x] src/config/env.ts
## src/context/ ## src/context
- [x] src/context/ThemeContext.tsx - [x] src/context/ThemeContext.tsx
## src/ ## src/main.tsx
- [x] src/main.tsx - [x] src/main.tsx
## src/middleware/ ## src/middleware
- [x] src/middleware/auth.ts - [x] src/middleware/auth.ts
- [x] src/middleware/security.ts - [x] src/middleware/security.ts
## src/routes/admin/ ## src/routes
- [x] src/routes/admin/ai.ts - [x] src/routes/admin/ai.ts
- [x] src/routes/admin/attendance.ts - [x] src/routes/admin/attendance.ts
@@ -283,12 +292,14 @@
- [x] src/routes/admin/company-settings.ts - [x] src/routes/admin/company-settings.ts
- [x] src/routes/admin/customers.ts - [x] src/routes/admin/customers.ts
- [x] src/routes/admin/dashboard.ts - [x] src/routes/admin/dashboard.ts
- [x] src/routes/admin/invoices-pdf.ts
- [x] src/routes/admin/invoices.ts - [x] src/routes/admin/invoices.ts
- [x] src/routes/admin/invoices-pdf.ts
- [x] src/routes/admin/issued-orders.ts
- [x] src/routes/admin/issued-orders-pdf.ts
- [x] src/routes/admin/leave-requests.ts - [x] src/routes/admin/leave-requests.ts
- [x] src/routes/admin/offers-pdf.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/orders.ts
- [x] src/routes/admin/orders-pdf.ts
- [x] src/routes/admin/plan.ts - [x] src/routes/admin/plan.ts
- [x] src/routes/admin/profile.ts - [x] src/routes/admin/profile.ts
- [x] src/routes/admin/project-files.ts - [x] src/routes/admin/project-files.ts
@@ -304,7 +315,36 @@
- [x] src/routes/admin/vehicles.ts - [x] src/routes/admin/vehicles.ts
- [x] src/routes/admin/warehouse.ts - [x] src/routes/admin/warehouse.ts
## src/schemas/ ## src/server.ts
- [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/issued-orders.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/schemas
- [x] src/schemas/ai.schema.ts - [x] src/schemas/ai.schema.ts
- [x] src/schemas/attendance.schema.ts - [x] src/schemas/attendance.schema.ts
@@ -313,6 +353,7 @@
- [x] src/schemas/common.ts - [x] src/schemas/common.ts
- [x] src/schemas/customers.schema.ts - [x] src/schemas/customers.schema.ts
- [x] src/schemas/invoices.schema.ts - [x] src/schemas/invoices.schema.ts
- [x] src/schemas/issued-orders.schema.ts
- [x] src/schemas/leave-requests.schema.ts - [x] src/schemas/leave-requests.schema.ts
- [x] src/schemas/offers.schema.ts - [x] src/schemas/offers.schema.ts
- [x] src/schemas/orders.schema.ts - [x] src/schemas/orders.schema.ts
@@ -329,40 +370,12 @@
- [x] src/schemas/vehicles.schema.ts - [x] src/schemas/vehicles.schema.ts
- [x] src/schemas/warehouse.schema.ts - [x] src/schemas/warehouse.schema.ts
## src/ ## src/types
- [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/fastify.d.ts
- [x] src/types/index.ts - [x] src/types/index.ts
## src/utils/ ## src/utils
- [x] src/utils/czech-holidays.ts - [x] src/utils/czech-holidays.ts
- [x] src/utils/date.ts - [x] src/utils/date.ts
@@ -373,14 +386,8 @@
- [x] src/utils/response.ts - [x] src/utils/response.ts
- [x] src/utils/totp.ts - [x] src/utils/totp.ts
## src/ ## src/vite-env.d.ts
- [x] src/vite-env.d.ts - [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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,385 +0,0 @@
# Deployment Guide — boha-app-ts (Ubuntu Server)
Migration from PHP boha-app to TypeScript boha-app-ts on the same Ubuntu server.
Both apps share the same MySQL database. The PHP app stays running during migration.
---
## Prerequisites
- Ubuntu server with the PHP boha-app already running
- nginx with SSL (Let's Encrypt) already configured
- MySQL database already running with production data
- NAS storage mounted (e.g., `/mnt/nas/02_PROJEKTY`)
- SSH access to the server
---
## Phase 1: Prepare on Dev Machine (Windows)
### 1.1 Create Prisma migration baseline
```bash
cd D:\cortex\boha-app-ts
mkdir -p prisma/migrations/0_init
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script > prisma/migrations/0_init/migration.sql
npx prisma migrate resolve --applied 0_init
git add prisma/migrations/
git commit -m "chore: create Prisma migration baseline"
```
### 1.2 Build the application
```bash
npm run build
```
This creates:
- `dist/` — compiled server (Node.js)
- `dist-client/` — compiled frontend (static files)
### 1.3 Generate new production secrets
```bash
openssl rand -hex 32
# Save this as JWT_SECRET
openssl rand -hex 32
# Save this as TOTP_ENCRYPTION_KEY (only if you want a new key)
```
### 1.4 Test the production build locally (optional)
```bash
APP_ENV=production JWT_SECRET=<your-dev-key> TOTP_ENCRYPTION_KEY=<your-dev-key> DATABASE_URL=<your-dev-db> node dist/server.js
```
Verify it starts and responds at `http://localhost:3001/api/health`.
---
## Phase 2: Prepare Ubuntu Server
### 2.1 Install Node.js 22
```bash
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v
npm -v
```
### 2.2 Install PM2
```bash
sudo npm install -g pm2
```
### 2.3 Create application directory
```bash
sudo mkdir -p /var/www/boha-app-ts
sudo chown $USER:$USER /var/www/boha-app-ts
```
---
## Phase 3: Transfer Files to Server
### 3.1 Copy built files
From your Windows machine (Git Bash or PowerShell with SCP):
```bash
scp -r dist/ dist-client/ package.json package-lock.json prisma/ scripts/ .env.example user@server:/var/www/boha-app-ts/
```
Or use rsync if available:
```bash
rsync -avz --exclude node_modules --exclude .env dist/ dist-client/ package.json package-lock.json prisma/ scripts/ .env.example user@server:/var/www/boha-app-ts/
```
### 3.2 Install production dependencies on server
```bash
ssh user@server
cd /var/www/boha-app-ts
npm install --production
npx prisma generate
```
---
## Phase 4: Configure Environment
### 4.1 Create production .env
```bash
cd /var/www/boha-app-ts
cp .env.example .env
nano .env
```
Fill in:
```env
# Database — same as the PHP app
DATABASE_URL=mysql://user:password@localhost:3306/your_db_name
# Server
PORT=3001
HOST=127.0.0.1
APP_ENV=production
# Auth — use the NEW secrets generated in step 1.3
JWT_SECRET=<paste-new-jwt-secret>
ACCESS_TOKEN_EXPIRY=900
REFRESH_TOKEN_SESSION_EXPIRY=3600
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
# TOTP — use SAME key as PHP app (unless you want to re-encrypt)
TOTP_ENCRYPTION_KEY=<same-key-as-php-app>
# NAS — Linux mount point
NAS_PATH=/mnt/nas/02_PROJEKTY
MAX_UPLOAD_SIZE=52428800
# Email
CONTACT_EMAIL_TO=manager@boha-automation.cz
CONTACT_EMAIL_FROM=web@boha-automation.cz
SMTP_FROM=noreply@boha-automation.cz
# CORS — your production domain(s)
CORS_ORIGINS=https://app.boha-automation.cz,https://www.boha-automation.cz
```
**Important decisions:**
| Setting | Recommendation |
|---------|---------------|
| `JWT_SECRET` | **New key.** All PHP sessions will be invalid — users re-login. This is expected. |
| `TOTP_ENCRYPTION_KEY` | **Same key as PHP app.** Avoids re-encrypting all TOTP secrets. |
| `DATABASE_URL` | **Same database as PHP app.** Both apps share it. |
| `NAS_PATH` | **Linux mount point** instead of Windows drive letter. |
---
## Phase 5: Database Setup
### 5.1 Mark Prisma baseline as applied
```bash
cd /var/www/boha-app-ts
npx prisma migrate resolve --applied 0_init
```
This tells Prisma the database already has all tables. No SQL is executed.
### 5.2 Verify database connection
```bash
npx prisma db pull --print | head -20
```
Should show your existing tables.
---
## Phase 6: TOTP Key Rotation (only if using new encryption key)
**Skip this section if you're using the same `TOTP_ENCRYPTION_KEY` as the PHP app.**
If you generated a new encryption key:
```bash
cd /var/www/boha-app-ts
# Dry run — verify all secrets can be decrypted and re-encrypted
npx tsx scripts/rotate-totp-key.ts <old-key> <new-key> --dry-run
# If all [OK], run for real
npx tsx scripts/rotate-totp-key.ts <old-key> <new-key>
```
After this, the PHP app's TOTP verification will break (secrets are now encrypted with the new key). Only do this when you're ready to cut over.
---
## Phase 7: Start Application with PM2
### 7.1 Create PM2 config
```bash
cd /var/www/boha-app-ts
cat > ecosystem.config.js << 'EOF'
module.exports = {
apps: [{
name: 'boha-app-ts',
script: 'dist/server.js',
cwd: '/var/www/boha-app-ts',
instances: 1,
env: {
NODE_ENV: 'production',
},
}]
};
EOF
```
### 7.2 Start the app
```bash
pm2 start ecosystem.config.js
pm2 save
pm2 startup
# Follow the printed command to enable auto-start on boot
```
### 7.3 Verify
```bash
pm2 status
pm2 logs boha-app-ts --lines 20
curl http://localhost:3001/api/health
```
Expected: `{"status":"ok","timestamp":"..."}`
---
## Phase 8: Nginx Configuration
### 8.1 Create nginx config
```bash
sudo nano /etc/nginx/sites-available/boha-app-ts
```
Paste:
```nginx
server {
listen 443 ssl http2;
server_name app.boha-automation.cz;
ssl_certificate /etc/letsencrypt/live/app.boha-automation.cz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.boha-automation.cz/privkey.pem;
client_max_body_size 55M;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
server {
listen 80;
server_name app.boha-automation.cz;
return 301 https://$host$request_uri;
}
```
Adjust `server_name` and SSL paths to match your setup.
### 8.2 Enable and reload
```bash
sudo ln -s /etc/nginx/sites-available/boha-app-ts /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
---
## Phase 9: Testing
### 9.1 Verify in browser
Open `https://app.boha-automation.cz` and test:
- [ ] Login page loads
- [ ] Login works (users will need to re-login due to new JWT_SECRET)
- [ ] TOTP verification works (if using same encryption key)
- [ ] Dashboard loads with data
- [ ] Offers — list, create, edit, PDF export
- [ ] Orders — list, create from offer, status transitions
- [ ] Invoices — list, create, PDF with QR code
- [ ] Projects — list, create, file manager (upload/download)
- [ ] Attendance — clock in/out, admin view, print
- [ ] Trips — list, history
- [ ] Settings — company settings, users, roles
### 9.2 Check logs for errors
```bash
pm2 logs boha-app-ts --lines 50
```
---
## Phase 10: Cutover Strategy
Both apps run simultaneously on the same database. Recommended approach:
1. **Week 1:** Run both apps. Use the TS app for daily work. Fall back to PHP if issues arise.
2. **Week 2:** If stable, redirect the main domain to the TS app.
3. **Week 3:** Stop the PHP app.
To redirect the PHP domain to the TS app, update the nginx config for the PHP domain to proxy to port 3001 instead.
---
## Future Updates
When you push code changes:
```bash
# On dev machine
npm run build
git push
# On server
cd /var/www/boha-app-ts
git pull
npm install --production
npx prisma generate
npx prisma migrate deploy # runs any new migrations
pm2 restart boha-app-ts
```
---
## Troubleshooting
| Problem | Solution |
|---------|----------|
| `EADDRINUSE: port 3001` | `pm2 stop boha-app-ts` then `pm2 start` |
| Prisma connection error | Check `DATABASE_URL` in `.env` |
| TOTP verification fails | Verify `TOTP_ENCRYPTION_KEY` matches the key used to encrypt secrets |
| NAS files not accessible | Check mount: `ls /mnt/nas/02_PROJEKTY`, verify permissions |
| 502 Bad Gateway | App not running: `pm2 status`, check logs: `pm2 logs` |
| CSS/JS not loading | Verify `dist-client/` was copied, check `APP_ENV=production` |
| CORS errors | Check `CORS_ORIGINS` in `.env` matches your domain exactly |
---
## Quick Reference
| Command | Purpose |
|---------|---------|
| `pm2 start ecosystem.config.js` | Start the app |
| `pm2 restart boha-app-ts` | Restart after update |
| `pm2 stop boha-app-ts` | Stop the app |
| `pm2 logs boha-app-ts` | View logs |
| `pm2 monit` | Live monitoring |
| `npx prisma migrate deploy` | Apply database migrations |
| `npx prisma studio` | Database GUI (dev only) |

96
docs/release.md Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
# Deferred HIGH issues — performance & UX sprint
**Created:** 2026-06-03
**Source audit:** Parallel 5-agent production risk audit (2026-06-03)
**Context:** All 8 CRITICAL and 3 of 8 HIGH issues were fixed in the same session.
The 5 items below were intentionally deferred — they are not data-integrity or
auth risks, but they are real production pain that should be addressed in a
dedicated performance / UX sprint before the user base notices.
---
## #9 — N+1 queries in warehouse list/detail/report
**Files:**
- `src/services/warehouse.service.ts:184-199` (getBelowMinimumItems)
- `src/services/warehouse.service.ts:629-646` (somewhere in items list)
- `src/services/warehouse.service.ts:681-683`
- `src/services/warehouse.service.ts:2182-2203` (report)
**Pattern:** `items.map(async (i) => { const stock = await getStock(i.id); const available = await getAvailable(i.id); const value = await getValue(i.id); })` — fires N+1 round-trips per item. For a list of 50 items, that's 150 sequential queries.
**Production impact:** Slow page loads (1-3s on 50 items, scales linearly). Users already complain about the items page; this is the root cause for the warehouse report being the slowest page in the app.
**Fix sketch:**
- Replace the `for` loop with a single grouped query: `prisma.sklad_batches.groupBy({ by: ['item_id'], _sum: { quantity: true }, where: { item_id: { in: itemIds } } })` for stocks, similar aggregates for reservations.
- Map the grouped results back to items in JS. One query instead of N.
- For the report view, do the same — group all batches and reservations by item_id in a single round-trip, then join with items in memory.
- Estimated effort: M (4-6 hours including testing).
---
## #10 — Missing FK indexes on hot warehouse tables
**File:** `prisma/schema.prisma` (no `@@index` directives on the new warehouse tables)
**Missing indexes (verify against `prisma/schema.prisma` and add to a new migration):**
- `sklad_receipt_lines.item_id` — every `confirmReceipt` reads/writes this
- `sklad_issue_lines.batch_id` — every `confirmIssue` reads/writes this
- `sklad_issue_lines.item_id`
- `sklad_reservations.project_id` — reservation list per project
- `sklad_reservations.item_id`
- `sklad_item_locations.item_id`
- `sklad_item_locations.location_id`
- `sklad_batches.receipt_line_id` (if nullable FK)
- `sklad_inventory_lines.item_id`
- `sklad_inventory_lines.session_id`
**Production impact:** MySQL currently does table scans for these joins. With even 10k batch rows, the receipts/issues/reservations pages will start to crawl. On production data (likely already 50k+ batches), this is a real performance cliff.
**Fix sketch:**
- Add `@@index([item_id])`, `@@index([batch_id])` etc. to each model in `prisma/schema.prisma`.
- Run `npx prisma migrate dev --name warehouse_fk_indexes` (after asking user to stop dev server per CLAUDE.md).
- Run `npx prisma generate`.
- Verify with `EXPLAIN` on the slow queries after deploy.
- Estimated effort: S (1-2 hours).
**CLAUDE.md note:** Before running `prisma migrate dev`, the user must stop the dev server.
---
## #13 — Pagination does not auto-adjust after delete
**Files:** All list pages (Invoices, Offers, Projects, Orders, WarehouseXxx)
**Pattern:** User is on page 5 (e.g. 10 items per page, 47 total, pages 1-5). They delete the last item on page 5. Total becomes 46, but the page param is still 5. The list endpoint returns 0 results, the user sees an empty table, and the "next" button is disabled but there's no automatic jump back to page 4.
**Production impact:** Confusing UX. Users have to manually click "previous" or reset filters. Doesn't cause data loss but is a small papercut that erodes trust.
**Fix sketch:**
- In each list page's `useQuery` error/empty handler, detect `pagination.total === 0 && page > 1` and call `setPage(page - 1)`.
- Or more cleanly: have the API include `pagination.total_pages` and the frontend clamp `page` to `min(page, total_pages)` after each refetch.
- Estimated effort: S (2-3 hours; touches ~10-12 pages).
---
## #14 — Offer lock lifecycle has multiple silent failures
**File:** `src/admin/pages/OfferDetail.tsx:472-516`
**Pattern:** Lock acquire, heartbeat (interval), and unlock all use `.catch(() => {})`. If any of them silently fails, the user thinks they have the lock but actually don't — or worse, the lock is held by a tab that's already closed because the unload handler also swallowed the error.
**Production impact:** Two users editing the same offer can both see "You have the lock" and clobber each other's changes. Data loss in a real, low-probability but high-impact scenario.
**Fix sketch:**
- Replace `.catch(() => {})` with `.catch((err) => console.error("Offer lock error:", err))` at minimum.
- Better: surface lock acquisition failures via a toast (`alert.error("Nepodařilo se získat zámek, stránka bude pouze pro čtení.")`).
- For the unlock on unmount: best-effort `navigator.sendBeacon` or a synchronous fallback so the lock is released even if the page is closing.
- Estimated effort: S (1-2 hours).
---
## #11 (audit item, not a real bug) — TOTP replay counter rewind
**File investigated:** `src/utils/totp.ts:5-30`, `src/routes/admin/auth.ts:165-184`
**Status:** False positive. The audit suggested `verifyResult.counter` could be lower than the actual step used. After reading the implementation:
```ts
const delta = totp.validate({ token: code, window: 1 });
// ...
const counterDelta = Math.min(delta, 0); // -1 or 0, never +1
const counter = currentCounter + counterDelta;
```
`Math.min(delta, 0)` clamps to ≤ 0, so `counter` is always the **current** step or **one step in the past** — never a future step. The `counter <= lastCounter` check in the route is correct and complete. No fix needed.
If anyone revisits this in the future, this analysis is the basis for closing the ticket.
---
## Suggested order for the next sprint
1. **#10 (indexes)** — cheap, high impact, addresses a growing data cliff. Do first.
2. **#9 (N+1)** — bigger effort but the same root cause family. Tackle after indexes.
3. **#14 (offer lock)** — small but prevents data loss; fits in a single PR.
4. **#13 (pagination)** — UX polish; do last, batch with other UX cleanup.
Estimated total: 1-2 days of focused work.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,770 @@
# Warehouse (Sklad) Module Design
**Date:** 2026-05-29
**Status:** Approved
**Module:** Warehouse/Inventory management for boha-app-ts
---
## Overview
Full warehouse management module under the Administration section. Tracks material receiving, issuing, reservations, inventory, and project assignment. Uses true FIFO with batch-level tracking, document-driven architecture (header + lines), and integrates with existing projects, number sequences, and NAS file storage.
---
## Requirements
### Movements
- **Receipt (Prijem):** Material enters warehouse. Multi-item document with delivery note attachment.
- **Issue (Vydej):** Material leaves warehouse, mandatory project assignment. Multi-item document.
- **Reservation:** Virtual hold on stock before issue. Reduces available qty but not physical stock. Issue consumes reservation.
- **Inventory (Inventura):** Corrective movements based on physical count vs system count.
- **Storno:** Cancel a confirmed receipt or issue. Creates opposite corrective movements, preserves audit trail.
### Catalog
- Each material has: catalog number, name, description, category, unit, min quantity.
- Categories are user-defined (CRUD).
- Units are fixed list: ks, m, kg, bal, sada, m2, m3, l.
- Items can be soft-deactivated (is_active=false).
### Pricing
- Purchase price per unit, recorded bez DPH.
- True FIFO: each receipt line creates a batch. Issues consume oldest batches first.
- Batch-level tracking with remaining quantity.
### Delivery Notes
- File attachment (PDF/image) uploaded to NAS.
- Delivery note number and date stored as metadata on receipt header.
### Suppliers
- Separate `sklad_suppliers` table (ICO, DIC, contact info).
- Not shared with customers table.
### Locations
- Warehouse has named locations/shelves (code + name, e.g. "A1 - Regal A, police 1").
- Junction table tracks qty per item per location.
### Projects
- Every issue is mandatory FK to `projects` table.
- No free/issues without project assignment.
### Min Stock Alerts
- Each item has optional `min_quantity`.
- Dashboard highlights items below minimum.
- No email notification (UI alert only).
### Immutability
- All movements are immutable once confirmed.
- Corrections via storno (cancel receipt/issue) which creates opposite movements.
- Draft movements can be edited freely.
### Numbering
- Auto-numbering via `number_sequences` on confirm.
- Types: `warehouse_receipt` (e.g. PRI-2026-001), `warehouse_issue` (e.g. VYD-2026-001).
### Issue Documents
- No PDF generation for issues. Record only in system.
### Reports
- Stock status: all items with qty, min_qty, value, below-min flag.
- Project consumption: material consumed per project (filter by project, date range).
- Movement log: all receipts + issues with filters.
- Below minimum: items under min_quantity.
---
## Architecture: Document-Driven
Each movement type is a document (header + lines), matching the existing invoice/offer pattern.
### Document Flow
```
DRAFT → CONFIRMED (affects stock)
DRAFT → CANCELLED (no stock effect)
CONFIRMED → CANCELLED (storno: reverses stock changes)
```
Only CONFIRMED documents affect `sklad_batches`, `sklad_item_locations`, and reservations.
---
## Database Schema
### Enums
```prisma
enum sklad_receipt_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_issue_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_reservation_status {
ACTIVE
FULFILLED
CANCELLED
}
enum sklad_inventory_status {
DRAFT
CONFIRMED
}
```
### Master Data
```prisma
model sklad_categories {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
description String? @db.Text
sort_order Int @default(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_items[]
@@map("sklad_categories")
}
model sklad_suppliers {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
ico String? @db.VarChar(20)
dic String? @db.VarChar(20)
contact_person String? @db.VarChar(255)
email String? @db.VarChar(255)
phone String? @db.VarChar(50)
address String? @db.Text
notes String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[]
@@map("sklad_suppliers")
}
model sklad_locations {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(20)
name String @db.VarChar(100)
description String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_item_locations[]
@@map("sklad_locations")
}
model sklad_items {
id Int @id @default(autoincrement())
item_number String? @unique @db.VarChar(50)
name String @db.VarChar(255)
description String? @db.Text
category_id Int?
unit String @db.VarChar(20)
min_quantity Decimal? @db.Decimal(12, 3)
is_active Boolean @default(true)
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
category sklad_categories? @relation(fields: [category_id], references: [id])
batches sklad_batches[]
item_locations sklad_item_locations[]
@@index([category_id], map: "sklad_items_category_id")
@@map("sklad_items")
}
```
### FIFO Batches & Location Tracking
```prisma
model sklad_batches {
id Int @id @default(autoincrement())
item_id Int
receipt_line_id Int
quantity Decimal @db.Decimal(12, 3)
original_qty Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
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])
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
@@map("sklad_batches")
}
model sklad_item_locations {
id Int @id @default(autoincrement())
item_id Int
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])
@@unique([item_id, location_id], map: "sklad_item_locations_unique")
@@map("sklad_item_locations")
}
```
### Receipts
```prisma
model sklad_receipts {
id Int @id @default(autoincrement())
receipt_number String? @db.VarChar(50)
supplier_id Int?
delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0)
received_by Int?
notes String? @db.Text
status sklad_receipt_status @default(DRAFT)
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(fields: [received_by], references: [id])
lines sklad_receipt_lines[]
attachments sklad_receipt_attachments[]
@@map("sklad_receipts")
}
model sklad_receipt_lines {
id Int @id @default(autoincrement())
receipt_id Int
item_id Int
quantity Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
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])
batch sklad_batches?
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
@@map("sklad_receipt_lines")
}
model sklad_receipt_attachments {
id Int @id @default(autoincrement())
receipt_id Int
file_name String @db.VarChar(255)
file_mime String @db.VarChar(100)
file_size Int
file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
@@map("sklad_receipt_attachments")
}
```
### Issues
```prisma
model sklad_issues {
id Int @id @default(autoincrement())
issue_number String? @db.VarChar(50)
project_id Int
issued_by Int?
notes String? @db.Text
status sklad_issue_status @default(DRAFT)
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(fields: [issued_by], references: [id])
lines sklad_issue_lines[]
@@map("sklad_issues")
}
model sklad_issue_lines {
id Int @id @default(autoincrement())
issue_id Int
item_id Int
batch_id Int
quantity Decimal @db.Decimal(12, 3)
location_id Int?
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])
@@index([issue_id], map: "sklad_issue_lines_issue_id")
@@map("sklad_issue_lines")
}
```
### Reservations
```prisma
model sklad_reservations {
id Int @id @default(autoincrement())
item_id Int
project_id Int
quantity Decimal @db.Decimal(12, 3)
remaining_qty Decimal @db.Decimal(12, 3)
reserved_by Int?
notes String? @db.Text
status sklad_reservation_status @default(ACTIVE)
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(fields: [reserved_by], references: [id])
issue_lines sklad_issue_lines[]
@@index([item_id, status], map: "sklad_reservations_item_status")
@@map("sklad_reservations")
}
```
### Inventory
```prisma
model sklad_inventory_sessions {
id Int @id @default(autoincrement())
session_number String? @db.VarChar(50)
notes String? @db.Text
status sklad_inventory_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
lines sklad_inventory_lines[]
@@map("sklad_inventory_sessions")
}
model sklad_inventory_lines {
id Int @id @default(autoincrement())
session_id Int
item_id Int
location_id Int?
system_qty Decimal @db.Decimal(12, 3)
actual_qty Decimal @db.Decimal(12, 3)
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])
@@index([session_id], map: "sklad_inventory_lines_session_id")
@@map("sklad_inventory_lines")
}
```
**Total: 13 models + 5 enums**
---
## API Endpoints
All under prefix `/api/admin/warehouse`.
### Items (warehouse.manage)
| Method | Path | Description |
| ------ | ------------ | ------------------------------------------------------------- |
| GET | `/items` | List items (paginated, filter by category, search, below-min) |
| GET | `/items/:id` | Item detail with batches, locations, reservations |
| POST | `/items` | Create item |
| PUT | `/items/:id` | Update item |
| DELETE | `/items/:id` | Soft-delete (is_active=false) |
### Categories (warehouse.manage)
| Method | Path | Description |
| ------ | ----------------- | ------------------------- |
| GET | `/categories` | List all |
| POST | `/categories` | Create |
| PUT | `/categories/:id` | Update |
| DELETE | `/categories/:id` | Delete (only if no items) |
### Suppliers (warehouse.manage)
| Method | Path | Description |
| ------ | ---------------- | ----------------------------- |
| GET | `/suppliers` | List (paginated, search) |
| GET | `/suppliers/:id` | Detail |
| POST | `/suppliers` | Create |
| PUT | `/suppliers/:id` | Update |
| DELETE | `/suppliers/:id` | Soft-delete (is_active=false) |
### Locations (warehouse.manage)
| Method | Path | Description |
| ------ | ---------------- | ------------------------------------- |
| GET | `/locations` | List all |
| POST | `/locations` | Create |
| PUT | `/locations/:id` | Update |
| DELETE | `/locations/:id` | Delete (only if no items at location) |
### Receipts (warehouse.operate)
| Method | Path | Description |
| ------ | ----------------------------------------- | ------------------------------------------------------------- |
| GET | `/receipts` | List (paginated, filter by status/supplier/date) |
| GET | `/receipts/:id` | Detail with lines + attachments |
| POST | `/receipts` | Create receipt (DRAFT, with lines) |
| PUT | `/receipts/:id` | Update receipt + lines (DRAFT only) |
| POST | `/receipts/:id/confirm` | Confirm: creates batches, updates locations, generates number |
| POST | `/receipts/:id/cancel` | Cancel: storno batches if confirmed |
| POST | `/receipts/:id/attachments` | Upload delivery note (multipart) |
| DELETE | `/receipts/:id/attachments/:attachmentId` | Delete attachment |
### Issues (warehouse.operate)
| Method | Path | Description |
| ------ | --------------------- | --------------------------------------------------------------------------------------- |
| GET | `/issues` | List (paginated, filter by status/project/date) |
| GET | `/issues/:id` | Detail with lines |
| POST | `/issues` | Create issue (DRAFT, with lines) |
| PUT | `/issues/:id` | Update issue + lines (DRAFT only) |
| POST | `/issues/:id/confirm` | Confirm: decrements batches, updates locations, fulfills reservations, generates number |
| POST | `/issues/:id/cancel` | Cancel: restores batch quantities if confirmed |
### Reservations (warehouse.operate)
| Method | Path | Description |
| ------ | -------------------------- | ------------------------------------ |
| GET | `/reservations` | List (filter by item/project/status) |
| POST | `/reservations` | Create reservation |
| POST | `/reservations/:id/cancel` | Cancel reservation |
### Inventory (warehouse.inventory)
| Method | Path | Description |
| ------ | --------------------------------- | --------------------------------------- |
| GET | `/inventory-sessions` | List sessions |
| GET | `/inventory-sessions/:id` | Detail with lines |
| POST | `/inventory-sessions` | Create session (system_qty auto-filled) |
| PUT | `/inventory-sessions/:id` | Update lines (DRAFT only) |
| POST | `/inventory-sessions/:id/confirm` | Confirm: creates corrective movements |
### Reports (warehouse.view)
| Method | Path | Description |
| ------ | ------------------------------ | ---------------------------------------------------- |
| GET | `/reports/stock-status` | All items with qty, min_qty, value, below-min flag |
| GET | `/reports/project-consumption` | Material per project (filter by project, date range) |
| GET | `/reports/movement-log` | All movements with filters |
| GET | `/reports/below-minimum` | Items below min_quantity |
### Utility (warehouse.view)
| Method | Path | Description |
| ------ | -------------------------- | -------------------------------------- |
| GET | `/items/:id/available-qty` | Available qty (total stock - reserved) |
| GET | `/items/:id/batches` | FIFO batch queue for item |
---
## Business Logic
### Receipt Confirm (single Prisma transaction)
1. Validate status is DRAFT
2. Generate `receipt_number` via `number_sequences` (type: `warehouse_receipt`)
3. For each receipt line:
- Create `sklad_batches` row (quantity = original_qty = line qty, unit_price from line, received_at = now)
- Upsert `sklad_item_locations` (add qty to specified location)
4. Update receipt status to CONFIRMED
5. Log audit
### Receipt Cancel
**If DRAFT:** Set status to CANCELLED. No stock changes.
**If CONFIRMED (storno):**
1. Validate no issue lines have consumed batches from this receipt
2. For each receipt line / batch:
- If batch is fully consumed (is_consumed=true), reject cancellation
- Decrement `sklad_item_locations` by batch remaining quantity
- Delete the batch row
3. Set receipt status to CANCELLED
4. Log audit
### Issue Confirm (single Prisma transaction)
1. Validate status is DRAFT
2. For each issue line, validate: batch has enough remaining quantity
3. Generate `issue_number` via `number_sequences` (type: `warehouse_issue`)
4. For each issue line:
- Decrement `sklad_batches.quantity`, set `is_consumed=true` if 0
- Decrement `sklad_item_locations.quantity`
- If `reservation_id` set, decrement `sklad_reservations.remaining_qty`
5. Check reservations: if remaining_qty = 0, set status to FULFILLED
6. Update issue status to CONFIRMED
7. Log audit
### Issue Cancel
**If DRAFT:** Set status to CANCELLED. No stock changes.
**If CONFIRMED (storno):**
1. For each issue line:
- Increment `sklad_batches.quantity`, set `is_consumed=false` if was true
- Increment `sklad_item_locations.quantity`
- If `reservation_id` set, increment `sklad_reservations.remaining_qty`, set status back to ACTIVE if was FULFILLED
2. Set issue status to CANCELLED
3. Log audit
### Auto-FIFO on Issue Creation
When creating an issue line, if user doesn't pick a specific batch:
- Service selects oldest unconsumed batches for the item (via `sklad_batches_fifo` index)
- May span multiple batches if quantity exceeds one batch's remaining qty
- Frontend shows available batches for manual override
### Reservation Creation
1. Validate item exists and is_active
2. Calculate available_qty = total stock qty - sum of active reservation quantities for this item
3. Validate available_qty >= requested quantity
4. Create reservation with quantity = remaining_qty
5. Does NOT modify `sklad_batches` or `sklad_item_locations`
### Inventory Confirm (single Prisma transaction)
1. Validate status is DRAFT
2. For each inventory line where difference != 0:
- If difference > 0 (more stock than system): create a corrective receipt (type: inventory, auto-confirmed)
- If difference < 0 (less stock than system): create a corrective issue (type: inventory, auto-confirmed)
3. Generate `session_number` via `number_sequences` (type: `warehouse_inventory`)
4. Set status to CONFIRMED
5. Log audit
---
## Frontend
### Sidebar
Entry in `Sidebar.tsx` with permission `warehouse.view`:
- Icon: package/box icon
- Label: "Sklad"
- Path: `/warehouse`
### Pages (lazy-loaded)
| Route | Component | Permission |
| ------------------------------ | ------------------------ | ------------------- |
| `/warehouse` | Warehouse | warehouse.view |
| `/warehouse/items` | WarehouseItems | warehouse.view |
| `/warehouse/items/:id` | WarehouseItemDetail | warehouse.view |
| `/warehouse/receipts` | WarehouseReceipts | warehouse.view |
| `/warehouse/receipts/new` | WarehouseReceiptForm | warehouse.operate |
| `/warehouse/receipts/:id` | WarehouseReceiptDetail | warehouse.view |
| `/warehouse/receipts/:id/edit` | WarehouseReceiptForm | warehouse.operate |
| `/warehouse/issues` | WarehouseIssues | warehouse.view |
| `/warehouse/issues/new` | WarehouseIssueForm | warehouse.operate |
| `/warehouse/issues/:id` | WarehouseIssueDetail | warehouse.view |
| `/warehouse/issues/:id/edit` | WarehouseIssueForm | warehouse.operate |
| `/warehouse/reservations` | WarehouseReservations | warehouse.view |
| `/warehouse/inventory` | WarehouseInventory | warehouse.inventory |
| `/warehouse/inventory/new` | WarehouseInventoryForm | warehouse.inventory |
| `/warehouse/inventory/:id` | WarehouseInventoryDetail | warehouse.inventory |
| `/warehouse/reports` | WarehouseReports | warehouse.view |
| `/warehouse/suppliers` | WarehouseSuppliers | warehouse.manage |
| `/warehouse/locations` | WarehouseLocations | warehouse.manage |
| `/warehouse/categories` | WarehouseCategories | warehouse.manage |
### Warehouse Dashboard
- Summary cards: total items, total stock value, below-minimum count, active reservations
- Below-minimum alert section (highlighted)
- Recent movements (last 10)
### WarehouseReports
- Tab-based: Stock Status | Project Consumption | Movement Log | Below Minimum
- Each tab has filters (date range, project, category, supplier)
- Paginated data tables with sorting
### Key Shared Components
| Component | Purpose |
| ---------------------- | ----------------------------------------------------------------------------- |
| ItemPicker | Searchable item selector (name + catalog number), used in receipt/issue forms |
| BatchPicker | FIFO batch selector for issue lines, shows available qty per batch |
| LocationSelect | Location dropdown |
| SupplierSelect | Supplier dropdown |
| WarehouseMovementTable | Reusable multi-line editor for receipt/issue lines |
### Query Keys
```
["warehouse"] -> invalidates all
["warehouse", "items", filters] -> item list
["warehouse", "items", id] -> item detail
["warehouse", "receipts", filters] -> receipt list
["warehouse", "receipts", id] -> receipt detail
["warehouse", "issues", filters] -> issue list
["warehouse", "issues", id] -> issue detail
["warehouse", "reservations", filters] -> reservation list
["warehouse", "inventory", filters] -> inventory sessions
["warehouse", "inventory", id] -> inventory detail
["warehouse", "suppliers", filters] -> supplier list
["warehouse", "locations"] -> location list
["warehouse", "categories"] -> category list
["warehouse", "reports", type, filters] -> report data
```
Mutation invalidation: broad `["warehouse"]` for any create/update/delete.
---
## Permissions
| Permission | Display Name | Module | Description |
| --------------------- | -------------- | ------ | ------------------------------------------------------ |
| `warehouse.view` | Zobrazit sklad | sklad | View stock status, items, reports, movement history |
| `warehouse.operate` | Prijem a vydej | sklad | Create/confirm receipts, issues, reservations |
| `warehouse.manage` | Sprava skladu | sklad | Manage items catalog, suppliers, locations, categories |
| `warehouse.inventory` | Inventura | sklad | Create/confirm inventory sessions |
---
## Number Sequences
Three new types registered in `number_sequences`:
- `warehouse_receipt` - generated on receipt confirm
- `warehouse_issue` - generated on issue confirm
- `warehouse_inventory` - generated on inventory session confirm
Pattern configurable via `company_settings`, same as invoices.
---
## File Storage
Receipt attachments use existing `NasFinancialsManager`:
- Stored under `warehouse/YYYY/MM/` on NAS
- DB metadata in `sklad_receipt_attachments` (file_name, file_mime, file_size, file_path)
- Upload via `@fastify/multipart`
---
## File Structure
### New Files
```
src/
routes/admin/warehouse.ts
services/warehouse.service.ts
schemas/warehouse.schema.ts
admin/
pages/
Warehouse.tsx
WarehouseItems.tsx
WarehouseItemDetail.tsx
WarehouseReceipts.tsx
WarehouseReceiptDetail.tsx
WarehouseReceiptForm.tsx
WarehouseIssues.tsx
WarehouseIssueDetail.tsx
WarehouseIssueForm.tsx
WarehouseReservations.tsx
WarehouseInventory.tsx
WarehouseInventoryDetail.tsx
WarehouseInventoryForm.tsx
WarehouseReports.tsx
WarehouseSuppliers.tsx
WarehouseLocations.tsx
WarehouseCategories.tsx
lib/queries/warehouse.ts
components/warehouse/
ItemPicker.tsx
BatchPicker.tsx
LocationSelect.tsx
SupplierSelect.tsx
WarehouseMovementTable.tsx
```
### Modified Files
| File | Change |
| ---------------------------------- | ---------------------------------- |
| `src/server.ts` | Import + register warehouse routes |
| `src/types/index.ts` | Add 8 EntityType values |
| `src/admin/AdminApp.tsx` | Lazy imports + routes |
| `src/admin/components/Sidebar.tsx` | Add warehouse menu entry |
| `prisma/schema.prisma` | Add 13 models + 5 enums |
| `prisma/seed.ts` | Add 4 permissions |
---
## Audit Entity Types
New values added to `EntityType` union in `src/types/index.ts`:
- `warehouse_item`
- `warehouse_receipt`
- `warehouse_issue`
- `warehouse_reservation`
- `warehouse_inventory`
- `warehouse_supplier`
- `warehouse_category`
- `warehouse_location`
---
## Constraints & Edge Cases
1. **Batch consumption on cancel:** If any batch from a receipt has been partially or fully consumed by an issue, the receipt cannot be cancelled. User must first cancel the consuming issues.
2. **Reservation availability:** Available qty = sum of active batch quantities - sum of active reservation quantities. Reservation creation validates available_qty >= requested.
3. **Location qty consistency:** `sklad_item_locations` is a denormalized cache for quick lookups. It must always stay in sync with `sklad_batches`. The confirm/cancel transactions maintain this.
4. **FIFO spanning batches:** When auto-FIFO selects batches for an issue line and the quantity spans multiple batches, the service creates **multiple issue lines** (one per batch consumed), all under the same issue document. The user's original requested quantity is preserved across the split lines. This works because each `sklad_issue_lines` row has exactly one `batch_id`.
5. **Inventory corrective movements:** On confirm, each line with a difference creates an auto-confirmed receipt or issue. The `notes` field of these corrective documents references the inventory session ID and line, so they can be traced back. These corrective documents are fully audited like manual receipts/issues.
6. **Unit consistency:** Once an item is created with a unit, it cannot be changed if any movements exist. This prevents unit mismatches in stock calculations.

View File

@@ -0,0 +1,191 @@
# Manual project & order creation — design
Date: 2026-06-06
Status: approved (pending spec review)
Author: Claude + BOHA
## Problem
Today both entities are only ever _derived_ from a parent:
- A **project** is created solely as a side-effect of `createOrderFromQuotation`
(`orders.service.ts`). There is **no** `POST /projects`, no `createProject`
service, no `CreateProjectSchema`. A manual-create feature existed but was
**removed in commit `82919d3`** (2026-04-28) — the note: _"Projects can only
be created through orders (shared numbering sequence)."_ The old code pulled
`project_number` from the shared order/project pool, which consumed an order
number and left **gaps in order numbering**. That is the bug we must not repeat.
- An **order** is normally created from an offer. A manual `createOrder`
(`orders.service.ts`, offer-less) **already exists** and the route already
dispatches to it, but there is **no UI**, and it does not create a project.
We want: (1) manually create projects on `/projects`, (2) manually create
orders without an offer on `/orders`.
## Decisions (locked with the user)
1. **Project numbering: shared pool, _with_ proper tracking.** Standalone
projects take the next **shared** number via `generateSharedNumber()` — the
same pool as orders — but we add the tracking/transparency that was missing
before (see "Tracking" below). Auto-assigned; no manual number entry.
2. **Manual order → project: optional checkbox, default ON.** The order form
has a pre-checked "Vytvořit propojený projekt"; when set, the order and its
project share the order number (identical to the from-offer flow → no gap).
3. **Order form scope: header only.** Customer, customer order number,
currency/VAT, scope title/description, notes. No line-item editor.
4. **Customer: optional** on both forms (matches the existing nullable schema).
5. **Match existing house design**`FormModal` + `FormField`, the
`Vehicles.tsx` create/edit-modal pattern, the `OffersCustomers.tsx`
header-button + customer-selector pattern, existing list/badge styling.
## Why the shared pool is safe here (the tracking)
The shared sequence is effectively a single "zakázkové číslo" pool. An order and
its project share one number; a standalone project simply takes the next number
in that pool and has no order. The mechanics that make this coherent:
- **Collision-safe:** `isSharedNumberTaken()` already checks **both**
`orders.order_number` and `projects.project_number`, so the same number can
never be issued twice across the pool (`numbering.service.ts:161-167`).
- **Atomic:** `createProject` wraps `generateSharedNumber(tx)` in
`prisma.$transaction` — the existing `SELECT … FOR UPDATE` lock
(`getNextSequence`) serialises concurrent consumers.
- **Release on delete:** `deleteProject` already calls `releaseSharedNumber`,
which decrements the sequence only if the deleted number is the current
highest — so deleting a standalone project doesn't leave a permanent tail-gap
(`numbering.service.ts:116-158, 319-328`).
- **Audit trail:** `logAudit` on create/delete records the number in
`newValues`/`oldValues`, so every consumed pool number has a "who/when/what"
trail showing it went to a project (not a lost order).
- **UI transparency:** the Projects list shows a badge — **"z objednávky"**
(order-linked, `order_id != null`) vs **"samostatný"** (standalone) — so a
pool number with no order reads as intentional. The create form previews the
number it is about to assign (`previewSharedNumber()`).
## Part A — Manual projects
### Backend
- **`src/schemas/projects.schema.ts`** — add `CreateProjectSchema`
(`z.strictObject`, shared coercers from `schemas/common.ts`, Czech messages):
- `name` — required, `z.string().min(1)`.
- `customer_id` — optional number (coerced, NaN-guarded).
- `responsible_user_id` — optional number.
- `status` — optional, default `"aktivni"`.
- `start_date`, `end_date` — optional date strings.
- `notes` — optional string.
- **No** `project_number` field (auto-assigned from the pool).
- **`src/services/projects.service.ts`** — add `createProject(data)`:
- `prisma.$transaction(async (tx) => …)`: `project_number = await
generateSharedNumber(tx)`; `tx.projects.create({ … order_id: null,
quotation_id: null, status: data.status ?? "aktivni", … })`.
- Validate `customer_id` / `responsible_user_id` exist (when provided) →
return `{ error, status: 400 }` (Czech) if not.
- After commit: `nasFileManager.createProjectFolder(number, name)` when
`isConfigured()` — non-fatal, logged on failure (mirrors the old code).
- Return `{ data: project }` (or `{ error, status }`).
- **`src/routes/admin/projects.ts`**:
- `POST /` guarded by `requirePermission("projects.create")` →
`parseBody(CreateProjectSchema, …)` → `createProject` → `logAudit`
(`action: "create"`, `entityType: "project"`, `newValues`) →
`success(reply, project, 201, "Projekt vytvořen")`.
- `GET /next-number` guarded by `projects.create` → `previewSharedNumber()` →
`success(reply, { number })`. (Preview only; does not consume.)
### Frontend (`src/admin/pages/Projects.tsx` + new modal)
- Header **"Přidat projekt"** button, gated on `hasPermission("projects.create")`,
matching the `OffersCustomers.tsx` "Přidat zákazníka" header-button pattern.
- A project create `FormModal` (house pattern from `Vehicles.tsx`): `FormField`
rows for name (required), customer (select from customers list), responsible
user (select from `userListOptions`), status, start/end dates, notes, plus a
read-only "Číslo projektu: `<preview>` (přiděleno automaticky)" line fetched
from `GET /projects/next-number` when the modal opens.
- `useApiMutation` POST `/api/admin/projects`; on success invalidate
**`["projects"]`** (broad domain key, per the invalidation convention) and
close modal; surface server errors via `useAlert`.
- List: add the **"z objednávky" / "samostatný"** badge (derive from
`order_id`). Update the empty-state text (currently "Projekt se vytvoří
automaticky při vytvoření objednávky") to mention manual creation.
## Part B — Manual orders (header only, optional project)
### Backend
- **`src/schemas/orders.schema.ts`** — add `create_project` to
`CreateOrderSchema`: boolean via the existing `preprocess(v => v === true || v
=== 1 || v === "1")` idiom, `.optional().default(true)`. (Only the manual path
uses this schema; the from-quotation path uses `CreateOrderFromQuotationSchema`.)
- **`src/services/orders.service.ts` `createOrder`** — inside the existing
transaction, after the order is created, when `body.create_project` is true and
there is no quotation link: `tx.projects.create({ project_number: orderNumber,
order_id: order.id, customer_id: order.customer_id, name: scope_title ??
customer_order_number ?? orderNumber, status: "aktivni" })` — mirrors
`createOrderFromQuotation`'s project block, so order + project share the number.
Return the project id so the route can audit it.
- **`src/routes/admin/orders.ts`** — the manual `POST /` branch already exists
(`orders.create`); add a second `logAudit` for the created project when present.
### Frontend (`src/admin/pages/Orders.tsx` + new modal)
- Header **"Vytvořit objednávku"** button, gated on `orders.create`.
- Header-only `FormModal`: customer (select), customer order number, currency,
VAT rate + apply-VAT, language, scope title, scope description, notes, and a
pre-checked **"Vytvořit propojený projekt"** checkbox. Read-only next
order-number preview via the existing `GET /api/admin/orders/next-number`.
- POST `/api/admin/orders` (no `quotationId`); on success invalidate
**`["orders"]`** and **`["projects"]`**; errors via `useAlert`.
## Part C — Permissions
`projects.create` was removed from the permission set and must come back the
**migration** way (per CLAUDE.md — never seed-only for prod data):
- New migration `prisma/migrations/<ts>_add_projects_create_permission/` that
`INSERT`s the `projects.create` permission row and grants it to the **admin**
role (mirror `20260603230000_add_warehouse_permissions/migration.sql`). Use
`INSERT … ON DUPLICATE KEY UPDATE` / `INSERT IGNORE` style so re-runs are safe.
- Add `projects.create` to the `PERMISSIONS` array in `prisma/seed.ts` (dev).
- `orders.create` already exists — no change.
## Part D — Tests (`src/__tests__/`)
Real-DB Vitest (no Prisma mocks), following `numbering.test.ts` style:
- `createProject` assigns the next shared number, increments the `shared`
sequence by exactly 1, sets `order_id = null`, writes an audit row.
- `createOrder` with `create_project: true` creates an order **and** a project
sharing one `order_number`/`project_number`.
- Interleaved coherence: order → standalone project → order produces three
consecutive shared numbers with no duplicates (the tracking guarantee).
- `createOrder` with `create_project: false` creates only the order.
## Error handling
- Service returns `{ error, status }` (Czech) for bad customer/user FK and
numbering exhaustion (the `generateSharedNumber` 100-retry throw → mapped to a
500 with a Czech message by the route, logged via `app.log.error`).
- NAS folder creation is non-fatal and logged (never blocks creation).
## Out of scope (v1) — noted for later
- Editing order line-items after creation.
- Attaching a standalone project to an order later (conflicts with current
`order_id` immutability + the `has_order` delete guard).
- Manual project-number override (user chose auto-from-pool).
- A unified order+project number lookup/search.
## Affected files (summary)
- `src/schemas/projects.schema.ts` (add `CreateProjectSchema`)
- `src/schemas/orders.schema.ts` (add `create_project`)
- `src/services/projects.service.ts` (add `createProject`)
- `src/services/orders.service.ts` (`createOrder` optional project)
- `src/routes/admin/projects.ts` (`POST /`, `GET /next-number`)
- `src/routes/admin/orders.ts` (audit project on manual order)
- `src/admin/pages/Projects.tsx` (+ create modal, badge, empty-state)
- `src/admin/pages/Orders.tsx` (+ create modal)
- `src/admin/lib/queries/projects.ts`, `orders.ts` (mutations/preview queries)
- `prisma/migrations/<ts>_add_projects_create_permission/migration.sql`
- `prisma/seed.ts` (+ `projects.create`)
- `src/__tests__/manual-create.test.ts` (new)

View File

@@ -0,0 +1,191 @@
# Plan Categories Management — Design Spec
Date: 2026-06-06
Status: Approved (approach A)
Area: Work Plan (`/plan-work`)
## Goal
Let managers (`attendance.manage`) **create, rename, recolor, and retire** the
categories used by the work plan, via a "Správa kategorií" button above the
calendar that opens a form modal with a per-category color picker. Categories
become data, not a hardcoded enum.
## Current state (what's hardcoded today)
- DB: `plan_category` **enum** (`work, preparation, travel, leave, sick,
training, other`) used by `work_plan_entries.category` and
`work_plan_overrides.category` (both `NOT NULL`).
- Validation: `planCategoryEnum` in `src/schemas/plan.schema.ts`.
- Frontend labels: `PLAN_CATEGORIES` / `planCategoryLabel` in
`src/admin/lib/queries/plan.ts`.
- Colors: per-category CSS variables `--plan-tape-<key>` in `src/admin/plan.css`,
applied via `.plan-cell:has(.plan-chip--<key>)::before` (left "tape" bar) and
`.plan-chip--<key>` (chip text/border/background via `color-mix`). 14 rules.
- Server audit: `PLAN_CATEGORY_LABELS_CS` in `src/utils/planAuditDescription.ts`.
- A decorative paper-grain gradient (`plan.css` ~lines 102107) tints the page
background with `--plan-tape-work` / `--plan-tape-training` at 5%. This is
cosmetic and **not** category-semantic.
## Approach (A)
Introduce a `plan_categories` table. Keep the entry/override `category` column
as a **string key** (slug) referencing `plan_categories.key`. Change the column
type from the enum to `VARCHAR(50)`; existing values are preserved verbatim.
"Remove" means **deactivate** (`is_active = false`), never hard-delete, so
historical entries keep resolving their label and color.
Rejected: a real FK `category_id` (needs per-row backfill + read/write churn for
no user-visible benefit); keeping the enum (can't add categories).
## Data model
New table `plan_categories`:
| column | type | notes |
| ---------- | ------------ | -------------------------------------------- |
| id | INT PK AI | |
| key | VARCHAR(50) | UNIQUE, slug, immutable after create |
| label | VARCHAR(100) | Czech display name, editable |
| color | VARCHAR(7) | `#RRGGBB` |
| sort_order | INT | display order; new categories append (max+1) |
| is_active | BOOLEAN | default true; false = retired |
| created_at | DATETIME | default now |
| updated_at | DATETIME | auto |
Column change: `work_plan_entries.category` and `work_plan_overrides.category`
`plan_category` → `VARCHAR(50) NOT NULL`. Drop the `plan_category` enum from the
Prisma schema once no column uses it.
## Migration (one tracked Prisma migration)
`npx prisma migrate dev --name plan_categories` (requires dev server stopped —
ask the user first per CLAUDE.md). The generated `migration.sql` will:
1. `CREATE TABLE plan_categories (...)`.
2. `ALTER` the two `category` columns enum → `VARCHAR(50)`.
3. `INSERT` the 7 seed rows (so production gets them — not seed-only, per the
migration policy). Seed values (key, label, color, sort_order):
| key | label | color | sort |
| ----------- | -------------- | ------- | ---- |
| work | Práce | #2563eb | 1 |
| preparation | Příprava | #0d9488 | 2 |
| travel | Cesta / Montáž | #ca8a04 | 3 |
| leave | Dovolená | #16a34a | 4 |
| sick | Nemoc | #dc2626 | 5 |
| training | Školení | #7c3aed | 6 |
| other | Jiné | #6b7280 | 7 |
After: `npx prisma generate`, commit schema + migration folder.
## API — `/api/admin/plan/categories`
All under the existing plan route file group.
- `GET /` — returns **all** categories (active + inactive), ordered by
`sort_order, id`. Readable by `attendance.record` OR `attendance.manage`
(every grid viewer needs labels/colors, including for retired categories on
historical entries).
- `POST /` — create. `attendance.manage`. Body: `{ label, color }`. `key` is
auto-slugged from `label` (ASCII, lowercased, deduped); `sort_order = max+1`.
- `PATCH /:id` — update `label` / `color` / `is_active`. `attendance.manage`.
`key` is immutable.
- `DELETE /:id` — deactivate (`is_active = false`). `attendance.manage`.
Service layer: `src/services/planCategory.service.ts` (plain async functions,
`{ data }` / `{ error, status }` envelope). Audit-logged via `logAudit` with a
new entity type `plan_category` (add to `EntityType` and the AuditLog +
dashboard `ENTITY_TYPE_LABELS` maps, e.g. "Plán prací kategorie").
Validation (`src/schemas/plan.schema.ts` or a new `planCategory.schema.ts`):
- `label`: required, 1100 chars, trimmed.
- `color`: regex `^#[0-9a-fA-F]{6}$`.
- `is_active`: boolean (PATCH only).
- Entry/override create/update: `category` becomes `z.string().min(1)`; the
**service** validates the key exists and `is_active` (reject unknown/inactive
with a Czech 400).
## Validation rules / edge cases
- **Slug collision:** if the slug derived from a new label already exists,
append `-2`, `-3`, … Keys are never shown to users.
- **At least one active category:** deactivating the last active category is
rejected (Czech 400) so the create form is never empty.
- **Deactivate in use:** allowed. Existing entries keep their key; the category
still renders (GET returns inactive ones) but is hidden from the create
`<select>`.
- **Rename/recolor:** applies to all entries with that key (reference is by
key) — this is the desired "recolor everywhere" behavior.
- **No hard delete** in this iteration (deactivate only). Possible later
enhancement: allow hard-delete when zero entries reference the key.
## Frontend
- **Button** "Správa kategorií" in `PlanWork.tsx` header, rendered only when
`hasPermission("attendance.manage")`.
- **Modal** (`PlanCategoriesModal.tsx`, uses `FormModal`): a row per category =
`label` text input + native `<input type="color">` swatch + active toggle (or
a "retire/restore" control), plus an "add category" row (label + color +
add). Mutations invalidate `["plan", "categories"]` **and** `["plan"]` (so the
grid recolors) and `["dashboard"]`/`["audit-log"]` (audit feed).
- **Category query** `["plan","categories"]` loaded in `PlanWork`, passed to the
grid and cell modal. The create/edit plan `<select>` lists **active**
categories from this query (replacing hardcoded `PLAN_CATEGORIES`); display
uses the **full** map (so retired categories still render).
- `planCategoryLabel` becomes a lookup into the categories map (with raw-key
fallback). `PLAN_CATEGORIES` constant is removed.
## Color rendering (the notable refactor)
Replace the 14 hardcoded per-key rules with a generic, custom-property-driven
treatment:
- `PlanGrid` sets `style={{ "--cat-color": color }}` on the `.plan-cell`
`<button>` when the cell has a category (`color` from the categories map). The
custom property cascades to the chip and is available to the cell's `::before`
tape.
- `plan.css`:
- `.plan-cell::before` tape uses
`background: var(--cat-color, var(--plan-tape-other))` — the graphite
`#6b7280` is the defensive neutral fallback when a cell has no category
color (replaces the seven `:has(.plan-chip--<key>)::before` rules).
- `.plan-chip` (generic) uses `color: var(--cat-color)`,
`border-color: color-mix(in srgb, var(--cat-color) 35%, transparent)`,
`background: color-mix(in srgb, var(--cat-color) 10%, var(--plan-paper))`
(replaces the seven `.plan-chip--<key>` rules).
- Visual output is identical for the existing 7; new categories work with no CSS
changes.
- The decorative paper-grain gradient stays static (keep two literal colors or
the two `--plan-tape-*` vars it needs); it is not category-semantic.
## Audit description
`src/services/plan.service.ts` builds the audit description with the category
label. Since labels now live in the DB, resolve the label from `plan_categories`
by key (alongside the existing `resolvePlanLabels` lookups) and pass the plain
label into `buildPlanAuditDescription`. `src/utils/planAuditDescription.ts`'s
`buildPlanAuditDescription` stays pure (receives the resolved label);
`PLAN_CATEGORY_LABELS_CS`/`planCategoryLabel` there are removed or reduced to a
fallback.
## Testing
Vitest (real DB, per project convention):
- `planCategory.service` CRUD: create (slug generation, sort_order), update
(label/color/active), deactivate, and the "last active category" guard.
- Color/label validation rejects bad hex and empty label.
- Entry create accepts an active key and rejects an unknown/inactive key.
## Out of scope (YAGNI)
- Per-category icons.
- Drag-to-reorder (order by `sort_order, id`; new categories append).
- Hard delete of unused categories (deactivate only).
## Assumptions to confirm
- "Remove" = deactivate (history preserved). ✅ implied by approach A.
- Native browser color picker (`<input type="color">`), not a custom palette.

View File

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

View File

@@ -0,0 +1,98 @@
# Per-item Sleva (% discount) — Offers + Invoices
**Date:** 2026-06-13
**Status:** Approved → implementing
**Scope:** Add a per-line-item percentage discount ("Sleva") to **offers**
(nabídky) and **issued invoices** (Faktury), on both the detail-page item
editor and the PDF. Received invoices, issued orders and orders are NOT
touched.
## Decisions (from brainstorming)
- **Type:** percentage per item (0100), not an absolute amount.
- **Totals/VAT:** the discount reduces the line **net**; on invoices VAT is
computed on the **discounted** net. `net = qty × unit_price × (1 discount/100)`.
- **PDF column is conditional:** render the Sleva column only when at least one
(included) item has `discount > 0`; otherwise the table is identical to today.
- **Detail-page column is always visible** (it's the input).
## Data model
Add to `quotation_items` and `invoice_items`:
```
discount Decimal? @default(0.00) @db.Decimal(5, 2) // percent, 0100
```
Nullable with default `0`, matching the existing money columns. "No sleva" =
`0` or null. One tracked migration; apply to dev `app` and `app_test`, then
`prisma generate`.
## Line math (single rule)
`lineNet = qty × unit_price × (1 clamp(discount,0,100)/100)`
Add a shared backend helper so every site is identical:
```ts
// src/utils/money.ts (new)
export const lineNet = (qty: number, unitPrice: number, discountPct: number) =>
qty * unitPrice * (1 - (discountPct || 0) / 100);
```
## Backend
- **Schemas (Zod):** add `discount` to the offer-item and invoice-item schemas
via `numberInRange(0, 100)` (shared coercer in `common.ts`), optional, default
`0`.
- **offers.service** (`enrichQuotation`): line total uses `lineNet`; document
net ("Celkem bez DPH") sums discounted included lines. Persist `discount` on
item create/update.
- **invoices.service**: every `computeMoney` `getBase` callback
(`computeInvoiceTotals`, monthly-stats `invoiceMonthlyAmount`, per-rate
`vatMap`) returns `lineNet(...)` so subtotal **and** VAT use the discounted
net. Persist `discount` on item create/update.
- **DocumentItemsEditor's `documentItemsTotal`** (frontend) mirrors the same
discounted net.
## Frontend (Section 1 — detail-page editor)
- **Offers** → shared `src/admin/components/document/DocumentItemsEditor.tsx`:
`DocumentItem` gains `discount`; add a **"Sleva %"** numeric column (0100);
per-line total and the footer net total apply it. Mobile card layout gets the
field. `emptyDocumentItem` defaults `discount: 0`. Hydration + save payloads
carry `discount`.
- **Invoices** → `src/admin/pages/InvoiceDetail.tsx` own editor
(`SortableInvoiceRow` + create/edit rows): `InvoiceItem` gains `discount`;
same column; net/VAT/gross totals apply it. Mobile layout too.
## PDF (Section 2 — conditional column)
`src/routes/admin/offers-pdf.ts` and `src/routes/admin/invoices-pdf.ts`:
- `const hasDiscount = items.some(i => includedInTotal(i) && Number(i.discount) > 0)`.
- When `hasDiscount`: add a column — header **"Sleva"** (cs) / **"Discount"**
(en, from the document's `language`), cell renders `"<n> %"` (escaped); adjust
colspans of any full-width rows accordingly.
- Line net / document total / VAT always use the discounted net regardless of
`hasDiscount`.
- `escapeHtml` every interpolation (string-built PDF templates).
## Tests
- `offer-invoice-totals` / invoice totals: net, VAT and gross with discounts
(incl. a 100% line = 0, and VAT-on-discounted-net).
- Schema: `discount` rejects <0 and >100, coerces form strings, defaults 0.
- PDF: column present when an item has a discount; absent when none; values
escaped.
## Non-goals (YAGNI)
- No separate "total discount" summary line, no document-level discount.
- No changes to received invoices, issued orders, or orders.
## Migration etiquette
DB change → user stops the dev server first; create the migration via the
non-interactive `prisma migrate diff` recipe, `migrate deploy` + `generate` on
dev and `app_test`, commit schema + migration folder together.

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.0", "version": "2.4.36",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {
@@ -14,7 +14,6 @@
"preview": "vite preview", "preview": "vite preview",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:pull": "prisma db pull", "db:pull": "prisma db pull",
"db:push": "prisma db push",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
-- Odin Phase 2a: assistant turns carry structured metadata (tool-call trace,
-- later full content blocks). `content` stays the plain-text display
-- projection; `content_json` holds the structured form. Anticipated by the
-- Phase-2 design notes (2026-06-08).
-- AlterTable
ALTER TABLE `ai_chat_messages` ADD COLUMN `content_json` LONGTEXT NULL AFTER `content`;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `project_notes` ADD COLUMN `edited_at` DATETIME(0) NULL;

View File

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

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE `invoice_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;
-- AlterTable
ALTER TABLE `quotation_items` ADD COLUMN `discount` DECIMAL(5, 2) NULL DEFAULT 0.00;

View File

@@ -7,29 +7,29 @@ datasource db {
} }
model attendance { model attendance {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user_id Int user_id Int
shift_date DateTime @db.Date shift_date DateTime @db.Date
arrival_time DateTime? @db.DateTime(0) arrival_time DateTime? @db.DateTime(0)
arrival_lat Decimal? @db.Decimal(10, 8) arrival_lat Decimal? @db.Decimal(10, 8)
arrival_lng Decimal? @db.Decimal(11, 8) arrival_lng Decimal? @db.Decimal(11, 8)
arrival_accuracy Decimal? @db.Decimal(10, 2) arrival_accuracy Decimal? @db.Decimal(10, 2)
arrival_address String? @db.VarChar(500) arrival_address String? @db.VarChar(500)
break_start DateTime? @db.DateTime(0) break_start DateTime? @db.DateTime(0)
break_end DateTime? @db.DateTime(0) break_end DateTime? @db.DateTime(0)
departure_time DateTime? @db.DateTime(0) departure_time DateTime? @db.DateTime(0)
departure_lat Decimal? @db.Decimal(10, 8) departure_lat Decimal? @db.Decimal(10, 8)
departure_lng Decimal? @db.Decimal(11, 8) departure_lng Decimal? @db.Decimal(11, 8)
departure_accuracy Decimal? @db.Decimal(10, 2) departure_accuracy Decimal? @db.Decimal(10, 2)
departure_address String? @db.VarChar(500) departure_address String? @db.VarChar(500)
notes String? @db.Text notes String? @db.Text
project_id Int? project_id Int?
leave_type attendance_leave_type? @default(work) leave_type attendance_leave_type? @default(work)
leave_hours Decimal? @db.Decimal(4, 2) leave_hours Decimal? @db.Decimal(4, 2)
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0) updated_at DateTime? @default(now()) @db.Timestamp(0)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1") users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "attendance_ibfk_1")
attendance_project_logs attendance_project_logs[] attendance_project_logs attendance_project_logs[]
@@index([user_id, shift_date], map: "idx_attendance_user_date") @@index([user_id, shift_date], map: "idx_attendance_user_date")
@@index([user_id, departure_time], map: "idx_attendance_user_departure") @@index([user_id, departure_time], map: "idx_attendance_user_departure")
@@ -65,7 +65,9 @@ model audit_logs {
new_values String? @db.LongText new_values String? @db.LongText
user_agent String? @db.Text user_agent String? @db.Text
session_id String? @db.VarChar(128) session_id String? @db.VarChar(128)
created_at DateTime? @default(now()) @db.Timestamp(0) // DATETIME, not TIMESTAMP — TIMESTAMP dies in 2038 and converts through
// the session timezone (2026-06-12 hardening migration).
created_at DateTime? @default(now()) @db.DateTime(0)
@@index([action], map: "idx_audit_logs_action") @@index([action], map: "idx_audit_logs_action")
@@index([created_at], map: "idx_audit_logs_created") @@index([created_at], map: "idx_audit_logs_created")
@@ -104,6 +106,7 @@ model ai_chat_messages {
conversation_id Int conversation_id Int
role String @db.VarChar(20) role String @db.VarChar(20)
content String @db.Text content String @db.Text
content_json String? @db.LongText
created_at DateTime @default(now()) @db.Timestamp(0) created_at DateTime @default(now()) @db.Timestamp(0)
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade) conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
@@ -127,54 +130,54 @@ model bank_accounts {
} }
model company_settings { model company_settings {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
company_name String? @db.VarChar(255) company_name String? @db.VarChar(255)
street String? @db.VarChar(255) street String? @db.VarChar(255)
city String? @db.VarChar(255) city String? @db.VarChar(255)
postal_code String? @db.VarChar(20) postal_code String? @db.VarChar(20)
country String? @db.VarChar(100) country String? @db.VarChar(100)
company_id String? @db.VarChar(50) company_id String? @db.VarChar(50)
vat_id String? @db.VarChar(50) vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText custom_fields String? @db.LongText
logo_data Bytes? logo_data Bytes?
logo_data_dark Bytes? @db.MediumBlob logo_data_dark Bytes? @db.MediumBlob
quotation_prefix String? @db.VarChar(20) quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10) default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
uuid String? @unique @db.VarChar(36) uuid String? @unique @db.VarChar(36)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false) is_deleted Boolean? @default(false)
sync_version Int? @default(0) sync_version Int? @default(0)
order_type_code String? @db.VarChar(10) order_type_code String? @db.VarChar(10)
invoice_type_code String? @db.VarChar(10) invoice_type_code String? @db.VarChar(10)
require_2fa Boolean @default(false) require_2fa Boolean @default(false)
break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2) break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2)
break_duration_short Int? @default(15) break_duration_short Int? @default(15)
break_duration_long Int? @default(30) break_duration_long Int? @default(30)
clock_rounding_minutes Int? @default(15) clock_rounding_minutes Int? @default(15)
invoice_alert_email String? @db.VarChar(255) invoice_alert_email String? @db.VarChar(255)
leave_notify_email String? @db.VarChar(255) leave_notify_email String? @db.VarChar(255)
max_login_attempts Int? @default(5) max_login_attempts Int? @default(5)
lockout_minutes Int? @default(15) lockout_minutes Int? @default(15)
max_requests_per_minute Int? @default(300) max_requests_per_minute Int? @default(300)
available_vat_rates String? @db.LongText available_vat_rates String? @db.LongText
available_currencies String? @db.LongText available_currencies String? @db.LongText
smtp_from String? @db.VarChar(255) smtp_from String? @db.VarChar(255)
smtp_from_name String? @db.VarChar(255) smtp_from_name String? @db.VarChar(255)
offer_number_pattern String? @db.VarChar(100) offer_number_pattern String? @db.VarChar(100)
order_number_pattern String? @db.VarChar(100) order_number_pattern String? @db.VarChar(100)
invoice_number_pattern String? @db.VarChar(100) invoice_number_pattern String? @db.VarChar(100)
issued_order_number_pattern String? @db.VarChar(100) issued_order_number_pattern String? @db.VarChar(100)
issued_order_type_code String? @db.VarChar(10) issued_order_type_code String? @db.VarChar(10)
project_number_pattern String? @db.VarChar(100) project_number_pattern String? @db.VarChar(100)
project_type_code String? @db.VarChar(10) project_type_code String? @db.VarChar(10)
warehouse_receipt_prefix String? @db.VarChar(20) warehouse_receipt_prefix String? @db.VarChar(20)
warehouse_receipt_number_pattern String? @db.VarChar(100) warehouse_receipt_number_pattern String? @db.VarChar(100)
warehouse_issue_prefix String? @db.VarChar(20) warehouse_issue_prefix String? @db.VarChar(20)
warehouse_issue_number_pattern String? @db.VarChar(100) warehouse_issue_number_pattern String? @db.VarChar(100)
warehouse_inventory_prefix String? @db.VarChar(20) warehouse_inventory_prefix String? @db.VarChar(20)
warehouse_inventory_number_pattern String? @db.VarChar(100) warehouse_inventory_number_pattern String? @db.VarChar(100)
ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2) ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2)
} }
model customers { model customers {
@@ -198,49 +201,50 @@ model customers {
} }
model invoice_items { model invoice_items {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
invoice_id Int invoice_id Int
description String? @db.VarChar(500) description String? @db.VarChar(500)
item_description String? @db.Text item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3) quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20) unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2) unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) discount Decimal? @default(0.00) @db.Decimal(5, 2)
position Int? @default(0) vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1") position Int? @default(0)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
@@index([invoice_id], map: "invoice_id") @@index([invoice_id], map: "invoice_id")
} }
model invoices { model invoices {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50) invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
order_id Int? order_id Int?
customer_id Int? customer_id Int?
status String? @default("issued") @db.VarChar(30) status String? @default("issued") @db.VarChar(30)
currency String? @default("CZK") @db.VarChar(10) currency String? @default("CZK") @db.VarChar(10)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true) apply_vat Boolean? @default(true)
payment_method String? @db.VarChar(50) payment_method String? @db.VarChar(50)
constant_symbol String? @db.VarChar(20) constant_symbol String? @db.VarChar(20)
bank_name String? @db.VarChar(255) bank_name String? @db.VarChar(255)
bank_swift String? @db.VarChar(20) bank_swift String? @db.VarChar(20)
bank_iban String? @db.VarChar(50) bank_iban String? @db.VarChar(50)
bank_account String? @db.VarChar(50) bank_account String? @db.VarChar(50)
issue_date DateTime? @db.Date issue_date DateTime? @db.Date
due_date DateTime? @db.Date due_date DateTime? @db.Date
tax_date DateTime? @db.Date tax_date DateTime? @db.Date
paid_date DateTime? @db.Date paid_date DateTime? @db.Date
issued_by String? @db.VarChar(255) issued_by String? @db.VarChar(255)
billing_text String? @db.VarChar(500) billing_text String? @db.VarChar(500)
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
notes String? @db.Text internal_notes String? @db.Text
internal_notes String? @db.Text created_at DateTime? @default(now()) @db.DateTime(0)
created_at DateTime? @default(now()) @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) invoice_items invoice_items[]
invoice_items invoice_items[] invoice_sections invoice_sections[]
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_1") orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_2") customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_2")
@@index([customer_id], map: "customer_id") @@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date") @@index([due_date], map: "idx_invoices_due_date")
@@ -249,6 +253,19 @@ model invoices {
@@index([order_id], map: "order_id") @@index([order_id], map: "order_id")
} }
model invoice_sections {
id Int @id @default(autoincrement())
invoice_id Int
position Int? @default(0)
title String? @db.VarChar(500)
title_cz String? @db.VarChar(500)
content String? @db.Text
modified_at DateTime? @db.DateTime(0)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_sections_fk")
@@index([invoice_id], map: "invoice_sections_invoice_id")
}
model item_templates { model item_templates {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String? @db.VarChar(255) name String? @db.VarChar(255)
@@ -347,8 +364,6 @@ model orders {
status String? @default("prijata") @db.VarChar(30) status String? @default("prijata") @db.VarChar(30)
currency String? @default("CZK") @db.VarChar(10) currency String? @default("CZK") @db.VarChar(10)
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4) exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
scope_title String? @db.VarChar(500) scope_title String? @db.VarChar(500)
scope_description String? @db.Text scope_description String? @db.Text
@@ -367,31 +382,45 @@ model orders {
} }
model issued_orders { model issued_orders {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50) po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
supplier_id Int? supplier_id Int?
status issued_orders_status @default(draft) status issued_orders_status @default(draft)
currency String? @default("CZK") @db.VarChar(10) currency String? @default("CZK") @db.VarChar(10)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
apply_vat Boolean? @default(true) order_date DateTime? @db.Date
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4) delivery_date DateTime? @db.Date
order_date DateTime? @db.Date language String? @default("cs") @db.VarChar(5)
delivery_date DateTime? @db.Date order_text String? @db.VarChar(500)
language String? @default("cs") @db.VarChar(5) internal_notes String? @db.Text
delivery_terms String? @db.VarChar(500) locked_by Int?
payment_terms String? @db.VarChar(500) locked_at DateTime? @db.DateTime(0)
issued_by String? @db.VarChar(255) created_at DateTime? @default(now()) @db.DateTime(0)
notes String? @db.Text modified_at DateTime? @db.DateTime(0)
internal_notes String? @db.Text issued_order_items issued_order_items[]
created_at DateTime? @default(now()) @db.DateTime(0) issued_order_sections issued_order_sections[]
modified_at DateTime? @db.DateTime(0) suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
issued_order_items issued_order_items[]
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([supplier_id], map: "issued_orders_supplier_id")
@@index([status, order_date], map: "idx_issued_orders_status_date") @@index([status, order_date], map: "idx_issued_orders_status_date")
} }
// Mirrors scope_sections (offers) 1:1 — free-form bilingual rich-text sections
// rendered on their own PDF page. Kept as a separate table (not shared) so the
// FK cascade and per-document ordering stay simple.
model issued_order_sections {
id Int @id @default(autoincrement())
issued_order_id Int
position Int? @default(0)
title String? @db.VarChar(500)
title_cz String? @db.VarChar(500)
content String? @db.Text
modified_at DateTime? @db.DateTime(0)
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_sections_fk")
@@index([issued_order_id], map: "issued_order_sections_order_id")
}
model issued_order_items { model issued_order_items {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
issued_order_id Int issued_order_id Int
@@ -400,7 +429,6 @@ model issued_order_items {
quantity Decimal? @default(1.000) @db.Decimal(12, 3) quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20) unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2) unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
position Int? @default(0) position Int? @default(0)
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1") issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
@@ -432,35 +460,36 @@ model project_notes {
user_name String? @db.VarChar(100) user_name String? @db.VarChar(100)
content String? @db.Text content String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
edited_at DateTime? @db.DateTime(0)
projects projects @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "project_notes_ibfk_1") projects projects @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "project_notes_ibfk_1")
@@index([project_id], map: "project_id") @@index([project_id], map: "project_id")
} }
model projects { model projects {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
project_number String? @unique @db.VarChar(50) project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255) name String? @db.VarChar(255)
customer_id Int? customer_id Int?
responsible_user_id Int? responsible_user_id Int?
quotation_id Int? quotation_id Int?
order_id Int? order_id Int?
status String? @default("aktivni") @db.VarChar(30) status String? @default("aktivni") @db.VarChar(30)
start_date DateTime? @db.Date start_date DateTime? @db.Date
end_date DateTime? @db.Date end_date DateTime? @db.Date
notes String? @db.Text notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[] attendance_project_logs attendance_project_logs[]
project_notes project_notes[] project_notes project_notes[]
sklad_issues sklad_issues[] sklad_issues sklad_issues[]
sklad_reservations sklad_reservations[] sklad_reservations sklad_reservations[]
work_plan_entries work_plan_entries[] work_plan_entries work_plan_entries[]
work_plan_overrides work_plan_overrides[] work_plan_overrides work_plan_overrides[]
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user") users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_1") customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_1")
quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_2") quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_2")
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_3") orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_3")
@@index([customer_id], map: "customer_id") @@index([customer_id], map: "customer_id")
@@index([responsible_user_id], map: "fk_projects_responsible_user") @@index([responsible_user_id], map: "fk_projects_responsible_user")
@@ -477,6 +506,7 @@ model quotation_items {
quantity Decimal? @default(1.000) @db.Decimal(12, 3) quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20) unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2) unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
discount Decimal? @default(0.00) @db.Decimal(5, 2)
is_included_in_total Boolean? @default(true) is_included_in_total Boolean? @default(true)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1") quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
@@ -485,28 +515,26 @@ model quotation_items {
} }
model quotations { model quotations {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
quotation_number String? @unique @db.VarChar(50) quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(100) project_code String? @db.VarChar(100)
customer_id Int? customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
valid_until DateTime? @db.Date valid_until DateTime? @db.Date
currency String? @default("CZK") @db.VarChar(10) currency String? @default("CZK") @db.VarChar(10)
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) order_id Int?
apply_vat Boolean? @default(true) status String @default("active") @db.VarChar(20)
order_id Int? scope_title String? @db.VarChar(500)
status String @default("active") @db.VarChar(20) scope_description String? @db.Text
scope_title String? @db.VarChar(500) locked_by Int?
scope_description String? @db.Text locked_at DateTime? @db.DateTime(0)
locked_by Int? modified_at DateTime? @db.DateTime(0)
locked_at DateTime? @db.DateTime(0) orders orders[]
modified_at DateTime? @db.DateTime(0) projects projects[]
orders orders[] quotation_items quotation_items[]
projects projects[] customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "quotations_ibfk_1")
quotation_items quotation_items[] scope_sections scope_sections[]
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "quotations_ibfk_1")
scope_sections scope_sections[]
@@index([customer_id], map: "customer_id") @@index([customer_id], map: "customer_id")
@@index([quotation_number], map: "idx_quotations_number") @@index([quotation_number], map: "idx_quotations_number")
@@ -580,14 +608,14 @@ model roles {
} }
model scope_sections { model scope_sections {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
quotation_id Int quotation_id Int
position Int? @default(0) position Int? @default(0)
title String? @db.VarChar(500) title String? @db.VarChar(500)
title_cz String? @db.VarChar(500) title_cz String? @db.VarChar(500)
content String? @db.Text content String? @db.Text
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1") quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
@@index([quotation_id], map: "quotation_id") @@index([quotation_id], map: "quotation_id")
} }
@@ -656,38 +684,38 @@ model trips {
} }
model users { model users {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
username String @unique(map: "username") @db.VarChar(50) username String @unique(map: "username") @db.VarChar(50)
email String @unique(map: "email") @db.VarChar(255) email String @unique(map: "email") @db.VarChar(255)
password_hash String @db.VarChar(255) password_hash String @db.VarChar(255)
first_name String @db.VarChar(50) first_name String @db.VarChar(50)
last_name String @db.VarChar(50) last_name String @db.VarChar(50)
role_id Int? role_id Int?
is_active Boolean? @default(true) is_active Boolean? @default(true)
last_login DateTime? @db.Timestamp(0) last_login DateTime? @db.Timestamp(0)
failed_login_attempts Int? @default(0) failed_login_attempts Int? @default(0)
locked_until DateTime? @db.Timestamp(0) locked_until DateTime? @db.Timestamp(0)
password_changed_at DateTime? @default(now()) @db.Timestamp(0) password_changed_at DateTime? @default(now()) @db.Timestamp(0)
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0) updated_at DateTime? @default(now()) @db.Timestamp(0)
totp_secret String? @db.VarChar(255) totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false) totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text totp_backup_codes String? @db.Text
totp_last_used_counter Int? totp_last_used_counter Int?
attendance attendance[] attendance attendance[]
leave_balances leave_balances[] leave_balances leave_balances[]
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers") leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers") leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers")
projects projects[] projects projects[]
trips trips[] trips trips[]
sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy") sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy") sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy")
sklad_reservations sklad_reservations[] @relation("SkladReservedBy") sklad_reservations sklad_reservations[] @relation("SkladReservedBy")
work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator") work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator")
work_plan_entries_owned work_plan_entries[] work_plan_entries_owned work_plan_entries[]
work_plan_overrides work_plan_overrides[] @relation("work_plan_overrides_creator") work_plan_overrides work_plan_overrides[] @relation("work_plan_overrides_creator")
work_plan_overrides_owned work_plan_overrides[] work_plan_overrides_owned work_plan_overrides[]
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1") roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
@@index([is_active], map: "idx_users_is_active") @@index([is_active], map: "idx_users_is_active")
@@index([role_id], map: "idx_users_role_id") @@index([role_id], map: "idx_users_role_id")
@@ -782,18 +810,18 @@ model sklad_categories {
} }
model sklad_suppliers { model sklad_suppliers {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @db.VarChar(255) name String @db.VarChar(255)
ico String? @db.VarChar(20) ico String? @db.VarChar(20)
dic String? @db.VarChar(20) dic String? @db.VarChar(20)
contact_person String? @db.VarChar(255) street String? @db.VarChar(255)
email String? @db.VarChar(255) city String? @db.VarChar(255)
phone String? @db.VarChar(50) postal_code String? @db.VarChar(20)
address String? @db.Text country String? @db.VarChar(100)
notes String? @db.Text custom_fields String? @db.LongText
is_active Boolean @default(true) is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[] receipts sklad_receipts[]
issued_orders issued_orders[] issued_orders issued_orders[]
@@ -810,7 +838,7 @@ model sklad_locations {
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
items sklad_item_locations[] items sklad_item_locations[]
receipt_lines sklad_receipt_lines[] receipt_lines sklad_receipt_lines[]
issue_lines sklad_issue_lines[] issue_lines sklad_issue_lines[]
inventory_lines sklad_inventory_lines[] inventory_lines sklad_inventory_lines[]
@@ -819,19 +847,19 @@ model sklad_locations {
} }
model sklad_items { model sklad_items {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
item_number String? @unique @db.VarChar(50) item_number String? @unique @db.VarChar(50)
name String @db.VarChar(255) name String @db.VarChar(255)
description String? @db.Text description String? @db.Text
category_id Int? category_id Int?
unit String @db.VarChar(20) unit String @db.VarChar(20)
min_quantity Decimal? @db.Decimal(12, 3) min_quantity Decimal? @db.Decimal(12, 3)
is_active Boolean @default(true) is_active Boolean @default(true)
notes String? @db.Text notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
category sklad_categories? @relation(fields: [category_id], references: [id], onDelete: SetNull) category sklad_categories? @relation(fields: [category_id], references: [id], onDelete: SetNull)
batches sklad_batches[] batches sklad_batches[]
item_locations sklad_item_locations[] item_locations sklad_item_locations[]
receipt_lines sklad_receipt_lines[] receipt_lines sklad_receipt_lines[]
@@ -846,50 +874,50 @@ model sklad_items {
model sklad_batches { model sklad_batches {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
item_id Int item_id Int
receipt_line_id Int @unique receipt_line_id Int @unique
quantity Decimal @db.Decimal(12, 3) quantity Decimal @db.Decimal(12, 3)
original_qty Decimal @db.Decimal(12, 3) original_qty Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2) unit_price Decimal @db.Decimal(12, 2)
received_at DateTime? @db.DateTime(0) received_at DateTime? @db.DateTime(0)
is_consumed Boolean @default(false) is_consumed Boolean @default(false)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id], onDelete: Restrict) receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id], onDelete: Restrict)
issue_lines sklad_issue_lines[] issue_lines sklad_issue_lines[]
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo") @@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
@@map("sklad_batches") @@map("sklad_batches")
} }
model sklad_item_locations { model sklad_item_locations {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
item_id Int item_id Int
location_id Int location_id Int
quantity Decimal @default(0) @db.Decimal(12, 3) quantity Decimal @default(0) @db.Decimal(12, 3)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations @relation(fields: [location_id], references: [id], onDelete: Restrict) location sklad_locations @relation(fields: [location_id], references: [id], onDelete: Restrict)
@@unique([item_id, location_id], map: "sklad_item_locations_unique") @@unique([item_id, location_id], map: "sklad_item_locations_unique")
@@map("sklad_item_locations") @@map("sklad_item_locations")
} }
model sklad_receipts { model sklad_receipts {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
receipt_number String? @unique @db.VarChar(50) receipt_number String? @unique @db.VarChar(50)
supplier_id Int? supplier_id Int?
delivery_note_number String? @db.VarChar(100) delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0) delivery_note_date DateTime? @db.DateTime(0)
received_by Int? received_by Int?
notes String? @db.Text notes String? @db.Text
status sklad_receipt_status @default(DRAFT) status sklad_receipt_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull) supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull)
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull) received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull)
items sklad_receipt_lines[] items sklad_receipt_lines[]
attachments sklad_receipt_attachments[] attachments sklad_receipt_attachments[]
@@index([supplier_id], map: "sklad_receipts_supplier_id") @@index([supplier_id], map: "sklad_receipts_supplier_id")
@@index([received_by], map: "sklad_receipts_received_by") @@index([received_by], map: "sklad_receipts_received_by")
@@ -897,50 +925,50 @@ model sklad_receipts {
} }
model sklad_receipt_lines { model sklad_receipt_lines {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
receipt_id Int receipt_id Int
item_id Int item_id Int
quantity Decimal @db.Decimal(12, 3) quantity Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2) unit_price Decimal @db.Decimal(12, 2)
location_id Int? location_id Int?
notes String? @db.VarChar(255) notes String? @db.VarChar(255)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade) receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict) location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
batch sklad_batches? batch sklad_batches?
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id") @@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
@@map("sklad_receipt_lines") @@map("sklad_receipt_lines")
} }
model sklad_receipt_attachments { model sklad_receipt_attachments {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
receipt_id Int receipt_id Int
file_name String @db.VarChar(255) file_name String @db.VarChar(255)
file_mime String @db.VarChar(100) file_mime String @db.VarChar(100)
file_size Int file_size Int
file_path String @db.VarChar(500) file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade) receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
@@map("sklad_receipt_attachments") @@map("sklad_receipt_attachments")
} }
model sklad_issues { model sklad_issues {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
issue_number String? @unique @db.VarChar(50) issue_number String? @unique @db.VarChar(50)
project_id Int project_id Int
issued_by Int? issued_by Int?
notes String? @db.Text notes String? @db.Text
status sklad_issue_status @default(DRAFT) status sklad_issue_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict) project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull) issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull)
items sklad_issue_lines[] items sklad_issue_lines[]
@@index([project_id], map: "sklad_issues_project_id") @@index([project_id], map: "sklad_issues_project_id")
@@index([issued_by], map: "sklad_issues_issued_by") @@index([issued_by], map: "sklad_issues_issued_by")
@@ -948,72 +976,72 @@ model sklad_issues {
} }
model sklad_issue_lines { model sklad_issue_lines {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
issue_id Int issue_id Int
item_id Int item_id Int
batch_id Int batch_id Int
quantity Decimal @db.Decimal(12, 3) quantity Decimal @db.Decimal(12, 3)
location_id Int? location_id Int?
reservation_id Int? reservation_id Int?
notes String? @db.VarChar(255) notes String? @db.VarChar(255)
issue sklad_issues @relation(fields: [issue_id], references: [id], onDelete: Cascade) issue sklad_issues @relation(fields: [issue_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
batch sklad_batches @relation(fields: [batch_id], references: [id], onDelete: Restrict) batch sklad_batches @relation(fields: [batch_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict) location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull) reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull)
@@index([issue_id], map: "sklad_issue_lines_issue_id") @@index([issue_id], map: "sklad_issue_lines_issue_id")
@@map("sklad_issue_lines") @@map("sklad_issue_lines")
} }
model sklad_reservations { model sklad_reservations {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
item_id Int item_id Int
project_id Int project_id Int
quantity Decimal @db.Decimal(12, 3) quantity Decimal @db.Decimal(12, 3)
remaining_qty Decimal @db.Decimal(12, 3) remaining_qty Decimal @db.Decimal(12, 3)
reserved_by Int? reserved_by Int?
notes String? @db.Text notes String? @db.Text
status sklad_reservation_status @default(ACTIVE) status sklad_reservation_status @default(ACTIVE)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict) project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id], onDelete: SetNull) reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id], onDelete: SetNull)
issue_lines sklad_issue_lines[] issue_lines sklad_issue_lines[]
@@index([item_id, status], map: "sklad_reservations_item_status") @@index([item_id, status], map: "sklad_reservations_item_status")
@@map("sklad_reservations") @@map("sklad_reservations")
} }
model sklad_inventory_sessions { model sklad_inventory_sessions {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
session_number String? @unique @db.VarChar(50) session_number String? @unique @db.VarChar(50)
notes String? @db.Text notes String? @db.Text
status sklad_inventory_status @default(DRAFT) status sklad_inventory_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
items sklad_inventory_lines[] items sklad_inventory_lines[]
@@map("sklad_inventory_sessions") @@map("sklad_inventory_sessions")
} }
model sklad_inventory_lines { model sklad_inventory_lines {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
session_id Int session_id Int
item_id Int item_id Int
location_id Int? location_id Int?
system_qty Decimal @db.Decimal(12, 3) system_qty Decimal @db.Decimal(12, 3)
actual_qty Decimal @db.Decimal(12, 3) actual_qty Decimal @db.Decimal(12, 3)
difference Decimal @db.Decimal(12, 3) difference Decimal @db.Decimal(12, 3)
notes String? @db.VarChar(255) notes String? @db.VarChar(255)
session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade) session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict) item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict) location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
@@index([session_id], map: "sklad_inventory_lines_session_id") @@index([session_id], map: "sklad_inventory_lines_session_id")
@@map("sklad_inventory_lines") @@map("sklad_inventory_lines")
@@ -1031,20 +1059,20 @@ model plan_categories {
} }
model work_plan_entries { model work_plan_entries {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user_id Int user_id Int
date_from DateTime @db.Date date_from DateTime @db.Date
date_to DateTime @db.Date date_to DateTime @db.Date
project_id Int? project_id Int?
category String @default("work") @db.VarChar(50) category String @default("work") @db.VarChar(50)
note String @db.VarChar(500) note String @db.VarChar(500)
created_by Int created_by Int
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0) updated_at DateTime? @default(now()) @db.Timestamp(0)
is_deleted Boolean? @default(false) is_deleted Boolean? @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction) projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
creator users @relation("work_plan_entries_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction) creator users @relation("work_plan_entries_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([user_id, date_from], map: "idx_wpe_user_from") @@index([user_id, date_from], map: "idx_wpe_user_from")
@@index([user_id, date_to], map: "idx_wpe_user_to") @@index([user_id, date_to], map: "idx_wpe_user_to")
@@ -1053,19 +1081,19 @@ model work_plan_entries {
} }
model work_plan_overrides { model work_plan_overrides {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
user_id Int user_id Int
shift_date DateTime @db.Date shift_date DateTime @db.Date
project_id Int? project_id Int?
category String @db.VarChar(50) category String @db.VarChar(50)
note String @db.VarChar(500) note String @db.VarChar(500)
created_by Int created_by Int
created_at DateTime? @default(now()) @db.Timestamp(0) created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0) updated_at DateTime? @default(now()) @db.Timestamp(0)
is_deleted Boolean? @default(false) is_deleted Boolean? @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction) projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
creator users @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction) creator users @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([user_id, shift_date], map: "idx_wpo_user_date") @@index([user_id, shift_date], map: "idx_wpo_user_date")
@@index([shift_date], map: "idx_wpo_date") @@index([shift_date], map: "idx_wpo_date")

View File

@@ -295,6 +295,16 @@ const PERMISSIONS: {
module: "warehouse", module: "warehouse",
description: "Vytvářet a potvrzovat inventurní sčítkání", description: "Vytvářet a potvrzovat inventurní sčítkání",
}, },
// AI asistent (Odin) — mirrors migration 20260608120000_add_ai_assistant;
// the seed WIPES permissions, so every migration-added permission must also
// live here or a dev reseed silently loses it (happened 2026-06-10).
{
name: "ai.use",
display_name: "AI asistent",
module: "ai",
description: "Používat AI asistenta (chat a import faktur)",
},
]; ];
async function main() { async function main() {
@@ -381,12 +391,12 @@ async function main() {
console.log(` Admin role now has all ${allPerms.length} permissions`); console.log(` Admin role now has all ${allPerms.length} permissions`);
// 4. Ensure admin user exists // 4. Ensure admin user exists
let adminUser = await prisma.users.findFirst({ const adminUser = await prisma.users.findFirst({
where: { username: "admin" }, where: { username: "admin" },
}); });
if (!adminUser) { if (!adminUser) {
const hash = bcrypt.hashSync("admin", 10); const hash = bcrypt.hashSync("admin", 10);
adminUser = await prisma.users.create({ await prisma.users.create({
data: { data: {
username: "admin", username: "admin",
email: "admin@boha.local", email: "admin@boha.local",

View File

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

View File

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

View File

@@ -0,0 +1,137 @@
import { describe, it, expect, 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 { securityHeaders } from "../middleware/security";
import aiRoutes from "../routes/admin/ai";
import { getBudgetUsd, setBudgetUsd } from "../services/ai.service";
/**
* Pinning test for audit finding L8a: the company-wide AI monthly budget must
* NOT be mutable by an ordinary ai.use holder (budget_usd=0 is a company-wide
* AI DoS; a huge value removes the cost cap). It is a company setting —
* settings.company / settings.system / admin only.
*/
const N = "ai_budget_perm_";
const stamp = Date.now().toString(36);
let app: ReturnType<typeof Fastify>;
let adminToken: string;
let aiUserToken: string;
let aiRoleId: number;
let savedBudget: number;
function token(user: { id: number; username: string; roleName: string }) {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
config.jwt.secret,
{ expiresIn: "15m" },
);
}
async function cleanup() {
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
const roles = await prisma.roles.findMany({
where: { name: { startsWith: N } },
select: { id: true },
});
const roleIds = roles.map((r) => r.id);
if (roleIds.length > 0) {
await prisma.role_permissions.deleteMany({
where: { role_id: { in: roleIds } },
});
await prisma.roles.deleteMany({ where: { id: { in: roleIds } } });
}
}
beforeAll(async () => {
await cleanup();
savedBudget = await getBudgetUsd();
app = Fastify({ logger: false });
await app.register(cookie);
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
app.addHook("onRequest", securityHeaders);
await app.register(aiRoutes, { prefix: "/api/admin/ai" });
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminToken = token({
id: admin.id,
username: admin.username,
roleName: "admin",
});
// Ordinary ai.use holder — may chat, must NOT control the budget.
const role = await prisma.roles.create({
data: { name: `${N}aiuse_${stamp}`, display_name: "AI Use Only" },
});
aiRoleId = role.id;
const perm = await prisma.permissions.findFirst({
where: { name: "ai.use" },
});
if (!perm) throw new Error("Test setup: ai.use permission missing");
await prisma.role_permissions.create({
data: { role_id: aiRoleId, permission_id: perm.id },
});
const user = await prisma.users.create({
data: {
username: `${N}user_${stamp}`,
email: `${N}${stamp}@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
first_name: "AiUse",
last_name: "Only",
is_active: true,
role_id: aiRoleId,
},
});
aiUserToken = token({
id: user.id,
username: user.username,
roleName: role.name,
});
});
afterAll(async () => {
await setBudgetUsd(savedBudget); // restore whatever the test DB had
if (app) await app.close();
await cleanup();
await prisma.$disconnect();
});
describe("PUT /ai/budget permission gate (audit L8a)", () => {
it("rejects an ordinary ai.use holder with 403", async () => {
const res = await app.inject({
method: "PUT",
url: "/api/admin/ai/budget",
headers: {
Authorization: `Bearer ${aiUserToken}`,
"Content-Type": "application/json",
},
payload: { budget_usd: 0 },
});
expect(res.statusCode).toBe(403);
// ...and the budget must be untouched.
expect(await getBudgetUsd()).toBe(savedBudget);
});
it("allows an admin to set the budget", async () => {
const res = await app.inject({
method: "PUT",
url: "/api/admin/ai/budget",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { budget_usd: savedBudget },
});
expect(res.statusCode).toBe(200);
});
});

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "../config/database";
import { createLeave, deleteAttendance } from "../services/attendance.service";
import { localDateStr } from "../utils/date";
/**
* Pinning tests for audit finding C1: deleting a vacation/sick attendance
* record must restore the hours it charged to leave_balances. Without the
* restore, cancelling booked leave by deleting its rows permanently inflates
* vacation_used/sick_used.
*/
const N = "att_delbal_";
let userId: number;
/** First Monday of March in the given year — always before the earliest
* possible Easter holiday and clear of all fixed Czech holidays, so a
* Mon-Fri range books exactly 5 days. (Far-future year per fixture hygiene.) */
function firstMondayOfMarch(year: number): Date {
const d = new Date(year, 2, 1, 12, 0, 0);
while (d.getDay() !== 1) d.setDate(d.getDate() + 1);
return d;
}
async function cleanup() {
const users = await prisma.users.findMany({
where: { username: { startsWith: N } },
select: { id: true },
});
const ids = users.map((u) => u.id);
if (ids.length > 0) {
await prisma.attendance.deleteMany({ where: { user_id: { in: ids } } });
await prisma.leave_balances.deleteMany({ where: { user_id: { in: ids } } });
await prisma.users.deleteMany({ where: { id: { in: ids } } });
}
}
beforeAll(async () => {
await cleanup();
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
if (!adminRole)
throw new Error("Admin role not found — seed the database first");
const user = await prisma.users.create({
data: {
username: `${N}user`,
email: `${N}user@test.local`,
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "Delete",
last_name: "Balance",
is_active: true,
role_id: adminRole.id,
},
});
userId = user.id;
});
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
async function balance(year: number) {
return prisma.leave_balances.findFirst({ where: { user_id: userId, year } });
}
describe("deleteAttendance leave-balance restore (audit C1)", () => {
it("restores vacation_used when booked vacation rows are deleted", async () => {
const monday = firstMondayOfMarch(2098);
const friday = new Date(monday);
friday.setDate(friday.getDate() + 4);
const created = await createLeave(
{
user_id: userId,
date_from: localDateStr(monday),
date_to: localDateStr(friday),
leave_type: "vacation",
leave_hours: 8,
},
userId,
);
expect("created" in created && created.created).toBe(5);
expect(Number((await balance(2098))?.vacation_used)).toBe(40);
const rows = await prisma.attendance.findMany({
where: { user_id: userId, leave_type: "vacation" },
});
expect(rows).toHaveLength(5);
for (const row of rows) {
const res = await deleteAttendance(row.id);
expect("success" in res && res.success).toBe(true);
}
expect(Number((await balance(2098))?.vacation_used)).toBe(0);
});
it("restores sick_used when a booked sick day is deleted", async () => {
const monday = firstMondayOfMarch(2099);
await createLeave(
{
user_id: userId,
date_from: localDateStr(monday),
leave_type: "sick",
leave_hours: 8,
},
userId,
);
expect(Number((await balance(2099))?.sick_used)).toBe(8);
const row = await prisma.attendance.findFirst({
where: { user_id: userId, leave_type: "sick" },
});
expect(row).not.toBeNull();
await deleteAttendance(row!.id);
expect(Number((await balance(2099))?.sick_used)).toBe(0);
});
it("does not touch balances when deleting a plain work shift", async () => {
const wednesday = firstMondayOfMarch(2098);
wednesday.setDate(wednesday.getDate() + 9); // a weekday a week later
const shift = await prisma.attendance.create({
data: {
user_id: userId,
shift_date: wednesday,
leave_type: "work",
},
});
const before = await balance(2098);
await deleteAttendance(shift.id);
const after = await balance(2098);
expect(Number(after?.vacation_used)).toBe(Number(before?.vacation_used));
expect(Number(after?.sick_used)).toBe(Number(before?.sick_used));
});
});

View File

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

View File

@@ -0,0 +1,96 @@
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 auditLogRoutes from "../routes/admin/audit-log";
/**
* Pinning test for audit finding L8b: the audit-log date_from boundary must
* be the LOCAL midnight of the requested day, symmetric with the date_to
* side (which already parses "T23:59:59" as local). The old
* `new Date(date_from)` was UTC midnight = 01:00/02:00 Prague, silently
* excluding events logged in the first morning hours of the from-day.
*
* NOTE: audit_logs.created_at is @db.Timestamp(0) — capped at 2038, so the
* far-future fixture year is 2037, not 2098.
*/
const N = "auditlog_datefilter_";
const DAY = "2037-04-09";
let app: ReturnType<typeof Fastify>;
let adminToken: string;
let earlyId: number; // 00:30 local — inside the old excluded window
let middayId: number; // 12:00 local
let prevDayId: number; // 23:30 local the day BEFORE — must stay excluded
async function cleanup() {
await prisma.audit_logs.deleteMany({
where: { description: { contains: N } },
});
}
beforeAll(async () => {
await cleanup();
app = Fastify({ logger: false });
await app.register(auditLogRoutes, { prefix: "/api/admin/audit-log" });
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" },
);
const early = await prisma.audit_logs.create({
data: {
action: "test",
description: `${N}early`,
created_at: new Date(2037, 3, 9, 0, 30, 0),
},
});
earlyId = early.id;
const midday = await prisma.audit_logs.create({
data: {
action: "test",
description: `${N}midday`,
created_at: new Date(2037, 3, 9, 12, 0, 0),
},
});
middayId = midday.id;
const prevDay = await prisma.audit_logs.create({
data: {
action: "test",
description: `${N}prevday`,
created_at: new Date(2037, 3, 8, 23, 30, 0),
},
});
prevDayId = prevDay.id;
});
afterAll(async () => {
if (app) await app.close();
await cleanup();
await prisma.$disconnect();
});
describe("audit-log date_from boundary (audit L8b)", () => {
it("includes events from the first morning hours of the from-day", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/audit-log?date_from=${DAY}&date_to=${DAY}&limit=100`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id);
expect(ids).toContain(earlyId); // 00:30 — dropped by the old UTC from-bound
expect(ids).toContain(middayId);
expect(ids).not.toContain(prevDayId); // previous evening stays out
});
});

View File

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

View File

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

View File

@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Pinning tests for audit finding M12: a shared Puppeteer browser that
* disconnects between getBrowser()'s point-in-time `connected` check and the
* actual render must be re-acquired once — instead of failing the request
* with a 500 that an immediate retry would have served fine.
*/
const { launchMock } = vi.hoisted(() => ({ launchMock: vi.fn() }));
vi.mock("puppeteer", () => ({ default: { launch: launchMock } }));
function healthyBrowser(pdfBytes = new Uint8Array([1, 2, 3])) {
return {
connected: true,
newPage: vi.fn(async () => ({
setContent: vi.fn(async () => undefined),
pdf: vi.fn(async () => pdfBytes),
close: vi.fn(async () => undefined),
})),
close: vi.fn(async () => undefined),
};
}
/** Browser whose connection dies the moment a page is requested — the
* post-guard crash window from the finding. */
function dyingBrowser() {
const b = {
connected: true,
newPage: vi.fn(async () => {
b.connected = false;
throw new Error("Protocol error (Target.createTarget): Session closed.");
}),
close: vi.fn(async () => undefined),
};
return b;
}
beforeEach(async () => {
vi.resetModules();
launchMock.mockReset();
});
describe("htmlToPdf browser re-acquire (audit M12)", () => {
it("relaunches once and resolves when the cached browser died mid-acquire", async () => {
launchMock
.mockResolvedValueOnce(dyingBrowser())
.mockResolvedValueOnce(healthyBrowser());
const { htmlToPdf } = await import("../utils/html-to-pdf");
const pdf = await htmlToPdf("<html><body>retry</body></html>");
expect(Buffer.isBuffer(pdf)).toBe(true);
expect(launchMock).toHaveBeenCalledTimes(2);
});
it("does NOT retry a genuine render error on a healthy browser", async () => {
const browser = healthyBrowser();
browser.newPage = vi.fn(async () => ({
setContent: vi.fn(async () => {
throw new Error("Render timeout");
}),
pdf: vi.fn(async () => new Uint8Array()),
close: vi.fn(async () => undefined),
}));
launchMock.mockResolvedValue(browser);
const { htmlToPdf } = await import("../utils/html-to-pdf");
await expect(htmlToPdf("<html></html>")).rejects.toThrow("Render timeout");
expect(launchMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,91 @@
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 invoicesRoutes from "../routes/admin/invoices";
import {
CreateInvoiceSchema,
UpdateInvoiceSchema,
} from "../schemas/invoices.schema";
/**
* Pinning tests for audit finding M1: invoice date fields must go through the
* lenient ISO-date coercion. A round-tripped datetime string like
* "2026-03-02T00:00:00" (the toJSON override emits LOCAL time, no Z) parses
* as local Prague midnight = 23:00Z of the PREVIOUS day, and Prisma truncates
* @db.Date columns to the UTC date part — the invoice lands a day early and
* in the wrong month bucket.
*/
let app: ReturnType<typeof Fastify>;
let adminToken: string;
const createdInvoiceIds: number[] = [];
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(invoicesRoutes, { prefix: "/api/admin/invoices" });
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 createdInvoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
if (app) await app.close();
await prisma.$disconnect();
});
describe("invoice @db.Date coercion (audit M1)", () => {
it("stores the submitted calendar day when issue_date carries a time part", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/invoices",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: {
status: "draft",
issue_date: "2026-03-02T00:00:00",
items: [],
},
});
expect(res.statusCode).toBe(201);
const id = res.json().data.id as number;
createdInvoiceIds.push(id);
const row = await prisma.invoices.findUnique({ where: { id } });
// @db.Date reads back as UTC midnight of the stored calendar day.
expect(row?.issue_date?.toISOString().slice(0, 10)).toBe("2026-03-02");
});
it("schema rejects a Czech-format date", () => {
const result = CreateInvoiceSchema.safeParse({
issue_date: "15.03.2026",
});
expect(result.success).toBe(false);
});
it("schema normalizes an empty string to null (edit forms clear with '')", () => {
const result = CreateInvoiceSchema.safeParse({ issue_date: "" });
expect(result.success).toBe(true);
if (result.success) expect(result.data.issue_date).toBeNull();
});
it("update schema strips the time part from tax_date", () => {
const result = UpdateInvoiceSchema.safeParse({
tax_date: "2026-03-02T00:00:00",
});
expect(result.success).toBe(true);
if (result.success) expect(result.data.tax_date).toBe("2026-03-02");
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
import { createInvoice } from "../services/invoices.service";
import { getRate } from "../services/exchange-rates";
/**
* Pinning tests for audit finding M5: a foreign-currency invoice PDF must
* degrade gracefully when the CNB rate lookup fails — render WITHOUT the CZK
* VAT recap/conversion footnote instead of 500ing the whole preview AND the
* NAS archival. The rate only feeds the recap conversion; the document itself
* is renderable without it.
*/
vi.mock("../services/exchange-rates", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../services/exchange-rates")>();
return { ...actual, getRate: vi.fn() };
});
let app: ReturnType<typeof Fastify>;
let adminToken: string;
const createdInvoiceIds: number[] = [];
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminToken = jwt.sign(
{ sub: admin.id, username: admin.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
for (const id of createdInvoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
if (app) await app.close();
await prisma.$disconnect();
});
async function makeEurInvoice() {
// Draft → no number consumed; EUR → triggers the CNB rate lookup.
const invoice = await createInvoice({
status: "draft",
currency: "EUR",
apply_vat: true,
vat_rate: 21,
items: [
{
description: "CNB-DEGRADE-MARKER",
quantity: 1,
unit_price: 100,
vat_rate: 21,
},
],
});
createdInvoiceIds.push(invoice.id);
return invoice;
}
describe("invoice PDF vs unreachable CNB (audit M5)", () => {
it("renders the invoice without the CZK recap when the rate lookup throws", async () => {
vi.mocked(getRate).mockRejectedValue(
new Error("Nepodařilo se získat aktuální kurzy z ČNB"),
);
const invoice = await makeEurInvoice();
const res = await app.inject({
method: "GET",
url: `/api/admin/invoices-pdf/${invoice.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const html = res.body;
expect(html).toContain("CNB-DEGRADE-MARKER");
// The CZK recap and the conversion footnote are omitted — their numbers
// cannot be computed without the rate.
expect(html).not.toContain("Rekapitulace DPH v Kč");
expect(html).not.toContain("Přepočet kurzem ČNB");
});
it("still renders the recap + conversion footnote when the rate is available", async () => {
vi.mocked(getRate).mockResolvedValue(25.0);
const invoice = await makeEurInvoice();
const res = await app.inject({
method: "GET",
url: `/api/admin/invoices-pdf/${invoice.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const html = res.body;
expect(html).toContain("Rekapitulace DPH v Kč");
expect(html).toContain("Přepočet kurzem ČNB");
expect(html).toContain("25,000");
});
});

View File

@@ -5,10 +5,14 @@ import {
invoiceTotalWithVat, invoiceTotalWithVat,
createInvoice, createInvoice,
updateInvoice, updateInvoice,
getInvoice,
listInvoices, listInvoices,
getInvoiceListTotals, getInvoiceListTotals,
} from "../services/invoices.service"; } from "../services/invoices.service";
import { UpdateInvoiceSchema } from "../schemas/invoices.schema"; import {
CreateInvoiceSchema,
UpdateInvoiceSchema,
} from "../schemas/invoices.schema";
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real // `invoiceTotalWithVat` receives a Prisma row whose money columns are real
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using // `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
@@ -168,7 +172,10 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema
invoiceIds.push(draft.id); invoiceIds.push(draft.id);
// Absent from the body -> updateInvoice's `!== undefined` guard skips it. // Absent from the body -> updateInvoice's `!== undefined` guard skips it.
await updateInvoice(draft.id, UpdateInvoiceSchema.parse({ notes: "x" })); await updateInvoice(
draft.id,
UpdateInvoiceSchema.parse({ internal_notes: "x" }),
);
let row = await prisma.invoices.findUnique({ where: { id: draft.id } }); let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBe("Text na PDF"); expect(row?.billing_text).toBe("Text na PDF");
@@ -182,6 +189,94 @@ describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema
}); });
}); });
/* -------------------------------------------------------------------------- */
/* "Obsah" sections + internal-only notes (mirrors issued orders) */
/* -------------------------------------------------------------------------- */
describe("invoice sections round-trip; dropped `notes` is stripped", () => {
const invoiceIds: number[] = [];
afterEach(async () => {
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
invoiceIds.length = 0;
});
it("creates, returns and replaces CZ/EN sections in position order", async () => {
const parsed = CreateInvoiceSchema.parse({
billing_text: "Sekce test",
sections: [
{ title: "Scope", title_cz: "Rozsah", content: "<p>obsah 1</p>" },
{ title: "", title_cz: "Podmínky", content: "<p>obsah 2</p>" },
],
});
const draft = await createInvoice(parsed);
invoiceIds.push(draft.id);
const detail = await getInvoice(draft.id);
expect(detail?.sections?.length).toBe(2);
expect(detail?.sections?.[0].title_cz).toBe("Rozsah");
expect(detail?.sections?.[1].title_cz).toBe("Podmínky");
expect(detail?.sections?.[0].position).toBe(0);
expect(detail?.sections?.[1].position).toBe(1);
// Full-replace on update (same model as items).
const upd = UpdateInvoiceSchema.parse({
sections: [
{ title: "Only", title_cz: "Jediná", content: "<p>nový obsah</p>" },
],
});
const res = await updateInvoice(draft.id, upd);
expect("error" in res).toBe(false);
const after = await getInvoice(draft.id);
expect(after?.sections?.length).toBe(1);
expect(after?.sections?.[0].title_cz).toBe("Jediná");
expect(after?.sections?.[0].content).toBe("<p>nový obsah</p>");
});
it("sections survive an items-only update untouched (absent = keep)", async () => {
const draft = await createInvoice(
CreateInvoiceSchema.parse({
sections: [{ title: "", title_cz: "Drží", content: "<p>x</p>" }],
}),
);
invoiceIds.push(draft.id);
await updateInvoice(
draft.id,
UpdateInvoiceSchema.parse({
items: [{ description: "Práce", quantity: 1, unit_price: 100 }],
}),
);
const after = await getInvoice(draft.id);
expect(after?.sections?.length).toBe(1);
expect(after?.sections?.[0].title_cz).toBe("Drží");
expect(after?.items?.length).toBe(1);
});
it("silently strips the dropped `notes` key from legacy payloads", async () => {
// The printed-notes column was dropped (notes are internal-only now) —
// a legacy client still sending `notes` must not 500 or write anything.
const parsedCreate = CreateInvoiceSchema.parse({
notes: "<p>stará poznámka</p>",
internal_notes: "<p>interní</p>",
});
expect("notes" in parsedCreate).toBe(false);
const draft = await createInvoice(parsedCreate);
invoiceIds.push(draft.id);
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.internal_notes).toBe("<p>interní</p>");
const parsedUpdate = UpdateInvoiceSchema.parse({ notes: "x" });
expect("notes" in parsedUpdate).toBe(false);
const res = await updateInvoice(draft.id, parsedUpdate);
expect("error" in res).toBe(false);
});
});
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Month filter boundaries — issue_date is @db.Date */ /* Month filter boundaries — issue_date is @db.Date */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import offersPdfRoutes from "../routes/admin/offers-pdf";
import invoicesPdfRoutes from "../routes/admin/invoices-pdf";
import { createInvoice } from "../services/invoices.service";
/**
* The per-item Sleva column is CONDITIONAL on the PDF: render it only when at
* least one item carries a discount > 0; otherwise the table looks exactly as
* before. The discounted net always flows into the line/grand totals.
*/
let app: ReturnType<typeof Fastify> | null = null;
let adminToken = "";
const quotationIds: number[] = [];
const invoiceIds: number[] = [];
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(offersPdfRoutes, { prefix: "/api/admin/offers-pdf" });
await app.register(invoicesPdfRoutes, { prefix: "/api/admin/invoices-pdf" });
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminToken = jwt.sign(
{ sub: admin.id, username: admin.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
for (const id of quotationIds)
await prisma.quotations.deleteMany({ where: { id } });
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
if (app) await app.close();
});
const offerHtml = async (discount: number): Promise<string> => {
const q = await prisma.quotations.create({
data: {
quotation_number: `Q-SLEVA-${discount}-${Date.now()}`,
status: "active",
currency: "CZK",
language: "cs",
quotation_items: {
create: [
{
description: "Položka",
quantity: 2,
unit: "ks",
unit_price: 100,
discount,
is_included_in_total: true,
position: 0,
},
],
},
},
});
quotationIds.push(q.id);
const res = await app!.inject({
method: "GET",
url: `/api/admin/offers-pdf/${q.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
return res.body;
};
describe("offer PDF Sleva column", () => {
it("renders a Sleva column + discounted total when an item has a discount", async () => {
const html = await offerHtml(10);
expect(html).toContain("Sleva");
// 2 × 100 with 10% off => 180,00 (no thousands separator at this size).
expect(html).toContain("180,00");
expect(html).not.toContain("200,00");
});
it("omits the Sleva column when no item has a discount", async () => {
const html = await offerHtml(0);
expect(html).not.toContain("Sleva");
expect(html).toContain("200,00");
});
});
const invoiceHtml = async (discount: number): Promise<string> => {
const inv = await createInvoice({
status: "draft",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
language: "cs",
items: [
{
description: "Položka",
quantity: 2,
unit_price: 1000,
discount,
vat_rate: 21,
},
],
});
invoiceIds.push(inv.id);
const res = await app!.inject({
method: "GET",
url: `/api/admin/invoices-pdf/${inv.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
return res.body;
};
describe("invoice PDF Sleva column", () => {
it("renders a Sleva column when an item has a discount", async () => {
const html = await invoiceHtml(10);
expect(html).toContain("Sleva");
expect(html).toContain("10");
});
it("omits the Sleva column when no item has a discount", async () => {
const html = await invoiceHtml(0);
expect(html).not.toContain("Sleva");
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { CreateQuotationSchema } from "../schemas/offers.schema";
import { CreateInvoiceSchema } from "../schemas/invoices.schema";
/**
* The per-item Sleva is a percentage: the offer- and invoice-item schemas must
* accept 0100, default to 0 when absent, and reject out-of-range values.
*/
describe("offer item discount schema", () => {
it("accepts a 0100 discount", () => {
const r = CreateQuotationSchema.safeParse({
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 15 }],
});
expect(r.success).toBe(true);
expect(r.success && r.data.items?.[0].discount).toBe(15);
});
it("defaults discount to 0 when absent", () => {
const r = CreateQuotationSchema.safeParse({
items: [{ description: "X", quantity: 1, unit_price: 100 }],
});
expect(r.success && r.data.items?.[0].discount).toBe(0);
});
it("rejects a discount above 100", () => {
const r = CreateQuotationSchema.safeParse({
items: [
{ description: "X", quantity: 1, unit_price: 100, discount: 150 },
],
});
expect(r.success).toBe(false);
});
});
describe("invoice item discount schema", () => {
it("accepts a 0100 discount and defaults to 0", () => {
const ok = CreateInvoiceSchema.safeParse({
items: [{ description: "X", quantity: 1, unit_price: 100, discount: 10 }],
});
expect(ok.success && ok.data.items?.[0].discount).toBe(10);
const def = CreateInvoiceSchema.safeParse({
items: [{ description: "X", quantity: 1, unit_price: 100 }],
});
expect(def.success && def.data.items?.[0].discount).toBe(0);
});
it("rejects a negative discount", () => {
const r = CreateInvoiceSchema.safeParse({
items: [{ description: "X", quantity: 1, unit_price: 100, discount: -5 }],
});
expect(r.success).toBe(false);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createOffer, getOfferTotals } from "../services/offers.service";
import {
createInvoice,
getInvoiceListTotals,
} from "../services/invoices.service";
/**
* Per-item Sleva (% discount) must reduce the line NET — and on invoices the
* VAT is computed on the discounted net. Hits the real `app_test` DB through the
* service layer (suite convention), mirroring offer-invoice-totals.test.ts.
*/
const MARKER = "SLEVA-TOTALS-2096";
const YEAR = 2096;
const MONTH = 8; // invoice issue_date window [2096-08-01, 2096-09-01)
const dateStr = `${YEAR}-0${MONTH}-15`;
const offerIds: number[] = [];
const invoiceIds: number[] = [];
afterEach(async () => {
for (const id of offerIds)
await prisma.quotations.deleteMany({ where: { id } });
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
offerIds.length = 0;
invoiceIds.length = 0;
await prisma.number_sequences.deleteMany({
where: { type: { in: ["offer", "invoice"] } },
});
});
const amountFor = (
totals: { currency: string; amount: number }[],
currency: string,
): number | undefined => totals.find((t) => t.currency === currency)?.amount;
describe("offer per-item discount", () => {
it("reduces the offer NET total by the per-item percentage", async () => {
// 2 × 1000 with 10% off => 1800 net.
const res = await createOffer({
status: "draft",
currency: "CZK",
project_code: MARKER,
items: [
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
],
});
if ("error" in res) throw new Error(JSON.stringify(res));
offerIds.push(res.id);
const { totals } = await getOfferTotals({ search: MARKER });
expect(amountFor(totals, "CZK")).toBe(1800);
});
});
describe("invoice per-item discount", () => {
it("computes VAT on the discounted net (gross = discounted net + VAT)", async () => {
// 2 × 1000 with 10% off => net 1800, VAT@21% = 378, gross 2178.
const inv = await createInvoice({
status: "draft",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
issue_date: dateStr,
items: [
{ description: "X", quantity: 2, unit_price: 1000, discount: 10 },
],
});
invoiceIds.push(inv.id);
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
expect(amountFor(totals, "CZK")).toBe(2178);
});
it("treats a 100% discount line as zero", async () => {
const inv = await createInvoice({
status: "draft",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
issue_date: dateStr,
items: [
{ description: "Free", quantity: 5, unit_price: 200, discount: 100 },
],
});
invoiceIds.push(inv.id);
const { totals } = await getInvoiceListTotals({ month: MONTH, year: YEAR });
expect(amountFor(totals, "CZK")).toBeUndefined(); // zero totals are dropped
});
});

View File

@@ -0,0 +1,202 @@
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 leaveRequestsRoutes from "../routes/admin/leave-requests";
/**
* Pinning tests for audit finding H6 + the year-spanning fast-follow:
* leave-request creation and approval must mirror attendance.service
* createLeave — skip Czech public holidays (a weekday holiday is already
* paid/free; charging it double-deducts) and book each day's hours against
* ITS OWN calendar year's balance (a Dec→Jan request used to charge
* everything to the start year).
*
* Fixture math (verified): 2098-05-01 (Thu) and 2098-05-08 (Thu) are both
* weekday holidays → the 05-01..05-08 range has 4 chargeable days (32h),
* not 6 (48h). 2098-12-28 (Sun)..2099-01-05 (Mon) has 3 chargeable days in
* 2098 (29/30/31 = 24h) and 2 in 2099 (Jan 2 + Jan 5 = 16h; Jan 1 is a
* holiday).
*/
const N = "leave_holiday_";
let app: ReturnType<typeof Fastify>;
let adminToken: string;
let userToken: string;
let userId: number;
async function cleanup() {
const users = await prisma.users.findMany({
where: { username: { startsWith: N } },
select: { id: true },
});
const ids = users.map((u) => u.id);
if (ids.length > 0) {
await prisma.attendance.deleteMany({ where: { user_id: { in: ids } } });
await prisma.leave_requests.deleteMany({
where: { user_id: { in: ids } },
});
await prisma.leave_balances.deleteMany({ where: { user_id: { in: ids } } });
await prisma.users.deleteMany({ where: { id: { in: ids } } });
}
}
beforeAll(async () => {
await cleanup();
app = Fastify({ logger: false });
await app.register(leaveRequestsRoutes, {
prefix: "/api/admin/leave-requests",
});
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" },
);
const role = await prisma.roles.findFirst();
const user = await prisma.users.create({
data: {
username: `${N}user`,
email: `${N}user@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
first_name: "Leave",
last_name: "Holiday",
is_active: true,
role_id: role!.id,
},
});
userId = user.id;
userToken = jwt.sign(
{ sub: user.id, username: user.username, role: role!.name },
config.jwt.secret,
{ expiresIn: "15m" },
);
for (const year of [2098, 2099]) {
await prisma.leave_balances.create({
data: {
user_id: userId,
year,
vacation_total: 200,
vacation_used: 0,
sick_used: 0,
},
});
}
});
afterAll(async () => {
if (app) await app.close();
await cleanup();
await prisma.$disconnect();
});
async function balance(year: number) {
return prisma.leave_balances.findFirst({
where: { user_id: userId, year },
});
}
describe("leave requests vs Czech holidays (audit H6)", () => {
it("creation excludes weekday public holidays from the charged total", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/leave-requests",
headers: {
Authorization: `Bearer ${userToken}`,
"Content-Type": "application/json",
},
payload: {
leave_type: "vacation",
date_from: "2098-05-01",
date_to: "2098-05-08",
},
});
expect(res.statusCode).toBe(201);
const row = await prisma.leave_requests.findFirst({
where: { id: res.json().data.id },
});
expect(row?.total_days).toBe(4);
expect(Number(row?.total_hours)).toBe(32);
// Leave it cancelled so the approval test's range stays free.
await prisma.leave_requests.update({
where: { id: row!.id },
data: { status: "cancelled" },
});
});
it("approval charges only non-holiday business days and skips holiday attendance rows", async () => {
// Stored totals mimic a PRE-FIX request (weekend-only counting) — the
// approval must recompute, not trust them.
const req = await prisma.leave_requests.create({
data: {
user_id: userId,
leave_type: "vacation",
date_from: new Date(Date.UTC(2098, 4, 1)),
date_to: new Date(Date.UTC(2098, 4, 8)),
total_hours: 48,
total_days: 6,
status: "pending",
},
});
const res = await app.inject({
method: "PUT",
url: `/api/admin/leave-requests/${req.id}`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { status: "approved" },
});
expect(res.statusCode).toBe(200);
expect(Number((await balance(2098))?.vacation_used)).toBe(32);
const rows = await prisma.attendance.findMany({
where: { user_id: userId, leave_type: "vacation" },
});
const days = rows.map((r) => r.shift_date.toISOString().slice(0, 10));
expect(rows).toHaveLength(4);
expect(days).not.toContain("2098-05-01");
expect(days).not.toContain("2098-05-08");
});
it("a year-spanning approval books each day against its own year's balance", async () => {
const req = await prisma.leave_requests.create({
data: {
user_id: userId,
leave_type: "sick",
date_from: new Date(Date.UTC(2098, 11, 28)),
date_to: new Date(Date.UTC(2099, 0, 5)),
total_hours: 48,
total_days: 6,
status: "pending",
},
});
const res = await app.inject({
method: "PUT",
url: `/api/admin/leave-requests/${req.id}`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { status: "approved" },
});
expect(res.statusCode).toBe(200);
// 29/30/31 Dec 2098 = 24h; Jan 2 + Jan 5 2099 = 16h (Jan 1 is a holiday).
expect(Number((await balance(2098))?.sick_used)).toBe(24);
expect(Number((await balance(2099))?.sick_used)).toBe(16);
});
});

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from "vitest";
import { lineNet } from "../utils/money";
describe("lineNet (per-item discounted net)", () => {
it("returns qty × unit_price when there is no discount", () => {
expect(lineNet(2, 1000, 0)).toBe(2000);
});
it("applies a percentage discount to the line net", () => {
expect(lineNet(2, 1000, 10)).toBe(1800);
});
it("treats a 100% discount as zero", () => {
expect(lineNet(3, 500, 100)).toBe(0);
});
it("treats null/undefined/NaN discount as no discount", () => {
expect(lineNet(2, 1000, null)).toBe(2000);
expect(lineNet(2, 1000, undefined)).toBe(2000);
expect(lineNet(2, 1000, NaN)).toBe(2000);
});
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "../config/database";
import { deleteOffer } from "../services/offers.service";
import { applyPattern } from "../services/numbering.service";
/**
* Pinning test for audit finding L2: an offer created in year Y but
* finalized (numbered) in year Y+1 must release the Y+1 sequence on delete.
* deleteOffer used to derive the release year from created_at, so the
* reconstructed highest number ("2025/NA/...") never matched the actual
* "2026/NA/..." and the counter kept a permanent gap.
*/
const CURRENT_YEAR = new Date().getFullYear();
let offerId: number;
let originalLastNumber: number | null = null; // null = row absent before test
let offerNumber: string;
async function getSeq(): Promise<number | null> {
const rows = await prisma.$queryRaw<Array<{ last_number: number }>>`
SELECT last_number FROM number_sequences
WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR}
`;
return rows.length > 0 ? Number(rows[0].last_number) : null;
}
beforeAll(async () => {
const settings = await prisma.company_settings.findFirst();
const pattern = settings?.offer_number_pattern || "{YYYY}/{PREFIX}/{NNN}";
const prefix = settings?.quotation_prefix || "NA";
originalLastNumber = await getSeq();
const base = originalLastNumber ?? 0;
const seq = base + 1;
offerNumber = applyPattern(pattern, {
year: CURRENT_YEAR,
prefix,
code: "",
seq,
});
// Consume the number in the sequence (what finalize would have done).
if (originalLastNumber === null) {
await prisma.$executeRaw`
INSERT INTO number_sequences (\`type\`, \`year\`, \`last_number\`)
VALUES ('offer', ${CURRENT_YEAR}, ${seq})
`;
} else {
await prisma.$executeRaw`
UPDATE number_sequences SET \`last_number\` = ${seq}
WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR}
`;
}
// The offer itself was CREATED last year (draft), finalized this year —
// its number carries the finalize year, created_at the draft year.
const offer = await prisma.quotations.create({
data: {
quotation_number: offerNumber,
status: "active",
currency: "CZK",
language: "cs",
created_at: new Date(CURRENT_YEAR - 1, 11, 30, 12, 0, 0),
},
});
offerId = offer.id;
});
afterAll(async () => {
await prisma.quotations
.deleteMany({ where: { id: offerId } })
.catch(() => {});
// Restore the sequence to its pre-test value regardless of outcome.
if (originalLastNumber === null) {
await prisma.$executeRaw`
DELETE FROM number_sequences
WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR}
`;
} else {
await prisma.$executeRaw`
UPDATE number_sequences SET \`last_number\` = ${originalLastNumber}
WHERE \`type\` = 'offer' AND \`year\` = ${CURRENT_YEAR}
`;
}
await prisma.$disconnect();
});
describe("offer sequence release across years (audit L2)", () => {
it("releases the finalize-year sequence when created_at is in a previous year", async () => {
const before = await getSeq();
expect(before).toBe((originalLastNumber ?? 0) + 1);
const result = await deleteOffer(offerId);
expect(result && "error" in result ? result.error : null).toBeNull();
// The highest number was just deleted → the counter must step back.
expect(await getSeq()).toBe(originalLastNumber ?? 0);
});
});

View File

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

View File

@@ -30,8 +30,6 @@ const baseOrder = {
status: "prijata" as const, status: "prijata" as const,
currency: "CZK", currency: "CZK",
language: "cs", language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1, exchange_rate: 1,
}; };

View File

@@ -0,0 +1,126 @@
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 customersRoutes from "../routes/admin/customers";
import receivedInvoicesRoutes from "../routes/admin/received-invoices";
/**
* Pinning tests for audit finding L3: offset pagination over a non-unique
* sort column needs an `{ id }` tiebreak, or rows sharing the sort value can
* reorder between page queries — duplicating one row and skipping another.
* (Documented determinism convention; siblings issued-orders/trips already
* use compound orderings.)
*
* NOTE: without the tiebreak the misbehavior is probabilistic (MySQL may
* return a stable order by luck), so the paging assertion is primarily a
* REGRESSION GUARD pinning the exactly-once contract.
*/
const N = "tiebreak_test_";
let app: ReturnType<typeof Fastify>;
let adminToken: string;
let customerIds: number[] = [];
let invoiceIds: number[] = [];
async function cleanup() {
await prisma.customers.deleteMany({ where: { name: { startsWith: N } } });
await prisma.received_invoices.deleteMany({
where: { supplier_name: { startsWith: N } },
});
}
beforeAll(async () => {
await cleanup();
app = Fastify({ logger: false });
await app.register(customersRoutes, { prefix: "/api/admin/customers" });
await app.register(receivedInvoicesRoutes, {
prefix: "/api/admin/received-invoices",
});
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" },
);
// 5 customers sharing one city (the sort key) so every page boundary
// falls inside a duplicate-key run.
customerIds = [];
for (let i = 0; i < 5; i++) {
const c = await prisma.customers.create({
data: { name: `${N}c${i}`, city: "Tiebreakov" },
});
customerIds.push(c.id);
}
// 5 received invoices sharing supplier_name in one far-future month.
invoiceIds = [];
for (let i = 0; i < 5; i++) {
const inv = await prisma.received_invoices.create({
data: {
supplier_name: `${N}supplier`,
month: 7,
year: 2098,
amount: 100 + i,
currency: "CZK",
vat_rate: 21,
vat_amount: 17.36,
status: "unpaid",
},
});
invoiceIds.push(inv.id);
}
});
afterAll(async () => {
if (app) await app.close();
await cleanup();
await prisma.$disconnect();
});
async function collectPages(urlBase: string, pages: number) {
const seen: number[] = [];
for (let page = 1; page <= pages; page++) {
const res = await app.inject({
method: "GET",
url: `${urlBase}&page=${page}&limit=2`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
for (const row of res.json().data as Array<{ id: number }>) {
seen.push(row.id);
}
}
return seen;
}
describe("offset pagination exactly-once (audit L3)", () => {
it("customers sorted by a duplicate city appear exactly once across pages", async () => {
const seen = (
await collectPages("/api/admin/customers?sort=city&search=" + N, 3)
).filter((id) => customerIds.includes(id));
expect([...new Set(seen)].sort()).toEqual([...customerIds].sort());
expect(seen.length).toBe(customerIds.length);
});
it("received invoices sorted by a duplicate supplier appear exactly once across pages", async () => {
const seen = (
await collectPages(
"/api/admin/received-invoices?sort=supplier_name&month=7&year=2098",
3,
)
).filter((id) => invoiceIds.includes(id));
expect([...new Set(seen)].sort()).toEqual([...invoiceIds].sort());
expect(seen.length).toBe(invoiceIds.length);
});
});

View File

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

View File

@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { cleanQuillHtml } from "../utils/pdf-shared";
describe("cleanQuillHtml — inline-style whitelist", () => {
it("keeps Quill text and background colors", () => {
const html =
'<p><span style="color: rgb(230, 0, 0);">červeně</span> ' +
'<span style="background-color: #ffff00;">zvýrazněně</span> ' +
'<span style="color: #06c;">modře</span></p>';
const out = cleanQuillHtml(html);
expect(out).toContain('style="color: rgb(230, 0, 0)"');
expect(out).toContain('style="background-color: #ffff00"');
expect(out).toContain('style="color: #06c"');
});
it("drops every non-color style declaration", () => {
const out = cleanQuillHtml(
'<span style="position: fixed; color: red; font-size: 99px;">x</span>',
);
expect(out).toBe('<span style="color: red">x</span>');
});
it("drops unsafe color values (url, expression, quotes)", () => {
expect(
cleanQuillHtml('<span style="color: url(javascript:alert(1))">x</span>'),
).toBe("<span>x</span>");
expect(
cleanQuillHtml('<span style="color: expression(alert(1))">x</span>'),
).toBe("<span>x</span>");
expect(cleanQuillHtml("<span style='color: \"red\"'>x</span>")).toBe(
"<span>x</span>",
);
});
it("restores Quill 2's empty <p></p> blank lines to <p><br></p>", () => {
// Quill 2's semantic HTML drops the <br> placeholder from blank lines.
expect(cleanQuillHtml("<p>A</p><p></p><p>B</p>")).toBe(
"<p>A</p><p><br></p><p>B</p>",
);
// Attribute-carrying and whitespace-only empties too.
expect(cleanQuillHtml('<p class="ql-align-center"> </p>')).toBe(
'<p class="ql-align-center"><br></p>',
);
});
it("keeps stripping scripts, event handlers and js: URLs", () => {
const out = cleanQuillHtml(
'<p onclick="alert(1)"><script>alert(1)</script>' +
'<a href="javascript:alert(1)">x</a></p>',
);
expect(out).not.toContain("script");
expect(out).not.toContain("onclick");
expect(out).not.toContain("javascript:");
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "../config/database";
import { updateEntry } from "../services/plan.service";
/**
* Pinning tests for audit finding M9: updateEntry must re-check the per-cell
* cap (MAX_RECORDS_PER_CELL = 3) when the date range changes — expanding an
* entry onto a day that already holds 3 active entries used to succeed,
* producing a 4th that the grid (capped at 3) silently hides.
*
* The re-check must EXCLUDE the updated entry itself, so editing an at-cap
* entry without moving it stays allowed.
*/
const NOTE = "plan_updcap_test";
let userId: number;
let cappedEntryIds: number[] = [];
let fourthId: number;
const D = (day: number) => new Date(Date.UTC(2099, 6, day)); // July 2099, UTC
async function cleanup() {
await prisma.work_plan_entries.deleteMany({ where: { note: NOTE } });
}
beforeAll(async () => {
await cleanup();
const user = await prisma.users.findFirst({ select: { id: true } });
if (!user) throw new Error("Test setup: no users — seed the database");
userId = user.id;
cappedEntryIds = [];
for (let i = 0; i < 3; i++) {
const entry = await prisma.work_plan_entries.create({
data: {
user_id: userId,
date_from: D(1),
date_to: D(1),
category: "work",
note: NOTE,
created_by: userId,
},
});
cappedEntryIds.push(entry.id);
}
const fourth = await prisma.work_plan_entries.create({
data: {
user_id: userId,
date_from: D(2),
date_to: D(2),
category: "work",
note: NOTE,
created_by: userId,
},
});
fourthId = fourth.id;
});
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
describe("updateEntry per-cell cap (audit M9)", () => {
it("rejects expanding an entry onto a day already at the cap", async () => {
const result = await updateEntry(
fourthId,
{ date_from: "2099-07-01", date_to: "2099-07-01" },
userId,
false,
);
expect("error" in result).toBe(true);
if ("error" in result) expect(result.status).toBe(400);
// The entry must not have moved.
const fresh = await prisma.work_plan_entries.findUnique({
where: { id: fourthId },
});
expect(fresh?.date_from.toISOString().slice(0, 10)).toBe("2099-07-02");
});
it("still allows editing an at-cap entry without moving it (self-exclusion)", async () => {
const result = await updateEntry(
cappedEntryIds[0],
{ date_from: "2099-07-01", date_to: "2099-07-01", note: NOTE },
userId,
false,
);
expect("error" in result).toBe(false);
});
it("still allows a note-only edit and a move to a free day", async () => {
const noteOnly = await updateEntry(fourthId, { note: NOTE }, userId, false);
expect("error" in noteOnly).toBe(false);
const move = await updateEntry(
fourthId,
{ date_from: "2099-07-03", date_to: "2099-07-03" },
userId,
false,
);
expect("error" in move).toBe(false);
});
});

View File

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

View File

@@ -0,0 +1,227 @@
import { describe, it, expect, 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 { securityHeaders } from "../middleware/security";
import projectsRoutes from "../routes/admin/projects";
// ---------------------------------------------------------------------------
// Project notes: author-only editing with a visible edited_at stamp, and
// admin-only deletion (a projects.edit holder may add + edit their own notes
// but never delete — user decision 2026-06-12).
// ---------------------------------------------------------------------------
const N = "projnotes_test_";
let app: Awaited<ReturnType<typeof buildApp>>;
let adminToken: string;
let userAId: number;
let userAToken: string;
let userBToken: string;
let roleId: number;
let projectId: number;
let noteId: 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(projectsRoutes, { prefix: "/api/admin/projects" });
return a;
}
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" },
);
}
function inject(
method: "POST" | "PUT" | "DELETE",
path: string,
token: string,
body?: unknown,
) {
return app.inject({
method,
url: path,
headers: {
Authorization: `Bearer ${token}`,
// Content-Type only with a body — Fastify 400s an empty JSON body.
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
...(body !== undefined ? { payload: body as object } : {}),
});
}
beforeAll(async () => {
app = await buildApp();
// Defensive pre-clean of a previously failed run.
await prisma.project_notes.deleteMany({
where: { content: { contains: N } },
});
await prisma.projects.deleteMany({ where: { name: { startsWith: N } } });
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
await prisma.roles.deleteMany({ where: { name: `${N}role` } });
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminToken = generateToken({
id: admin.id,
username: admin.username,
roleName: admin.roles?.name ?? null,
});
// Non-admin role with projects.view + projects.edit.
const role = await prisma.roles.create({
data: { name: `${N}role`, display_name: "ProjNotes test role" },
});
roleId = role.id;
const perms = await prisma.permissions.findMany({
where: { name: { in: ["projects.view", "projects.edit"] } },
select: { id: true },
});
if (perms.length !== 2) {
throw new Error("Test setup: projects.view/projects.edit missing");
}
await prisma.role_permissions.createMany({
data: perms.map((p) => ({ role_id: roleId, permission_id: p.id })),
});
const mkUser = async (suffix: string) =>
prisma.users.create({
data: {
username: `${N}${suffix}`,
email: `${N}${suffix}@test.local`,
password_hash: "x",
first_name: "Projnotes",
last_name: suffix.toUpperCase(),
is_active: true,
role_id: roleId,
},
});
const userA = await mkUser("a");
const userB = await mkUser("b");
userAId = userA.id;
userAToken = generateToken({
id: userA.id,
username: userA.username,
roleName: `${N}role`,
});
userBToken = generateToken({
id: userB.id,
username: userB.username,
roleName: `${N}role`,
});
const project = await prisma.projects.create({
data: { name: `${N}project`, status: "aktivni" },
});
projectId = project.id;
// Note authored by user A (through the route, like the UI does).
const res = await inject(
"POST",
`/api/admin/projects/${projectId}/notes`,
userAToken,
{ content: `${N}original` },
);
expect(res.statusCode).toBe(201);
noteId = res.json().data.note.id;
});
afterAll(async () => {
await prisma.project_notes.deleteMany({ where: { project_id: projectId } });
await prisma.projects.deleteMany({ where: { id: projectId } });
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
await prisma.roles.deleteMany({ where: { id: roleId } });
await app.close();
});
describe("project notes — author-only edit with edited_at stamp", () => {
it("the author edits their own note and the edit is stamped", async () => {
const res = await inject(
"PUT",
`/api/admin/projects/${projectId}/notes/${noteId}`,
userAToken,
{ content: `${N}edited` },
);
expect(res.statusCode).toBe(200);
const row = await prisma.project_notes.findUnique({
where: { id: noteId },
});
expect(row?.content).toBe(`${N}edited`);
expect(row?.user_id).toBe(userAId);
expect(row?.edited_at).not.toBeNull();
});
it("another user cannot edit someone else's note", async () => {
const res = await inject(
"PUT",
`/api/admin/projects/${projectId}/notes/${noteId}`,
userBToken,
{ content: `${N}hijack` },
);
expect(res.statusCode).toBe(403);
const row = await prisma.project_notes.findUnique({
where: { id: noteId },
});
expect(row?.content).toBe(`${N}edited`); // unchanged
});
it("rejects empty content and unknown note ids", async () => {
const empty = await inject(
"PUT",
`/api/admin/projects/${projectId}/notes/${noteId}`,
userAToken,
{ content: " " },
);
expect(empty.statusCode).toBe(400);
const missing = await inject(
"PUT",
`/api/admin/projects/${projectId}/notes/999999999`,
userAToken,
{ content: "x" },
);
expect(missing.statusCode).toBe(404);
});
});
describe("project notes — admin-only deletion", () => {
it("a projects.edit holder cannot delete (even their own note)", async () => {
const res = await inject(
"DELETE",
`/api/admin/projects/${projectId}/notes/${noteId}`,
userAToken,
);
expect(res.statusCode).toBe(403);
});
it("an admin deletes anybody's note", async () => {
const res = await inject(
"DELETE",
`/api/admin/projects/${projectId}/notes/${noteId}`,
adminToken,
);
expect(res.statusCode).toBe(200);
const row = await prisma.project_notes.findUnique({
where: { id: noteId },
});
expect(row).toBeNull();
});
});

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "../config/database";
import { updateProject } from "../services/projects.service";
/**
* Pinning tests for audit finding M6: updateProject must pre-validate FK ids
* (customer_id, responsible_user_id, quotation_id, order_id) and return clean
* Czech 400s like createProject does — a dangling id otherwise raises P2003
* at Prisma and surfaces as a generic 500.
*/
const N = "prj_fk_";
let projectId: number;
async function cleanup() {
await prisma.projects.deleteMany({ where: { name: { startsWith: N } } });
}
beforeAll(async () => {
await cleanup();
const project = await prisma.projects.create({
data: { name: `${N}project`, status: "active" },
});
projectId = project.id;
});
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
function isError(r: unknown): r is { error: string; status: number } {
return typeof r === "object" && r !== null && "error" in r;
}
describe("updateProject FK existence checks (audit M6)", () => {
it.each([
["customer_id", "Zákazník nenalezen"],
["responsible_user_id", "Zodpovědná osoba nenalezena"],
["quotation_id", "Nabídka nenalezena"],
["order_id", "Zakázka nenalezena"],
])("returns a Czech 400 for a dangling %s", async (field, message) => {
const result = await updateProject(projectId, { [field]: 99999999 });
expect(isError(result)).toBe(true);
if (isError(result)) {
expect(result.status).toBe(400);
expect(result.error).toBe(message);
}
// Nothing was written.
const fresh = await prisma.projects.findUnique({
where: { id: projectId },
});
expect(fresh?.[field as "customer_id"]).toBeNull();
});
it("still allows clearing an FK with null", async () => {
const result = await updateProject(projectId, { customer_id: null });
expect(isError(result)).toBe(false);
});
it("still allows setting a valid FK", async () => {
const user = await prisma.users.findFirst({ select: { id: true } });
expect(user).not.toBeNull();
const result = await updateProject(projectId, {
responsible_user_id: user!.id,
});
expect(isError(result)).toBe(false);
const fresh = await prisma.projects.findUnique({
where: { id: projectId },
});
expect(fresh?.responsible_user_id).toBe(user!.id);
});
});

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { z } from "zod";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import receivedInvoicesRoutes from "../routes/admin/received-invoices";
import { CreateReceivedInvoiceSchema } from "../schemas/received-invoices.schema";
/**
* Pinning tests for audit finding H5: Czech-format dates in the received-
* invoice upload metadata must be rejected with a clean Czech 400 BEFORE any
* NAS side effect — "15.03.2026" used to parse to Invalid Date (NaN
* month/year written after the NAS save → 500 + orphaned file) and
* "12.6.2026" silently filed under December (US month-first parsing).
*/
let app: ReturnType<typeof Fastify>;
let adminToken: string;
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(receivedInvoicesRoutes, {
prefix: "/api/admin/received-invoices",
});
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminToken = jwt.sign(
{ sub: admin.id, username: admin.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
if (app) await app.close();
await prisma.$disconnect();
});
function multipartUpload(meta: Record<string, unknown>) {
const boundary = "----vitestboundary";
const payload = [
`--${boundary}`,
'Content-Disposition: form-data; name="invoices"',
"",
JSON.stringify([meta]),
`--${boundary}`,
'Content-Disposition: form-data; name="files"; filename="test.pdf"',
"Content-Type: application/pdf",
"",
"%PDF-1.4 test",
`--${boundary}--`,
"",
].join("\r\n");
return app.inject({
method: "POST",
url: "/api/admin/received-invoices",
headers: {
Authorization: `Bearer ${adminToken}`,
"content-type": `multipart/form-data; boundary=${boundary}`,
},
payload,
});
}
describe("received-invoice date handling (audit H5)", () => {
const partialSchema = z.array(CreateReceivedInvoiceSchema.partial());
it("schema rejects Czech-format dates", () => {
expect(
partialSchema.safeParse([{ issue_date: "15.03.2026" }]).success,
).toBe(false);
expect(partialSchema.safeParse([{ issue_date: "12.6.2026" }]).success).toBe(
false,
);
});
it("schema strips a trailing time part and normalizes '' to null", () => {
const withTime = partialSchema.safeParse([
{ issue_date: "2098-05-03T00:00:00" },
]);
expect(withTime.success).toBe(true);
if (withTime.success)
expect(withTime.data[0].issue_date).toBe("2098-05-03");
const empty = partialSchema.safeParse([{ issue_date: "" }]);
expect(empty.success).toBe(true);
if (empty.success) expect(empty.data[0].issue_date).toBeNull();
});
it("upload with a Czech-format issue_date returns 400 before any NAS write", async () => {
const res = await multipartUpload({
supplier_name: "Test s.r.o.",
amount: 121,
vat_rate: 21,
issue_date: "15.03.2026",
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain("Datum");
});
it("upload with an impossible regex-passing date returns 400, not NaN month", async () => {
const res = await multipartUpload({
supplier_name: "Test s.r.o.",
amount: 121,
vat_rate: 21,
issue_date: "2098-99-99",
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain("datum");
});
});

View File

@@ -0,0 +1,394 @@
import { describe, it, expect } from "vitest";
import type { z } from "zod";
import {
CreateOrderSchema,
UpdateOrderSchema,
CreateOrderFromQuotationSchema,
} from "../schemas/orders.schema";
import {
CreateInvoiceSchema,
UpdateInvoiceSchema,
} from "../schemas/invoices.schema";
import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema";
import { TotpEnableSchema } from "../schemas/auth.schema";
import {
CreateReceivedInvoiceSchema,
UpdateReceivedInvoiceSchema,
} from "../schemas/received-invoices.schema";
import {
CreateSupplierSchema,
UpdateSupplierSchema,
} from "../schemas/warehouse.schema";
import {
CreateCustomerSchema,
UpdateCustomerSchema,
} from "../schemas/customers.schema";
/**
* Pinning tests for the Zod-cap-vs-DB-column class (audit H2, M2, M4, M11,
* L1, L8d, L8e + adjacent invoice item/bank caps): every form-facing string
* cap must fit its DB column width, or an over-width value passes Zod and
* dies at Prisma as a 500 (P2000) instead of a clean Czech 400.
*
* Each case asserts BOTH directions: width+1 chars must FAIL the schema, and
* exactly width chars must PASS (guards against over-tightening — stored
* values can never exceed the column width, so re-submitted edit forms stay
* valid).
*/
interface CapCase {
name: string;
schema: z.ZodTypeAny;
/** DB column width from prisma/schema.prisma */
width: number;
build: (value: string) => unknown;
}
const tripBase = {
vehicle_id: 1,
trip_date: "2098-03-02",
start_km: 0,
end_km: 1,
route_from: "A",
route_to: "B",
};
const receivedBase = { supplier_name: "X", month: 6, year: 2026 };
const CASES: CapCase[] = [
// ── orders.schema.ts vs order_items / orders ──
{
name: "CreateOrderSchema items.description",
schema: CreateOrderSchema,
width: 500,
build: (v) => ({ items: [{ description: v }] }),
},
{
name: "CreateOrderSchema items.unit",
schema: CreateOrderSchema,
width: 20,
build: (v) => ({ items: [{ unit: v }] }),
},
{
name: "CreateOrderSchema order_number",
schema: CreateOrderSchema,
width: 50,
build: (v) => ({ order_number: v }),
},
{
name: "CreateOrderSchema customer_order_number",
schema: CreateOrderSchema,
width: 100,
build: (v) => ({ customer_order_number: v }),
},
{
name: "CreateOrderSchema currency",
schema: CreateOrderSchema,
width: 10,
build: (v) => ({ currency: v }),
},
{
name: "CreateOrderSchema language",
schema: CreateOrderSchema,
width: 5,
build: (v) => ({ language: v }),
},
{
name: "UpdateOrderSchema customer_order_number",
schema: UpdateOrderSchema,
width: 100,
build: (v) => ({ customer_order_number: v }),
},
{
name: "UpdateOrderSchema currency",
schema: UpdateOrderSchema,
width: 10,
build: (v) => ({ currency: v }),
},
{
name: "UpdateOrderSchema language",
schema: UpdateOrderSchema,
width: 5,
build: (v) => ({ language: v }),
},
{
name: "CreateOrderFromQuotationSchema customerOrderNumber",
schema: CreateOrderFromQuotationSchema,
width: 100,
build: (v) => ({ quotationId: 1, customerOrderNumber: v }),
},
// ── invoices.schema.ts vs invoice_items / invoices ──
{
name: "CreateInvoiceSchema items.description",
schema: CreateInvoiceSchema,
width: 500,
build: (v) => ({ items: [{ description: v }] }),
},
{
name: "CreateInvoiceSchema items.unit",
schema: CreateInvoiceSchema,
width: 20,
build: (v) => ({ items: [{ unit: v }] }),
},
{
name: "CreateInvoiceSchema invoice_number",
schema: CreateInvoiceSchema,
width: 50,
build: (v) => ({ invoice_number: v }),
},
{
name: "CreateInvoiceSchema currency",
schema: CreateInvoiceSchema,
width: 10,
build: (v) => ({ currency: v }),
},
{
name: "CreateInvoiceSchema language",
schema: CreateInvoiceSchema,
width: 5,
build: (v) => ({ language: v }),
},
{
name: "CreateInvoiceSchema payment_method",
schema: CreateInvoiceSchema,
width: 50,
build: (v) => ({ payment_method: v }),
},
{
name: "CreateInvoiceSchema constant_symbol",
schema: CreateInvoiceSchema,
width: 20,
build: (v) => ({ constant_symbol: v }),
},
{
name: "CreateInvoiceSchema bank_swift",
schema: CreateInvoiceSchema,
width: 20,
build: (v) => ({ bank_swift: v }),
},
{
name: "CreateInvoiceSchema bank_iban",
schema: CreateInvoiceSchema,
width: 50,
build: (v) => ({ bank_iban: v }),
},
{
name: "CreateInvoiceSchema bank_account",
schema: CreateInvoiceSchema,
width: 50,
build: (v) => ({ bank_account: v }),
},
{
name: "CreateInvoiceSchema billing_text",
schema: CreateInvoiceSchema,
width: 500,
build: (v) => ({ billing_text: v }),
},
{
name: "UpdateInvoiceSchema currency",
schema: UpdateInvoiceSchema,
width: 10,
build: (v) => ({ currency: v }),
},
{
name: "UpdateInvoiceSchema language",
schema: UpdateInvoiceSchema,
width: 5,
build: (v) => ({ language: v }),
},
{
name: "UpdateInvoiceSchema payment_method",
schema: UpdateInvoiceSchema,
width: 50,
build: (v) => ({ payment_method: v }),
},
{
name: "UpdateInvoiceSchema constant_symbol",
schema: UpdateInvoiceSchema,
width: 20,
build: (v) => ({ constant_symbol: v }),
},
{
name: "UpdateInvoiceSchema bank_swift",
schema: UpdateInvoiceSchema,
width: 20,
build: (v) => ({ bank_swift: v }),
},
{
name: "UpdateInvoiceSchema bank_iban",
schema: UpdateInvoiceSchema,
width: 50,
build: (v) => ({ bank_iban: v }),
},
{
name: "UpdateInvoiceSchema bank_account",
schema: UpdateInvoiceSchema,
width: 50,
build: (v) => ({ bank_account: v }),
},
{
name: "UpdateInvoiceSchema billing_text",
schema: UpdateInvoiceSchema,
width: 500,
build: (v) => ({ billing_text: v }),
},
// ── trips.schema.ts vs trips ──
{
name: "CreateTripSchema route_from",
schema: CreateTripSchema,
width: 100,
build: (v) => ({ ...tripBase, route_from: v }),
},
{
name: "CreateTripSchema route_to",
schema: CreateTripSchema,
width: 100,
build: (v) => ({ ...tripBase, route_to: v }),
},
{
name: "UpdateTripSchema route_from",
schema: UpdateTripSchema,
width: 100,
build: (v) => ({ route_from: v }),
},
{
name: "UpdateTripSchema route_to",
schema: UpdateTripSchema,
width: 100,
build: (v) => ({ route_to: v }),
},
// ── auth.schema.ts vs users.totp_secret ──
// The stored value is AES-256-GCM ciphertext (base64(IV+ct+tag)) of the
// plaintext secret in a VarChar(255) column: a plaintext over ~164 chars
// overflows after encryption. Real TOTP secrets are 16-32 chars; cap 64.
{
name: "TotpEnableSchema secret",
schema: TotpEnableSchema,
width: 64,
build: (v) => ({ secret: v, code: "123456" }),
},
// ── received-invoices.schema.ts vs received_invoices ──
{
name: "CreateReceivedInvoiceSchema description",
schema: CreateReceivedInvoiceSchema,
width: 500,
build: (v) => ({ ...receivedBase, description: v }),
},
{
name: "CreateReceivedInvoiceSchema invoice_number",
schema: CreateReceivedInvoiceSchema,
width: 100,
build: (v) => ({ ...receivedBase, invoice_number: v }),
},
{
name: "CreateReceivedInvoiceSchema currency",
schema: CreateReceivedInvoiceSchema,
width: 3,
build: (v) => ({ ...receivedBase, currency: v }),
},
{
name: "UpdateReceivedInvoiceSchema description",
schema: UpdateReceivedInvoiceSchema,
width: 500,
build: (v) => ({ description: v }),
},
{
name: "UpdateReceivedInvoiceSchema invoice_number",
schema: UpdateReceivedInvoiceSchema,
width: 100,
build: (v) => ({ invoice_number: v }),
},
{
name: "UpdateReceivedInvoiceSchema currency",
schema: UpdateReceivedInvoiceSchema,
width: 3,
build: (v) => ({ currency: v }),
},
// ── warehouse.schema.ts (suppliers) vs sklad_suppliers ──
{
name: "CreateSupplierSchema ico",
schema: CreateSupplierSchema,
width: 20,
build: (v) => ({ name: "X", ico: v }),
},
{
name: "CreateSupplierSchema dic",
schema: CreateSupplierSchema,
width: 20,
build: (v) => ({ name: "X", dic: v }),
},
{
name: "UpdateSupplierSchema ico",
schema: UpdateSupplierSchema,
width: 20,
build: (v) => ({ ico: v }),
},
{
name: "UpdateSupplierSchema dic",
schema: UpdateSupplierSchema,
width: 20,
build: (v) => ({ dic: v }),
},
// ── customers.schema.ts vs customers ──
{
name: "CreateCustomerSchema company_id",
schema: CreateCustomerSchema,
width: 50,
build: (v) => ({ name: "X", company_id: v }),
},
{
name: "CreateCustomerSchema vat_id",
schema: CreateCustomerSchema,
width: 50,
build: (v) => ({ name: "X", vat_id: v }),
},
{
name: "CreateCustomerSchema postal_code",
schema: CreateCustomerSchema,
width: 20,
build: (v) => ({ name: "X", postal_code: v }),
},
{
name: "CreateCustomerSchema country",
schema: CreateCustomerSchema,
width: 100,
build: (v) => ({ name: "X", country: v }),
},
{
name: "UpdateCustomerSchema company_id",
schema: UpdateCustomerSchema,
width: 50,
build: (v) => ({ company_id: v }),
},
{
name: "UpdateCustomerSchema vat_id",
schema: UpdateCustomerSchema,
width: 50,
build: (v) => ({ vat_id: v }),
},
{
name: "UpdateCustomerSchema postal_code",
schema: UpdateCustomerSchema,
width: 20,
build: (v) => ({ postal_code: v }),
},
{
name: "UpdateCustomerSchema country",
schema: UpdateCustomerSchema,
width: 100,
build: (v) => ({ country: v }),
},
];
describe("Zod caps fit DB column widths (audit H2/M2/M4/M11/L1/L8d/L8e + adjacent)", () => {
for (const c of CASES) {
it(`${c.name} rejects ${c.width + 1} chars`, () => {
const result = c.schema.safeParse(c.build("x".repeat(c.width + 1)));
expect(result.success).toBe(false);
});
it(`${c.name} accepts ${c.width} chars`, () => {
const result = c.schema.safeParse(c.build("x".repeat(c.width)));
expect(result.success).toBe(true);
});
}
});

View File

@@ -58,34 +58,6 @@ describe("Zod form coercion + NaN rejection", () => {
}); });
describe("CreateQuotationSchema", () => { describe("CreateQuotationSchema", () => {
it("rejects NaN string in top-level vat_rate", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: "bad",
});
expect(result.success).toBe(false);
});
it("accepts valid vat_rate", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: 21,
});
expect(result.success).toBe(true);
});
it("coerces a valid numeric STRING vat_rate to a number", () => {
const result = CreateQuotationSchema.safeParse({
customer_id: 1,
vat_rate: "21",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.vat_rate).toBe(21);
expect(typeof result.data.vat_rate).toBe("number");
}
});
it("rejects NaN string in item quantity", () => { it("rejects NaN string in item quantity", () => {
const result = CreateQuotationSchema.safeParse({ const result = CreateQuotationSchema.safeParse({
customer_id: 1, customer_id: 1,
@@ -113,11 +85,13 @@ describe("Zod form coercion + NaN rejection", () => {
describe("CreateTripSchema", () => { describe("CreateTripSchema", () => {
it("coerces numeric STRING vehicle_id / start_km / end_km to numbers", () => { it("coerces numeric STRING vehicle_id / start_km / end_km to numbers", () => {
// km fields are Int columns — integer strings coerce, decimals are
// rejected (see trips-vehicles-km-int.test.ts for the rejection pins).
const result = CreateTripSchema.safeParse({ const result = CreateTripSchema.safeParse({
vehicle_id: "5", vehicle_id: "5",
trip_date: "2026-01-15", trip_date: "2026-01-15",
start_km: "100", start_km: "100",
end_km: "150.5", end_km: "150",
route_from: "Praha", route_from: "Praha",
route_to: "Brno", route_to: "Brno",
}); });
@@ -125,7 +99,7 @@ describe("Zod form coercion + NaN rejection", () => {
if (result.success) { if (result.success) {
expect(result.data.vehicle_id).toBe(5); expect(result.data.vehicle_id).toBe(5);
expect(result.data.start_km).toBe(100); expect(result.data.start_km).toBe(100);
expect(result.data.end_km).toBe(150.5); expect(result.data.end_km).toBe(150);
expect(typeof result.data.start_km).toBe("number"); expect(typeof result.data.start_km).toBe("number");
} }
}); });
@@ -268,18 +242,25 @@ describe("schema hardening — does not reject previously-valid input", () => {
).toBe(false); ).toBe(false);
}); });
it("warehouse supplier: empty email accepted, valid accepted, bad rejected", () => { it("warehouse supplier: dropped legacy contact/address keys are silently stripped", () => {
expect( // email/phone/contact_person/notes/address columns were replaced by the
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "" }).success, // customers-model custom_fields blob (2026-06); legacy clients still
).toBe(true); // sending them must not 400 — z.object strips unknown keys.
expect( const parsed = CreateSupplierSchema.safeParse({
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "x@y.cz" }) name: "Dodavatel",
.success, email: "x@y.cz",
).toBe(true); phone: "123",
expect( contact_person: "Jan",
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "nope" }) notes: "n",
.success, address: "Ulice 1",
).toBe(false); custom_fields: [{ name: "Kontakt", value: "Jan", showLabel: true }],
});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect("email" in parsed.data).toBe(false);
expect("address" in parsed.data).toBe(false);
expect(parsed.data.custom_fields).toHaveLength(1);
}
}); });
it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => { it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {
@@ -300,8 +281,8 @@ describe("schema hardening — does not reject previously-valid input", () => {
expect(b.success).toBe(true); expect(b.success).toBe(true);
if (b.success) expect(b.data.trip_date).toBe("2026-06-09"); if (b.success) expect(b.data.trip_date).toBe("2026-06-09");
// Genuinely malformed dates are still rejected. // Genuinely malformed dates are still rejected.
expect(CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success).toBe( expect(
false, CreateTripSchema.safeParse({ ...base, trip_date: "09/06/2026" }).success,
); ).toBe(false);
}); });
}); });

View File

@@ -0,0 +1,138 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import type { FastifyRequest } from "fastify";
import prisma from "../config/database";
import { refreshAccessToken, hashToken } from "../services/auth";
/**
* Pinning tests for audit finding H1: a session terminated via the sessions
* routes (replaced_at set, NO replaced_by_hash) presented on a later refresh
* is NOT token theft — it must be rejected with a plain 401 WITHOUT revoking
* the user's other sessions. Real theft (replay of a ROTATED token, i.e.
* replaced_by_hash set) must keep revoking the whole family.
*/
const N = "sess_term_";
const stamp = Date.now().toString(36);
const fakeReq = {
log: { warn: () => {} },
ip: "127.0.0.1",
headers: {},
} as unknown as FastifyRequest;
let userId: number;
const rawA = `${N}rawA_${stamp}`;
const rawB = `${N}rawB_${stamp}`;
const rawC = `${N}rawC_${stamp}`;
const rawD = `${N}rawD_${stamp}`;
let tokenAId: number;
let tokenDId: number;
async function cleanup() {
const users = await prisma.users.findMany({
where: { username: { startsWith: N } },
select: { id: true },
});
const ids = users.map((u) => u.id);
if (ids.length > 0) {
await prisma.refresh_tokens.deleteMany({ where: { user_id: { in: ids } } });
await prisma.users.deleteMany({ where: { id: { in: ids } } });
}
}
beforeAll(async () => {
await cleanup();
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_${stamp}`,
email: `${N}${stamp}@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
first_name: "Session",
last_name: "Terminate",
is_active: true,
role_id: role.id,
},
});
userId = user.id;
const future = new Date(Date.now() + 7 * 24 * 3600 * 1000);
const tokenA = await prisma.refresh_tokens.create({
data: {
user_id: userId,
token_hash: hashToken(rawA),
expires_at: future,
remember_me: false,
},
});
tokenAId = tokenA.id;
// Token B: terminated the way DELETE /sessions/:id does it — replaced_at
// ONLY, no replaced_by_hash, row kept.
await prisma.refresh_tokens.create({
data: {
user_id: userId,
token_hash: hashToken(rawB),
expires_at: future,
remember_me: false,
replaced_at: new Date(),
},
});
// Token C: legitimately ROTATED (both replaced_at and replaced_by_hash set)
// — replaying it is the theft signature.
await prisma.refresh_tokens.create({
data: {
user_id: userId,
token_hash: hashToken(rawC),
expires_at: future,
remember_me: false,
replaced_at: new Date(),
replaced_by_hash: hashToken(`${N}descendant_${stamp}`),
},
});
const tokenD = await prisma.refresh_tokens.create({
data: {
user_id: userId,
token_hash: hashToken(rawD),
expires_at: future,
remember_me: false,
},
});
tokenDId = tokenD.id;
});
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
describe("refreshAccessToken vs terminated sessions (audit H1)", () => {
it("rejects a manually terminated token with 401 WITHOUT revoking other sessions", async () => {
const result = await refreshAccessToken(rawB, fakeReq);
expect(result.type).toBe("error");
if (result.type === "error") expect(result.status).toBe(401);
// The user's OTHER session must survive — termination is not theft.
const tokenA = await prisma.refresh_tokens.findUnique({
where: { id: tokenAId },
});
expect(tokenA).not.toBeNull();
});
it("still revokes the whole family on replay of a ROTATED token (theft)", async () => {
const result = await refreshAccessToken(rawC, fakeReq);
expect(result.type).toBe("error");
// Breach containment must keep working: every session of the user is
// gone, including the otherwise-valid token D.
const tokenD = await prisma.refresh_tokens.findUnique({
where: { id: tokenDId },
});
expect(tokenD).toBeNull();
});
});

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { CreateTripSchema, UpdateTripSchema } from "../schemas/trips.schema";
import {
CreateVehicleSchema,
UpdateVehicleSchema,
} from "../schemas/vehicles.schema";
/**
* Pinning tests for audit finding L7: odometer fields are Int columns —
* a decimal reading must 400 at Zod instead of 500ing at Prisma (or
* silently truncating, corrupting the derived distance and
* vehicles.actual_km).
*/
const tripBase = {
vehicle_id: 1,
trip_date: "2098-01-15",
start_km: 100,
end_km: 200,
route_from: "A",
route_to: "B",
};
describe("km fields are integers (audit L7)", () => {
it.each([["start_km"], ["end_km"]])(
"CreateTripSchema rejects a decimal %s",
(field) => {
expect(
CreateTripSchema.safeParse({ ...tripBase, [field]: 100.5 }).success,
).toBe(false);
},
);
it("CreateTripSchema still accepts integer readings", () => {
expect(CreateTripSchema.safeParse(tripBase).success).toBe(true);
});
it("UpdateTripSchema rejects a decimal start_km", () => {
expect(UpdateTripSchema.safeParse({ start_km: 100.5 }).success).toBe(false);
});
it.each([["initial_km"], ["actual_km"]])(
"CreateVehicleSchema rejects a decimal %s",
(field) => {
expect(
CreateVehicleSchema.safeParse({
spz: "1AB 1234",
name: "Test",
[field]: 12345.5,
}).success,
).toBe(false);
},
);
it("UpdateVehicleSchema rejects a decimal actual_km, accepts an integer", () => {
expect(UpdateVehicleSchema.safeParse({ actual_km: 12345.5 }).success).toBe(
false,
);
expect(UpdateVehicleSchema.safeParse({ actual_km: 12345 }).success).toBe(
true,
);
});
});

View File

@@ -0,0 +1,165 @@
import { describe, it, expect, 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 { securityHeaders } from "../middleware/security";
import usersRoutes from "../routes/admin/users";
/**
* Pinning tests for audit finding M10: POST /users must strip role_id for
* non-admin callers (mirroring the PUT privilege-escalation guard) — a bare
* users.create holder must not be able to mint an account on a privileged
* role the caller itself lacks.
*/
const N = "usr_roleguard_";
const stamp = Date.now().toString(36);
let app: ReturnType<typeof Fastify>;
let adminToken: string;
let creatorToken: string;
let creatorRoleId: number;
let privilegedRoleId: number;
function token(user: { id: number; username: string; roleName: string }) {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
config.jwt.secret,
{ expiresIn: "15m" },
);
}
async function cleanup() {
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
const roles = await prisma.roles.findMany({
where: { name: { startsWith: N } },
select: { id: true },
});
const roleIds = roles.map((r) => r.id);
if (roleIds.length > 0) {
await prisma.role_permissions.deleteMany({
where: { role_id: { in: roleIds } },
});
await prisma.roles.deleteMany({ where: { id: { in: roleIds } } });
}
}
beforeAll(async () => {
await cleanup();
app = Fastify({ logger: false });
await app.register(cookie);
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
app.addHook("onRequest", securityHeaders);
await app.register(usersRoutes, { prefix: "/api/admin/users" });
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminToken = token({
id: admin.id,
username: admin.username,
roleName: "admin",
});
// Caller role: ONLY users.create.
const creatorRole = await prisma.roles.create({
data: { name: `${N}creator_${stamp}`, display_name: "Creator Only" },
});
creatorRoleId = creatorRole.id;
const perm = await prisma.permissions.findFirst({
where: { name: "users.create" },
});
if (!perm) throw new Error("Test setup: users.create permission missing");
await prisma.role_permissions.create({
data: { role_id: creatorRoleId, permission_id: perm.id },
});
const creator = await prisma.users.create({
data: {
username: `${N}creator_${stamp}`,
email: `${N}creator_${stamp}@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
first_name: "Creator",
last_name: "Only",
is_active: true,
role_id: creatorRoleId,
},
});
creatorToken = token({
id: creator.id,
username: creator.username,
roleName: creatorRole.name,
});
// Target role the caller does NOT hold — its content is irrelevant, the
// guard must strip ANY role assignment from a non-admin create.
const privilegedRole = await prisma.roles.create({
data: { name: `${N}privileged_${stamp}`, display_name: "Privileged" },
});
privilegedRoleId = privilegedRole.id;
});
afterAll(async () => {
if (app) await app.close();
await cleanup();
await prisma.$disconnect();
});
describe("POST /users role escalation guard (audit M10)", () => {
it("strips role_id when the caller is not admin", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/users",
headers: {
Authorization: `Bearer ${creatorToken}`,
"Content-Type": "application/json",
},
payload: {
username: `${N}victim_${stamp}`,
email: `${N}victim_${stamp}@test.local`,
password: "Heslo12345",
first_name: "Victim",
last_name: "User",
role_id: privilegedRoleId,
},
});
expect(res.statusCode).toBe(201);
const created = await prisma.users.findFirst({
where: { username: `${N}victim_${stamp}` },
});
expect(created).not.toBeNull();
expect(created?.role_id).not.toBe(privilegedRoleId);
});
it("keeps role assignment for an admin caller", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/users",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: {
username: `${N}byadmin_${stamp}`,
email: `${N}byadmin_${stamp}@test.local`,
password: "Heslo12345",
first_name: "ByAdmin",
last_name: "User",
role_id: privilegedRoleId,
},
});
expect(res.statusCode).toBe(201);
const created = await prisma.users.findFirst({
where: { username: `${N}byadmin_${stamp}` },
});
expect(created?.role_id).toBe(privilegedRoleId);
});
});

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, 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 { securityHeaders } from "../middleware/security";
import warehouseRoutes from "../routes/admin/warehouse";
/**
* Pinning tests for audit finding M7: date_from/date_to filters on warehouse
* lists must cover the FULL local calendar days. created_at holds local-time
* instants; the old bounds (gte/lte `new Date("YYYY-MM-DD")` = UTC midnight)
* excluded the whole inclusive end day and the first 1-2 morning hours of
* the start day.
*/
const N = "wh_datefilter_";
const DAY = "2098-04-08";
let app: ReturnType<typeof Fastify>;
let adminToken: string;
let earlyId: number; // 00:30 local on DAY — start-day morning window
let middayId: number; // 09:00 local on DAY
let lateId: number; // 23:30 local on DAY — end-day coverage
let nextDayId: number; // 00:30 local the day AFTER — must stay excluded
async function cleanup() {
await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } });
}
beforeAll(async () => {
await cleanup();
app = Fastify({ logger: false });
await app.register(cookie);
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
app.addHook("onRequest", securityHeaders);
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
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" },
);
// Local-time instants on the filtered day (year 2098 keeps real data out).
const early = await prisma.sklad_receipts.create({
data: { notes: `${N}early`, created_at: new Date(2098, 3, 8, 0, 30, 0) },
});
earlyId = early.id;
const midday = await prisma.sklad_receipts.create({
data: { notes: `${N}midday`, created_at: new Date(2098, 3, 8, 9, 0, 0) },
});
middayId = midday.id;
const late = await prisma.sklad_receipts.create({
data: { notes: `${N}late`, created_at: new Date(2098, 3, 8, 23, 30, 0) },
});
lateId = late.id;
const nextDay = await prisma.sklad_receipts.create({
data: { notes: `${N}nextday`, created_at: new Date(2098, 3, 9, 0, 30, 0) },
});
nextDayId = nextDay.id;
});
afterAll(async () => {
if (app) await app.close();
await cleanup();
await prisma.$disconnect();
});
describe("warehouse receipts date filter (audit M7)", () => {
it("a single-day range returns every receipt of that local day and nothing else", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/receipts?date_from=${DAY}&date_to=${DAY}&limit=100`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id);
expect(ids).toContain(earlyId); // 00:30 — dropped by the old UTC from-bound
expect(ids).toContain(middayId); // 09:00 — dropped by the old lte to-bound
expect(ids).toContain(lateId); // 23:30 — dropped by the old lte to-bound
expect(ids).not.toContain(nextDayId); // next day stays out
});
});

View File

@@ -0,0 +1,206 @@
import { describe, it, expect, 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 { securityHeaders } from "../middleware/security";
import warehouseRoutes from "../routes/admin/warehouse";
/**
* Pinning tests for audit finding M8: warehouse receipts/issues create+update
* must pre-validate FK existence (supplier, item, location, project, explicit
* batch) and return Czech 400s — a dangling id otherwise raises P2003 at
* Prisma and surfaces as a generic 500. The document modules and trips.ts
* pre-validate this way; warehouse omitted the guard.
*/
const N = "wh_fk400_";
const MISSING_ID = 99999999;
let app: ReturnType<typeof Fastify>;
let adminToken: string;
let projectId: number;
let itemId: number;
let draftReceiptId: number;
async function cleanup() {
await prisma.sklad_issue_lines.deleteMany({
where: { issue: { notes: { contains: N } } },
});
await prisma.sklad_issues.deleteMany({ where: { notes: { contains: N } } });
await prisma.sklad_batches.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_receipt_lines.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } });
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
await prisma.projects.deleteMany({ where: { name: { startsWith: N } } });
}
beforeAll(async () => {
await cleanup();
app = Fastify({ logger: false });
await app.register(cookie);
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
app.addHook("onRequest", securityHeaders);
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
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" },
);
const project = await prisma.projects.create({
data: { name: `${N}project`, status: "active" },
});
projectId = project.id;
const item = await prisma.sklad_items.create({
data: { name: `${N}item`, unit: "ks" },
});
itemId = item.id;
// In-stock batch so issue tests get past availability checks.
const receipt = await prisma.sklad_receipts.create({
data: { notes: `${N}stock`, status: "CONFIRMED" },
});
const line = await prisma.sklad_receipt_lines.create({
data: {
receipt_id: receipt.id,
item_id: itemId,
quantity: 10,
unit_price: 5,
},
});
await prisma.sklad_batches.create({
data: {
item_id: itemId,
receipt_line_id: line.id,
quantity: 10,
original_qty: 10,
unit_price: 5,
received_at: new Date(2026, 0, 1, 12, 0, 0),
is_consumed: false,
},
});
// DRAFT receipt for the PUT test.
const draft = await prisma.sklad_receipts.create({
data: {
notes: `${N}draft`,
status: "DRAFT",
items: { create: [{ item_id: itemId, quantity: 1, unit_price: 5 }] },
},
});
draftReceiptId = draft.id;
});
afterAll(async () => {
if (app) await app.close();
await cleanup();
await prisma.$disconnect();
});
function post(url: string, payload: unknown) {
return app.inject({
method: "POST",
url,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: payload as Record<string, unknown>,
});
}
describe("warehouse FK pre-validation (audit M8)", () => {
it("POST /receipts with a nonexistent item_id returns 400, not 500", async () => {
const res = await post("/api/admin/warehouse/receipts", {
notes: `${N}r1`,
items: [{ item_id: MISSING_ID, quantity: 1, unit_price: 10 }],
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain("nenalezen");
});
it("POST /receipts with a nonexistent supplier_id returns 400, not 500", async () => {
const res = await post("/api/admin/warehouse/receipts", {
notes: `${N}r2`,
supplier_id: MISSING_ID,
items: [{ item_id: itemId, quantity: 1, unit_price: 10 }],
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain("nenalezen");
});
it("POST /receipts with a nonexistent location_id returns 400, not 500", async () => {
const res = await post("/api/admin/warehouse/receipts", {
notes: `${N}r3`,
items: [
{
item_id: itemId,
quantity: 1,
unit_price: 10,
location_id: MISSING_ID,
},
],
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain("nenalezen");
});
it("PUT /receipts/:id with a nonexistent item_id returns 400, not 500", async () => {
const res = await app.inject({
method: "PUT",
url: `/api/admin/warehouse/receipts/${draftReceiptId}`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: {
notes: `${N}draft`,
items: [{ item_id: MISSING_ID, quantity: 1, unit_price: 10 }],
},
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain("nenalezen");
});
it("POST /issues with a nonexistent project_id returns 400, not 500", async () => {
const res = await post("/api/admin/warehouse/issues", {
notes: `${N}i1`,
project_id: MISSING_ID,
items: [{ item_id: itemId, quantity: 1 }],
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain("nenalezen");
});
it("POST /issues with a nonexistent explicit batch_id returns 400, not 500", async () => {
const res = await post("/api/admin/warehouse/issues", {
notes: `${N}i2`,
project_id: projectId,
items: [{ item_id: itemId, quantity: 1, batch_id: MISSING_ID }],
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain("nenalezen");
});
it("valid receipt create still succeeds", async () => {
const res = await post("/api/admin/warehouse/receipts", {
notes: `${N}ok`,
items: [{ item_id: itemId, quantity: 2, unit_price: 10 }],
});
expect(res.statusCode).toBe(201);
});
});

View File

@@ -0,0 +1,124 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "../config/database";
import { confirmInventorySession } from "../services/warehouse.service";
/**
* Pinning tests for audit finding C3: confirmInventorySession must be atomic.
* A session whose item list contains a valid surplus line BEFORE a deficit
* line that cannot be satisfied (no batches) must fail WITHOUT committing the
* surplus item's corrective receipt/batch — and retries must not accumulate
* phantom stock.
*/
const N = "wh_invconf_";
let itemAId: number; // surplus line (processed first — lower line id)
let itemBId: number; // deficit line with no batches -> selectFifoBatches throws
let sessionId: number;
/** Corrective receipts carry generated notes without our prefix — collect
* them via their lines' items before deleting. */
async function cleanup() {
const lines = await prisma.sklad_receipt_lines.findMany({
where: { item: { name: { contains: N } } },
select: { receipt_id: true },
});
const receiptIds = [...new Set(lines.map((l) => l.receipt_id))];
await prisma.sklad_batches.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_receipt_lines.deleteMany({
where: { item: { name: { contains: N } } },
});
if (receiptIds.length > 0) {
await prisma.sklad_receipts.deleteMany({
where: { id: { in: receiptIds } },
});
}
await prisma.sklad_inventory_lines.deleteMany({
where: { session: { notes: { contains: N } } },
});
await prisma.sklad_inventory_sessions.deleteMany({
where: { notes: { contains: N } },
});
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
}
beforeAll(async () => {
await cleanup();
const itemA = await prisma.sklad_items.create({
data: { name: `${N}surplus_item`, unit: "ks" },
});
itemAId = itemA.id;
const itemB = await prisma.sklad_items.create({
data: { name: `${N}deficit_item`, unit: "ks" },
});
itemBId = itemB.id;
// Surplus line created first => lower id => processed first by the confirm
// loop. Deficit line for an item with NO batches forces the FIFO throw.
const session = await prisma.sklad_inventory_sessions.create({
data: {
notes: `${N}session`,
items: {
create: [
{ item_id: itemAId, system_qty: 0, actual_qty: 5, difference: 5 },
{ item_id: itemBId, system_qty: 3, actual_qty: 0, difference: -3 },
],
},
},
});
sessionId = session.id;
});
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
describe("confirmInventorySession atomicity (audit C3)", () => {
it("rolls back the surplus corrective receipt when a later deficit line fails", async () => {
const result = await confirmInventorySession(sessionId);
// The confirm must fail — item B has nothing to consume.
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.status).toBe(400);
expect(result.error).toContain(`ID ${itemBId}`);
}
// ...and item A's surplus writes must NOT have been committed.
const receiptLines = await prisma.sklad_receipt_lines.findMany({
where: { item_id: itemAId },
});
expect(receiptLines).toHaveLength(0);
const batches = await prisma.sklad_batches.findMany({
where: { item_id: itemAId },
});
expect(batches).toHaveLength(0);
// The session itself stays DRAFT and unnumbered either way.
const session = await prisma.sklad_inventory_sessions.findUnique({
where: { id: sessionId },
});
expect(session?.status).toBe("DRAFT");
expect(session?.session_number).toBeNull();
});
it("does not accumulate phantom stock when the failed confirm is retried", async () => {
await confirmInventorySession(sessionId);
await confirmInventorySession(sessionId);
const batches = await prisma.sklad_batches.findMany({
where: { item_id: itemAId },
});
expect(batches).toHaveLength(0);
const receipts = await prisma.sklad_receipts.findMany({
where: { notes: { contains: `inventarizace #${sessionId}` } },
});
expect(receipts).toHaveLength(0);
});
});

View File

@@ -0,0 +1,196 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "../config/database";
import { confirmIssue } from "../services/warehouse.service";
/**
* Pinning tests for audit finding C2: confirmIssue must validate duplicate
* lines pointing at the SAME batch against their combined quantity. Two lines
* that each pass the per-line check individually must not decrement the batch
* cumulatively below zero (silent over-issue hidden from stock totals).
*
* The duplicate-lines draft is exactly what POST /issues produces when two
* same-item lines without batch_id FIFO-resolve to the same oldest batch
* (per-line resolution persists nothing between lines), and is equally
* reachable with explicit batch_ids.
*/
const N = "wh_issueconf_";
let projectId: number;
let userId: number;
let itemId: number;
let batch1Id: number;
let overIssueId: number;
let exactIssueId: number;
async function cleanup() {
await prisma.sklad_issue_lines.deleteMany({
where: { issue: { notes: { contains: N } } },
});
await prisma.sklad_issues.deleteMany({ where: { notes: { contains: N } } });
await prisma.sklad_batches.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_receipt_lines.deleteMany({
where: { item: { name: { contains: N } } },
});
await prisma.sklad_receipts.deleteMany({ where: { notes: { contains: N } } });
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
await prisma.projects
.deleteMany({ where: { name: { startsWith: N } } })
.catch(() => {});
}
beforeAll(async () => {
await cleanup();
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
if (!adminRole)
throw new Error("Admin role not found — seed the database first");
const user = await prisma.users.create({
data: {
username: `${N}user`,
email: `${N}user@test.local`,
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "Issue",
last_name: "Confirm",
is_active: true,
role_id: adminRole.id,
},
});
userId = user.id;
const project = await prisma.projects.create({
data: { name: `${N}project`, status: "active" },
});
projectId = project.id;
const item = await prisma.sklad_items.create({
data: { name: `${N}item`, unit: "ks" },
});
itemId = item.id;
// Two batches of 8 — total stock 16, so the per-item aggregate check
// passes a combined quantity of 10, but FIFO resolves both lines to the
// OLDER batch B1 which only holds 8.
const receipt = await prisma.sklad_receipts.create({
data: { notes: `${N}receipt`, status: "CONFIRMED" },
});
const line1 = await prisma.sklad_receipt_lines.create({
data: {
receipt_id: receipt.id,
item_id: itemId,
quantity: 8,
unit_price: 10,
},
});
const line2 = await prisma.sklad_receipt_lines.create({
data: {
receipt_id: receipt.id,
item_id: itemId,
quantity: 8,
unit_price: 10,
},
});
const batch1 = await prisma.sklad_batches.create({
data: {
item_id: itemId,
receipt_line_id: line1.id,
quantity: 8,
original_qty: 8,
unit_price: 10,
received_at: new Date(2026, 0, 1, 12, 0, 0),
is_consumed: false,
},
});
batch1Id = batch1.id;
await prisma.sklad_batches.create({
data: {
item_id: itemId,
receipt_line_id: line2.id,
quantity: 8,
original_qty: 8,
unit_price: 10,
received_at: new Date(2026, 0, 2, 12, 0, 0),
is_consumed: false,
},
});
// Draft mirroring the FIFO resolution: two lines, both on B1, 5 + 5 = 10 > 8.
const overIssue = await prisma.sklad_issues.create({
data: {
project_id: projectId,
notes: `${N}over_issue`,
items: {
create: [
{ item_id: itemId, batch_id: batch1Id, quantity: 5 },
{ item_id: itemId, batch_id: batch1Id, quantity: 5 },
],
},
},
});
overIssueId = overIssue.id;
// Control draft: duplicate lines whose combined quantity EXACTLY fits B1.
const exactIssue = await prisma.sklad_issues.create({
data: {
project_id: projectId,
notes: `${N}exact_issue`,
items: {
create: [
{ item_id: itemId, batch_id: batch1Id, quantity: 3 },
{ item_id: itemId, batch_id: batch1Id, quantity: 5 },
],
},
},
});
exactIssueId = exactIssue.id;
});
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
describe("confirmIssue cross-line batch totals (audit C2)", () => {
it("rejects duplicate same-batch lines whose combined quantity exceeds the batch", async () => {
const result = await confirmIssue(overIssueId, userId);
expect("error" in result).toBe(true);
if ("error" in result) expect(result.status).toBe(400);
// The batch must be untouched — not driven negative and hidden.
const b1 = await prisma.sklad_batches.findUnique({
where: { id: batch1Id },
});
expect(Number(b1?.quantity)).toBe(8);
expect(b1?.is_consumed).toBe(false);
const negatives = await prisma.sklad_batches.findMany({
where: { item_id: itemId, quantity: { lt: 0 } },
});
expect(negatives).toHaveLength(0);
const issue = await prisma.sklad_issues.findUnique({
where: { id: overIssueId },
});
expect(issue?.status).toBe("DRAFT");
});
it("still confirms duplicate same-batch lines whose combined quantity fits", async () => {
const result = await confirmIssue(exactIssueId, userId);
expect("error" in result).toBe(false);
const b1 = await prisma.sklad_batches.findUnique({
where: { id: batch1Id },
});
expect(Number(b1?.quantity)).toBe(0);
expect(b1?.is_consumed).toBe(true);
});
});

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, 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 { securityHeaders } from "../middleware/security";
import warehouseRoutes from "../routes/admin/warehouse";
/**
* Pinning tests for the verified-adjacent audit finding: GET /items ignores
* the client `sort` param (the WarehouseItems page sends one — default
* item_number) and hardcodes `orderBy: { name: "asc" }` with no `{ id }`
* tiebreak.
*/
const N = "wh_itemsort_";
let app: ReturnType<typeof Fastify>;
let adminToken: string;
// Names sort A→B, item numbers sort the OPPOSITE way, so honoring the sort
// param produces a different first row than the hardcoded name ordering.
let itemAId: number; // name "...a_name", item_number "...Z2"
let itemBId: number; // name "...b_name", item_number "...A1"
async function cleanup() {
await prisma.sklad_items.deleteMany({ where: { name: { contains: N } } });
}
beforeAll(async () => {
await cleanup();
app = Fastify({ logger: false });
await app.register(cookie);
await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
app.addHook("onRequest", securityHeaders);
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
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" },
);
const itemA = await prisma.sklad_items.create({
data: { name: `${N}a_name`, item_number: `${N}Z2`, unit: "ks" },
});
itemAId = itemA.id;
const itemB = await prisma.sklad_items.create({
data: { name: `${N}b_name`, item_number: `${N}A1`, unit: "ks" },
});
itemBId = itemB.id;
});
afterAll(async () => {
if (app) await app.close();
await cleanup();
await prisma.$disconnect();
});
describe("GET /warehouse/items sort param", () => {
it("honors sort=item_number (asc puts the A1 item first)", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items?search=${N}&sort=item_number&order=asc`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id);
expect(ids).toEqual([itemBId, itemAId]);
});
it("defaults to name ordering when no sort is given", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items?search=${N}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const ids = (res.json().data as Array<{ id: number }>).map((r) => r.id);
expect(ids).toEqual([itemAId, itemBId]);
});
it("ignores a non-allow-listed sort field (no 500)", async () => {
const res = await app.inject({
method: "GET",
url: `/api/admin/warehouse/items?search=${N}&sort=total_quantity`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
});
});

View File

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

View File

@@ -162,7 +162,7 @@ export default function BulkPlanModal({
value={form.project_id} value={form.project_id}
onChange={(val) => setForm({ ...form, project_id: val })} onChange={(val) => setForm({ ...form, project_id: val })}
options={[ options={[
{ value: "", label: "— bez projektu —" }, { value: "", label: "Vyberte projekt" },
...projects.map((p) => ({ value: String(p.id), label: p.name })), ...projects.map((p) => ({ value: String(p.id), label: p.name })),
]} ]}
/> />

View File

@@ -0,0 +1,249 @@
import { useState } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
/**
* Chat-bubble style note row (author avatar + bubble), shared by the
* project notes list. Mobile-first: the header wraps (name / timestamp),
* the action icons live in the bubble's top-right corner so they never
* squeeze the content column, and editing happens inline in the bubble.
*/
const initialsOf = (name: string): string =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
// Same edit/delete glyphs as the data tables (AttendanceShiftTable & co.).
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
function formatNoteDate(dateStr: string | null | undefined): string {
if (!dateStr) return "";
const d = new Date(dateStr);
if (isNaN(d.getTime())) return "";
const hours = String(d.getHours()).padStart(2, "0");
const mins = String(d.getMinutes()).padStart(2, "0");
return `${d.getDate()}. ${d.getMonth() + 1}. ${d.getFullYear()} ${hours}:${mins}`;
}
export interface NoteCardProps {
authorName: string;
createdAt: string;
editedAt?: string | null;
content: string;
canEdit?: boolean;
canDelete?: boolean;
deleting?: boolean;
/** Reject (throw) to keep the editor open — the caller shows the alert. */
onSave?: (content: string) => Promise<void>;
onDelete?: () => void;
}
export default function NoteCard({
authorName,
createdAt,
editedAt,
content,
canEdit = false,
canDelete = false,
deleting = false,
onSave,
onDelete,
}: NoteCardProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const startEdit = () => {
setDraft(content);
setEditing(true);
};
const save = async () => {
if (!onSave || !draft.trim() || saving) return;
setSaving(true);
try {
await onSave(draft.trim());
setEditing(false);
} catch {
// Caller already alerted; keep the editor open with the draft.
} finally {
setSaving(false);
}
};
return (
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start" }}>
<Box
aria-hidden
sx={(t) => ({
width: 32,
height: 32,
borderRadius: "50%",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.75rem",
fontWeight: 700,
bgcolor: `rgba(${t.vars!.palette.primary.mainChannel} / 0.12)`,
color: "primary.main",
})}
>
{initialsOf(authorName)}
</Box>
<Box
sx={{
flex: 1,
minWidth: 0,
bgcolor: "action.hover",
borderRadius: 2,
borderTopLeftRadius: 4,
p: 1.5,
}}
>
<Box
sx={{ display: "flex", alignItems: "flex-start", gap: 1, mb: 0.5 }}
>
<Box
sx={{
flex: 1,
minWidth: 0,
display: "flex",
alignItems: "baseline",
columnGap: 1,
flexWrap: "wrap",
}}
>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{authorName}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatNoteDate(createdAt)}
{editedAt && (
<Box component="span" sx={{ fontStyle: "italic" }}>
{" "}
· upraveno {formatNoteDate(editedAt)}
</Box>
)}
</Typography>
</Box>
{!editing && (canEdit || canDelete) && (
<Box
sx={{
display: "flex",
gap: 0.25,
flexShrink: 0,
mt: -0.5,
mr: -0.5,
}}
>
{canEdit && (
<IconButton
size="small"
onClick={startEdit}
title="Upravit poznámku"
aria-label="Upravit poznámku"
>
{EditIcon}
</IconButton>
)}
{canDelete && (
<IconButton
size="small"
color="error"
onClick={onDelete}
title="Smazat poznámku"
aria-label="Smazat poznámku"
disabled={deleting}
>
{DeleteIcon}
</IconButton>
)}
</Box>
)}
</Box>
{editing ? (
<Box>
<TextField
value={draft}
onChange={(e) => setDraft(e.target.value)}
multiline
minRows={2}
maxRows={16}
fullWidth
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && e.ctrlKey && draft.trim()) save();
if (e.key === "Escape") setEditing(false);
}}
/>
<Box sx={{ mt: 1, display: "flex", gap: 1 }}>
<Button
size="small"
variant="contained"
onClick={save}
disabled={saving || !draft.trim()}
>
{saving ? "Ukládání…" : "Uložit"}
</Button>
<Button
size="small"
color="inherit"
onClick={() => setEditing(false)}
disabled={saving}
>
Zrušit
</Button>
</Box>
</Box>
) : (
<Typography
variant="body2"
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
>
{content}
</Typography>
)}
</Box>
</Box>
);
}

View File

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

View File

@@ -148,7 +148,7 @@ const TrashIcon = (
); );
export default function PlanCellModal(props: Props) { export default function PlanCellModal(props: Props) {
const { open, mode, onClose } = props; const { open, mode } = props;
if (!open || mode.kind === "closed") return null; if (!open || mode.kind === "closed") return null;
if (mode.kind === "view") return <ViewModal {...props} />; if (mode.kind === "view") return <ViewModal {...props} />;
if (mode.kind === "day-in-range") if (mode.kind === "day-in-range")
@@ -392,7 +392,7 @@ function EditForm(props: Props) {
value={projectId === null ? "" : String(projectId)} value={projectId === null ? "" : String(projectId)}
onChange={(val) => setProjectId(val ? Number(val) : null)} onChange={(val) => setProjectId(val ? Number(val) : null)}
options={[ options={[
{ value: "", label: "— bez projektu —" }, { value: "", label: "Vyberte projekt" },
...projects.map((p) => ({ ...projects.map((p) => ({
value: String(p.id), value: String(p.id),
label: p.name, label: p.name,
@@ -488,6 +488,7 @@ function DayInRangeModal(
onClose={onClose} onClose={onClose}
onSubmit={onClose} onSubmit={onClose}
submitText="Zavřít — ponechat rozsah beze změny" submitText="Zavřít — ponechat rozsah beze změny"
hideCancel
maxWidth="md" maxWidth="md"
> >
<Typography variant="body2" sx={{ mb: 2 }}> <Typography variant="body2" sx={{ mb: 2 }}>

View File

@@ -1,6 +1,7 @@
import { useMemo } from "react"; import { useMemo, useState } from "react";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { styled } from "@mui/material/styles"; import { styled, useTheme } from "@mui/material/styles";
import useMediaQuery from "@mui/material/useMediaQuery";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import { import {
GridData, GridData,
@@ -11,7 +12,8 @@ import {
} from "../lib/queries/plan"; } from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects"; import type { Project } from "../lib/queries/projects";
import PlanRangeChips from "./PlanRangeChips"; import PlanRangeChips from "./PlanRangeChips";
import { LoadingState } from "../ui"; import { LoadingState, Select, Field } from "../ui";
import { useAuth } from "../context/AuthContext";
import { fonts } from "../theme"; import { fonts } from "../theme";
/** /**
@@ -30,7 +32,10 @@ const PlanGridRoot = styled(Box)(({ theme }) => ({
border: `1px solid ${theme.vars!.palette.divider}`, border: `1px solid ${theme.vars!.palette.divider}`,
borderRadius: 14, borderRadius: 14,
boxShadow: theme.shadows[2], boxShadow: theme.shadows[2],
maxHeight: "calc(100dvh - 240px)", // Never collapse below ~5 rows: on a phone in LANDSCAPE 100dvh is only
// ~360-400px, so the bare calc left <160px and the cells disappeared
// under the sticky header. The grid scrolls internally either way.
maxHeight: "max(calc(100dvh - 240px), 360px)",
isolation: "isolate", isolation: "isolate",
"& .plan-grid": { "& .plan-grid": {
@@ -464,6 +469,15 @@ export default function PlanGrid({
pulseKey, pulseKey,
onCellClick, onCellClick,
}: Props) { }: Props) {
const theme = useTheme();
// Phone layout: the multi-person grid doesn't fit a portrait viewport, so
// a person picker shows ONE column at a time (Datum + selected person).
// Desktop/tablet keeps all columns.
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const { user: authUser } = useAuth();
// null = "no explicit choice yet" → defaults to the logged-in user when
// they are in the plan, else the first person.
const [mobileUserId, setMobileUserId] = useState<number | null>(null);
const today = useMemo(() => todayIso(), []); const today = useMemo(() => todayIso(), []);
const catMap = useMemo(() => categoryMap(categories), [categories]); const catMap = useMemo(() => categoryMap(categories), [categories]);
// Index projects by id once per projects change instead of a linear // Index projects by id once per projects change instead of a linear
@@ -477,13 +491,23 @@ export default function PlanGrid({
}, [projects]); }, [projects]);
// Memoize the day list so it isn't rebuilt on every render (only when the // 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. // visible range changes). Stable identity also keeps the row map cheap.
const dateFrom = data?.date_from;
const dateTo = data?.date_to;
const days = useMemo( const days = useMemo(
() => (data ? eachDay(data.date_from, data.date_to) : []), () => (dateFrom && dateTo ? eachDay(dateFrom, dateTo) : []),
[data?.date_from, data?.date_to], [dateFrom, dateTo],
); );
if (!data) return <LoadingState />; if (!data) return <LoadingState />;
const users = data.users; const allUsers = data.users;
// Mobile: narrow to the picked person (derived, not stored — a stale pick
// after the user list changes falls back gracefully).
const mobileUser =
allUsers.find((u) => u.id === mobileUserId) ??
allUsers.find((u) => u.id === authUser?.id) ??
allUsers[0];
const users =
isMobile && allUsers.length > 1 && mobileUser ? [mobileUser] : allUsers;
// pulseKey?.nonce is included in the data-pulse attribute so a second // pulseKey?.nonce is included in the data-pulse attribute so a second
// mutation on the same cell re-triggers the animation (CSS animations // mutation on the same cell re-triggers the animation (CSS animations
// don't restart unless the keyframe applies to a fresh element/class // don't restart unless the keyframe applies to a fresh element/class
@@ -493,156 +517,172 @@ export default function PlanGrid({
: undefined; : undefined;
return ( return (
<PlanGridRoot data-pulse={pulseAttr}> <>
<table className="plan-grid"> {isMobile && allUsers.length > 1 && (
{/* The colgroup is what actually controls column width in a <Box sx={{ mb: 1.5 }}>
<Field label="Osoba">
<Select
value={String(mobileUser?.id ?? "")}
onChange={(value) => setMobileUserId(Number(value))}
options={allUsers.map((u) => ({
value: String(u.id),
label: u.full_name,
}))}
/>
</Field>
</Box>
)}
<PlanGridRoot data-pulse={pulseAttr}>
<table className="plan-grid">
{/* The colgroup is what actually controls column width in a
table — `min-width` on `<th>`/`<td>` is just a floor that table — `min-width` on `<th>`/`<td>` is just a floor that
`table-layout: auto` happily blows past. The first column `table-layout: auto` happily blows past. The first column
gets a fixed width via the col element so the date stamp gets a fixed width via the col element so the date stamp
doesn't get stretched to share space with person columns. */} doesn't get stretched to share space with person columns. */}
<colgroup> <colgroup>
<col className="plan-grid-date-col" /> <col className="plan-grid-date-col" />
{users.map((u) => ( {users.map((u) => (
<col key={u.id} /> <col key={u.id} />
))} ))}
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th className="plan-grid-date-col">Datum</th> <th className="plan-grid-date-col">Datum</th>
{users.map((u) => { {users.map((u) => {
const { first, last } = splitName(u.full_name); const { first, last } = splitName(u.full_name);
return ( return (
<th key={u.id}> <th key={u.id}>
<span className="plan-person-head"> <span className="plan-person-head">
<span className="plan-person-dot" aria-hidden /> <span className="plan-person-dot" aria-hidden />
<span className="plan-person-name"> <span className="plan-person-name">
<strong title={u.full_name}> <strong title={u.full_name}>
{first} {first}
{last ? ` ${last}` : ""} {last ? ` ${last}` : ""}
</strong> </strong>
{shortRole(u.role_name) && ( {shortRole(u.role_name) && (
<small>{shortRole(u.role_name)}</small> <small>{shortRole(u.role_name)}</small>
)} )}
</span>
</span> </span>
</span> </th>
</th> );
})}
</tr>
</thead>
<tbody>
{days.map((date) => {
const dow = czechWeekday(date);
const dayNum = date.slice(8, 10);
const isToday = date === today;
const trCls = [
isWeekend(date) ? "plan-grid-weekend" : "",
isToday ? "is-today" : "",
]
.filter(Boolean)
.join(" ");
return (
<tr key={date} className={trCls}>
<td className="plan-grid-date-col">
<span className="plan-date-stamp">
<span className="plan-date-daynum">{dayNum}</span>
<span className="plan-date-dow">{dow}</span>
</span>
</td>
{users.map((u) => {
const cellArr = data.cells[u.id]?.[date] ?? [];
const cell = cellArr[0] ?? null;
const past = isPastDate(date, today);
// Past-day cells are read-only in the UI: an empty past
// cell is non-interactive (no create modal, no "+" hint),
// a past cell with data opens in view mode only. The
// server still enforces the past-date rule with a 403
// (defense in depth), but the click path here never
// reaches a create/edit submission for a past date.
const isLocked = !canEdit || past;
// `isPulsing` is true for the single (user, date) cell
// that the most recent successful mutation touched.
// CSS restarts the keyframe animation whenever the
// `nonce` changes (we embed it in data-pulse on the
// wrapper, see above), so back-to-back mutations on the
// same cell re-trigger the pulse.
const isPulsing =
!!pulseKey &&
pulseKey.userId === u.id &&
pulseKey.date === date;
let cls: string;
if (cell) {
cls = isLocked
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell";
} else {
cls = isLocked
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell plan-cell--empty";
}
if (isPulsing) cls += " plan-cell--pulse";
return (
<td key={u.id}>
<button
type="button"
className={cls}
style={
cell
? ({
"--cat-color": catMap[cell.category]?.color,
} as CSSProperties)
: undefined
}
onClick={() => onCellClick(u.id, date, cellArr)}
aria-label={
cell
? cellArr.length === 1
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
: isLocked
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
: `${u.full_name}, ${date}, prázdné — přidat záznam`
}
>
{cellArr.map((c, i) => (
<Box
key={
c.entryId != null
? c.entryId
: c.overrideId != null
? `o${c.overrideId}`
: i
}
className="plan-cell-record"
style={
{
"--cat-color": catMap[c.category]?.color,
} as CSSProperties
}
>
<PlanRangeChips
cell={c}
project={
c.project_id
? (projectMap.get(c.project_id) ?? null)
: null
}
categoryLabel={planCategoryLabel(
c.category,
catMap,
)}
/>
</Box>
))}
</button>
</td>
);
})}
</tr>
); );
})} })}
</tr> </tbody>
</thead> </table>
<tbody> </PlanGridRoot>
{days.map((date) => { </>
const dow = czechWeekday(date);
const dayNum = date.slice(8, 10);
const isToday = date === today;
const trCls = [
isWeekend(date) ? "plan-grid-weekend" : "",
isToday ? "is-today" : "",
]
.filter(Boolean)
.join(" ");
return (
<tr key={date} className={trCls}>
<td className="plan-grid-date-col">
<span className="plan-date-stamp">
<span className="plan-date-daynum">{dayNum}</span>
<span className="plan-date-dow">{dow}</span>
</span>
</td>
{users.map((u) => {
const cellArr = data.cells[u.id]?.[date] ?? [];
const cell = cellArr[0] ?? null;
const past = isPastDate(date, today);
// Past-day cells are read-only in the UI: an empty past
// cell is non-interactive (no create modal, no "+" hint),
// a past cell with data opens in view mode only. The
// server still enforces the past-date rule with a 403
// (defense in depth), but the click path here never
// reaches a create/edit submission for a past date.
const isLocked = !canEdit || past;
// `isPulsing` is true for the single (user, date) cell
// that the most recent successful mutation touched.
// CSS restarts the keyframe animation whenever the
// `nonce` changes (we embed it in data-pulse on the
// wrapper, see above), so back-to-back mutations on the
// same cell re-trigger the pulse.
const isPulsing =
!!pulseKey &&
pulseKey.userId === u.id &&
pulseKey.date === date;
let cls: string;
if (cell) {
cls = isLocked
? `plan-cell plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell";
} else {
cls = isLocked
? `plan-cell plan-cell--empty plan-cell--readonly${past ? " plan-cell--past" : ""}`
: "plan-cell plan-cell--empty";
}
if (isPulsing) cls += " plan-cell--pulse";
return (
<td key={u.id}>
<button
type="button"
className={cls}
style={
cell
? ({
"--cat-color": catMap[cell.category]?.color,
} as CSSProperties)
: undefined
}
onClick={() => onCellClick(u.id, date, cellArr)}
aria-label={
cell
? cellArr.length === 1
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
: isLocked
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
: `${u.full_name}, ${date}, prázdné — přidat záznam`
}
>
{cellArr.map((c, i) => (
<Box
key={
c.entryId != null
? c.entryId
: c.overrideId != null
? `o${c.overrideId}`
: i
}
className="plan-cell-record"
style={
{
"--cat-color": catMap[c.category]?.color,
} as CSSProperties
}
>
<PlanRangeChips
cell={c}
project={
c.project_id
? (projectMap.get(c.project_id) ?? null)
: null
}
categoryLabel={planCategoryLabel(
c.category,
catMap,
)}
/>
</Box>
))}
</button>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</PlanGridRoot>
); );
} }

View File

@@ -19,46 +19,22 @@ import {
} from "../utils/attendanceHelpers"; } from "../utils/attendanceHelpers";
// ---------- Shared types ---------- // ---------- Shared types ----------
// Definitions live in the dependency-free ./shiftFormTypes module; re-exported
export interface ShiftFormData { // here so existing importers of ShiftFormModal keep working.
user_id: string; export type {
shift_date: string; ShiftFormData,
leave_type: string; ProjectLog,
leave_hours: number; Project,
arrival_date: string; User,
arrival_time: string; EditingRecord,
break_start_date: string; } from "./shiftFormTypes";
break_start_time: string; import type {
break_end_date: string; ShiftFormData,
break_end_time: string; ProjectLog,
departure_date: string; Project,
departure_time: string; User,
notes: string; EditingRecord,
} } from "./shiftFormTypes";
export interface ProjectLog {
_key?: string;
id?: number;
project_id: string | number;
hours: string | number;
minutes: string | number;
}
export interface Project {
id: number | string;
project_number: string;
name: string;
}
export interface User {
id: number | string;
name: string;
}
export interface EditingRecord {
user_name: string;
shift_date: string;
}
// ---------- Sub-component props ---------- // ---------- Sub-component props ----------
@@ -157,7 +133,7 @@ function ProjectLogRow({
value={String(log.project_id)} value={String(log.project_id)}
onChange={(val) => onUpdate(index, "project_id", val)} onChange={(val) => onUpdate(index, "project_id", val)}
options={[ options={[
{ value: "", label: "— Projekt" }, { value: "", label: "Vyberte projekt" },
...projectList.map((p) => ({ ...projectList.map((p) => ({
value: String(p.id), value: String(p.id),
label: `${p.project_number} ${p.name}`, label: `${p.project_number} ${p.name}`,

View File

@@ -179,10 +179,12 @@ export default function DashQuickActions({
}); });
const result: ApiResult<unknown> = await response.json(); const result: ApiResult<unknown> = await response.json();
if (result.success) { if (result.success) {
// A new trip changes the trip list and the dashboard vehicle widgets // A new trip changes the trip list, the dashboard vehicle widgets AND
// invalidate the broad domains (prefix-matching covers sub-queries). // the vehicles domain (actual_km moves) — invalidate all three broad
// domains (prefix-matching covers sub-queries).
queryClient.invalidateQueries({ queryKey: ["trips"] }); queryClient.invalidateQueries({ queryKey: ["trips"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] }); queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
setShowTripModal(false); setShowTripModal(false);
alert.success(result.message ?? "Jízda uložena"); alert.success(result.message ?? "Jízda uložena");
} else { } else {

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
@@ -109,6 +110,17 @@ export default function OdinChat() {
}); });
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
// Mobile is immersive (no AppShell header on /odin) — this is the only way
// back. A deep link straight to /odin has no in-app history → go home.
const navigate = useNavigate();
const goBack = () => {
if (((window.history.state as { idx?: number } | null)?.idx ?? 0) > 0) {
navigate(-1);
} else {
navigate("/");
}
};
// No auto-select: like claude.ai, we land on a fresh "new chat" (activeId // 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 // 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 // row in the DB is created lazily on the first message (see submit), so
@@ -122,6 +134,7 @@ export default function OdinChat() {
messagesData.messages.map((m) => ({ messagesData.messages.map((m) => ({
role: m.role as "user" | "assistant", role: m.role as "user" | "assistant",
content: m.content, content: m.content,
tools: m.meta?.tools,
})), })),
); );
setReview([]); setReview([]);
@@ -148,6 +161,9 @@ export default function OdinChat() {
messages: msgs.map((m) => ({ messages: msgs.map((m) => ({
role: m.role, role: m.role,
content: m.content.slice(0, MAX_STORE), content: m.content.slice(0, MAX_STORE),
...(m.tools && m.tools.length > 0
? { meta: { tools: m.tools } }
: {}),
})), })),
}), }),
}) })
@@ -208,26 +224,12 @@ export default function OdinChat() {
const files = attachments.map((a) => a.file); const files = attachments.map((a) => a.file);
if (!text && files.length === 0) return; if (!text && files.length === 0) return;
// Guard: Odin is scoped to invoice processing only (for now). A text-only // Phase 2a: text messages go to the agentic /chat endpoint (read-only
// message makes NO AI call — it gets a canned reply locally, so open-ended // tools over system data); attachments keep the invoice-extract path.
// 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); setBusy(true);
const prevTurns = turns; const prevTurns = turns;
const prevInput = input;
const prevAttachments = attachments;
const userContent = [ const userContent = [
text, text,
@@ -240,6 +242,10 @@ export default function OdinChat() {
{ role: "user", content: userContent }, { role: "user", content: userContent },
]; ];
setTurns(optimistic); setTurns(optimistic);
// Clear the composer immediately — the message now lives in the thread
// as the optimistic bubble; a failure restores both below.
setInput("");
setAttachments([]);
try { try {
if (files.length > 0) { if (files.length > 0) {
@@ -280,8 +286,6 @@ export default function OdinChat() {
setReview((prev) => [...prev, ...reviews]); setReview((prev) => [...prev, ...reviews]);
const note = summaryNote(reviews); const note = summaryNote(reviews);
setTurns((t) => [...t, { role: "assistant", content: note }]); setTurns((t) => [...t, { role: "assistant", content: note }]);
setInput("");
setAttachments([]);
// Create the conversation now (first successful message) and persist. // Create the conversation now (first successful message) and persist.
const convId = await ensureActive(); const convId = await ensureActive();
if (convId != null) if (convId != null)
@@ -301,19 +305,24 @@ export default function OdinChat() {
const body = await res.json(); const body = await res.json();
if (!res.ok) throw new Error(body?.error || "Chyba AI"); if (!res.ok) throw new Error(body?.error || "Chyba AI");
const reply: string = body.data.reply; const reply: string = body.data.reply;
setTurns((t) => [...t, { role: "assistant", content: reply }]); const tools: ChatTurn["tools"] = Array.isArray(body.data.tool_trace)
setInput(""); ? body.data.tool_trace
: undefined;
setTurns((t) => [...t, { role: "assistant", content: reply, tools }]);
// Create the conversation now (first successful message) and persist. // Create the conversation now (first successful message) and persist.
const convId = await ensureActive(); const convId = await ensureActive();
if (convId != null) if (convId != null)
persist(convId, [ persist(convId, [
{ role: "user", content: userContent }, { role: "user", content: userContent },
{ role: "assistant", content: reply }, { role: "assistant", content: reply, tools },
]); ]);
qc.invalidateQueries({ queryKey: ["ai", "usage"] }); qc.invalidateQueries({ queryKey: ["ai", "usage"] });
} }
} catch (e) { } catch (e) {
setTurns(prevTurns); setTurns(prevTurns);
// Give the user their message back to retry/edit.
setInput(prevInput);
setAttachments(prevAttachments);
const fallback = files.length > 0 ? "Chyba čtení faktur" : "Chyba AI"; const fallback = files.length > 0 ? "Chyba čtení faktur" : "Chyba AI";
alert.error(e instanceof Error ? e.message : fallback); alert.error(e instanceof Error ? e.message : fallback);
} finally { } finally {
@@ -422,11 +431,26 @@ export default function OdinChat() {
return ( return (
<Box <Box
sx={{ sx={{
height: "calc(100dvh - 100px)", // svh (NOT dvh): dvh grows live as the mobile URL bar collapses, so a
// dvh-sized full-height layout always lets the page scroll by the
// browser-chrome height. svh sizes for the bar-visible viewport — the
// page never scrolls and the chat's message list scrolls internally.
// Desktop: svh === vh.
// Mobile is immersive (AppShell hides its header and main padding on
// /odin): the chat owns the whole viewport, full-bleed. --app-height
// is AppShell's live window.innerHeight measurement — standalone-PWA
// viewports misreport svh/dvh (MIUI), only the measured value fits.
height: {
xs: "var(--app-height, 100svh)",
md: "calc(100svh - 100px)",
},
display: "flex", display: "flex",
border: 1, // Longhand on purpose: a responsive `border` shorthand lands in a
// media query AFTER borderColor and resets the color to black.
borderStyle: "solid",
borderWidth: { xs: 0, md: 1 },
borderColor: "divider", borderColor: "divider",
borderRadius: 3, borderRadius: { xs: 0, md: 3 },
bgcolor: "background.paper", bgcolor: "background.paper",
overflow: "hidden", overflow: "hidden",
}} }}
@@ -452,15 +476,41 @@ export default function OdinChat() {
flexDirection: "column", flexDirection: "column",
gap: 1.5, gap: 1.5,
p: 2, p: 2,
// Immersive mobile: respect the notch / home-indicator insets
// (env() is 0 outside standalone mode, so max() keeps the 16px).
pt: { xs: "max(16px, env(safe-area-inset-top))", md: 2 },
pb: { xs: "max(16px, env(safe-area-inset-bottom))", md: 2 },
}} }}
> >
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{isMobile && (
<IconButton
onClick={goBack}
aria-label="Zpět"
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"
strokeLinejoin="round"
>
<line x1="19" y1="12" x2="5" y2="12" />
<polyline points="12 19 5 12 12 5" />
</Box>
</IconButton>
)}
{isMobile && ( {isMobile && (
<IconButton <IconButton
onClick={() => setSidebarOpen(true)} onClick={() => setSidebarOpen(true)}
aria-label="Konverzace" aria-label="Konverzace"
size="small" size="small"
sx={{ ml: -0.5, flexShrink: 0 }} sx={{ flexShrink: 0 }}
> >
<Box <Box
component="svg" component="svg"
@@ -490,8 +540,7 @@ export default function OdinChat() {
color="text.secondary" color="text.secondary"
sx={{ flexShrink: 0, display: { xs: "none", sm: "block" } }} sx={{ flexShrink: 0, display: { xs: "none", sm: "block" } }}
> >
Utraceno: ${usage.month_spend_usd.toFixed(2)} / $ Utraceno: ${usage.month_spend_usd.toFixed(2)}
{usage.budget_usd.toFixed(2)}
</Typography> </Typography>
)} )}
</Box> </Box>

View File

@@ -1,6 +1,12 @@
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import { motion, useReducedMotion, type Variants } from "framer-motion"; import Chip from "@mui/material/Chip";
import {
motion,
AnimatePresence,
useReducedMotion,
type Variants,
} from "framer-motion";
import type { ChatTurn } from "./types"; import type { ChatTurn } from "./types";
import OdinMark from "./OdinMark"; import OdinMark from "./OdinMark";
@@ -29,29 +35,6 @@ interface OdinThreadProps {
threadRef: React.RefObject<HTMLDivElement | null>; 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({ export default function OdinThread({
turns, turns,
busy, busy,
@@ -172,12 +155,17 @@ export default function OdinThread({
return ( return (
<MotionBox <MotionBox
key={i} key={i}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }} initial={
animate={{ opacity: 1, y: 0 }} reduce
transition={{ ? { opacity: 0 }
duration: reduce ? 0.3 : 0.34, : { opacity: 0, y: 14, scale: 0.9, x: isUser ? 12 : -12 }
ease: [0.16, 1, 0.3, 1], }
}} animate={{ opacity: 1, y: 0, scale: 1, x: 0 }}
transition={
reduce
? { duration: 0.3 }
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
}
sx={{ sx={{
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
@@ -185,9 +173,12 @@ export default function OdinThread({
gap: 1, gap: 1,
alignSelf: isUser ? "flex-end" : "flex-start", alignSelf: isUser ? "flex-end" : "flex-start",
maxWidth: "80%", maxWidth: "80%",
// The pop grows out of the corner where the bubble is anchored
// (next to the avatar / the composer side), not from its centre.
transformOrigin: isUser ? "bottom right" : "bottom left",
}} }}
> >
{!isUser && <OdinAvatar size={28} />} {!isUser && <OdinMark size={28} />}
<Box <Box
sx={{ sx={{
px: 1.5, px: 1.5,
@@ -195,8 +186,34 @@ export default function OdinThread({
borderRadius: 2, borderRadius: 2,
boxShadow: 1, boxShadow: 1,
bgcolor: isUser ? "primary.main" : "background.paper", bgcolor: isUser ? "primary.main" : "background.paper",
// The global ::selection is primary-on-white — invisible on
// this primary-filled bubble; invert it so selected/copied
// text stays readable.
...(isUser && {
"& ::selection": {
backgroundColor: "common.white",
color: "primary.main",
},
}),
}} }}
> >
{/* Tools the assistant consulted for this turn (Phase 2a). */}
{!isUser && t.tools && t.tools.length > 0 && (
<Box
sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mb: 0.75 }}
>
{t.tools.map((tool, j) => (
<Chip
key={`${tool.name}-${j}`}
size="small"
label={`🔍 ${tool.label}`}
color={tool.ok ? "default" : "warning"}
variant="outlined"
sx={{ fontSize: "0.7rem", height: 22 }}
/>
))}
</Box>
)}
{/* Color MUST sit on the Typography: GlobalStyles pins `p` to {/* Color MUST sit on the Typography: GlobalStyles pins `p` to
text.secondary, which beats a color merely inherited from the text.secondary, which beats a color merely inherited from the
Box. An sx class on the element wins over that element rule. */} Box. An sx class on the element wins over that element rule. */}
@@ -214,25 +231,79 @@ export default function OdinThread({
); );
})} })}
{/* Busy indicator */} {/* Busy indicator — a typing bubble (three bouncing dots) anchored in
{busy && ( the avatar column, springing in/out where the reply will land. */}
<Box <AnimatePresence>
sx={{ {busy && (
alignSelf: "flex-start", <MotionBox
display: "flex", key="busy"
alignItems: "center", initial={
gap: 1, reduce ? { opacity: 0 } : { opacity: 0, y: 10, scale: 0.85 }
px: 1.5, }
py: 1, animate={{ opacity: 1, y: 0, scale: 1 }}
color: "text.secondary", exit={
}} reduce
> ? { opacity: 0, transition: { duration: 0.15 } }
<OdinMark size={22} state="thinking" /> : {
<Typography variant="caption" sx={{ color: "inherit" }}> opacity: 0,
Pracuji scale: 0.85,
</Typography> transition: { duration: 0.16, ease: "easeIn" },
</Box> }
)} }
transition={
reduce
? { duration: 0.3 }
: { type: "spring", stiffness: 460, damping: 32, mass: 0.7 }
}
sx={{
alignSelf: "flex-start",
display: "flex",
alignItems: "flex-end",
gap: 1,
transformOrigin: "bottom left",
}}
>
<OdinMark size={28} state="thinking" />
<Box
role="status"
aria-label="Odin pracuje"
sx={{
px: 1.5,
py: 1.5,
borderRadius: 2,
boxShadow: 1,
bgcolor: "background.paper",
display: "flex",
alignItems: "center",
gap: 0.6,
}}
>
{[0, 1, 2].map((i) => (
<MotionBox
key={i}
animate={
reduce
? { opacity: [0.35, 1, 0.35] }
: { y: [0, -4, 0], opacity: [0.35, 1, 0.35] }
}
transition={{
duration: 0.9,
repeat: Infinity,
delay: i * 0.15,
ease: "easeInOut",
}}
sx={{
width: 6,
height: 6,
borderRadius: "50%",
bgcolor: "text.secondary",
}}
/>
))}
</Box>
</MotionBox>
)}
</AnimatePresence>
</Box> </Box>
); );
} }

View File

@@ -1,6 +1,14 @@
export interface ToolTraceEntry {
name: string;
label: string;
ok: boolean;
}
export interface ChatTurn { export interface ChatTurn {
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
/** Read-only tools the assistant called while producing this turn. */
tools?: ToolTraceEntry[];
} }
export interface ReviewInvoice { export interface ReviewInvoice {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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