Compare commits

..

57 Commits

Author SHA1 Message Date
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
164 changed files with 25004 additions and 5249 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.

View File

@@ -204,8 +204,11 @@ descriptions for unnumbered drafts. Audit failures are non-fatal.
a deterministic path and **overwrite in place** — never uniquePath `_N` a deterministic path and **overwrite in place** — never uniquePath `_N`
suffixes. The issued-order PDF gets language from the document's `language` suffixes. The issued-order PDF gets language from the document's `language`
column and carries a Puppeteer `footerTemplate` footer on every page column and carries a Puppeteer `footerTemplate` footer on every page
("Vystavil" left, "Strana X z Y"/"Page X of Y" centered) — Chromium has no ("Vystavil" left, "Strana X z Y"/"Page X of Y" centered). Two repeating-
CSS margin-box footers; use `htmlToPdf(html, { footerTemplate })`. 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:** - **Shared modules — extend, never fork local copies:**
`src/admin/components/document/` (DocumentItemsEditor, SectionsEditor, `src/admin/components/document/` (DocumentItemsEditor, SectionsEditor,
LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` / LockBanner), hooks `useDocumentLock` / `useUnsavedChangesGuard` /
@@ -291,15 +294,28 @@ CSP blocks it and it would leak the secret).
## AI Assistant — "Odin" ## AI Assistant — "Odin"
Admin-only (`ai.use` permission) Claude assistant at `/odin`. **Phase-1 scope `ai.use`-gated Claude assistant at `/odin` (granted to admin). **Phase 2a:
is invoice import ONLY** — general chat is guarded off in `OdinChat.submit` read-only agentic assistant** — chat turns run an agentic loop
(text-only messages get a canned reply, no API call). Backend (`agenticChat` in `src/services/ai.service.ts`, max 6 round-trips, budget
`src/services/ai.service.ts` + `src/routes/admin/ai.ts`; per-call cost ledger re-checked between iterations) over the read-only tools in
in `ai_usage` with a monthly budget cap (402 when exceeded). Invoice flow: `src/services/ai-tools.ts` (invoices, offers, orders, projects, customers,
PDF → `extract-invoices` (structured output) → review cards → existing warehouse, attendance).
`POST /received-invoices`. **`received_invoices.amount` is GROSS
(VAT-inclusive)** — VAT is back-calculated (`vatFromGross`). Phase-2 design **Security model (do not weaken):** Odin is the **user's delegate** — the
notes live in `docs/superpowers/specs/`. 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).
Per-call cost ledger in `ai_usage` with a monthly budget cap (402 when
exceeded). Invoice flow unchanged: PDF → `extract-invoices` (structured
output) → review cards → existing `POST /received-invoices`.
**`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is
back-calculated (`vatFromGross`). Phase-2 design notes live in
`docs/superpowers/specs/`.
--- ---

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) |

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).

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "app-ts", "name": "app-ts",
"version": "2.4.5", "version": "2.4.35",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "app-ts", "name": "app-ts",
"version": "2.4.5", "version": "2.4.35",
"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.5", "version": "2.4.35",
"description": "", "description": "",
"main": "dist/server.js", "main": "dist/server.js",
"scripts": { "scripts": {

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

@@ -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)
@@ -456,6 +459,7 @@ 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")
@@ -804,18 +808,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[]

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

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

@@ -450,9 +450,20 @@ describe("GET /api/admin/issued-orders/suppliers", () => {
const row = body.data.find((s: { id: number }) => s.id === active.id); const row = body.data.find((s: { id: number }) => s.id === active.id);
expect(row.name).toBe("io_test_Aktivní dodavatel"); expect(row.name).toBe("io_test_Aktivní dodavatel");
expect(row.ico).toBe("12345678"); expect(row.ico).toBe("12345678");
// Lightweight lookup shape — all picker fields present. // Lightweight lookup shape — all picker fields present (structured
// address since the 2026-06 supplier customers-model split; the
// email/phone columns were dropped in favour of custom_fields).
expect(Object.keys(row).sort()).toEqual( expect(Object.keys(row).sort()).toEqual(
["address", "dic", "email", "ico", "id", "name", "phone"].sort(), [
"city",
"country",
"dic",
"ico",
"id",
"name",
"postal_code",
"street",
].sort(),
); );
}); });
@@ -528,6 +539,40 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat"); expect(res.json().error).toBe("Objednávku v tomto stavu nelze upravovat");
}); });
it("persists item edits sent WITH a sent→confirmed transition (the H3 flush contract)", async () => {
// The IssuedOrderDetail transition flushes unsaved edits by sending the
// full payload + status in ONE PUT while the order is still editable
// (sent). The server must apply the items AND the transition together.
const order = await mkIssued({});
await updateIssuedOrder(order.id, { status: "sent" });
const res = await app!.inject({
method: "PUT",
url: `/api/admin/issued-orders/${order.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
payload: {
status: "confirmed",
items: [
{
description: "Flushnutá položka",
quantity: 2,
unit_price: 50,
position: 0,
},
],
},
});
expect(res.statusCode).toBe(200);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
include: { issued_order_items: true },
});
expect(row!.status).toBe("confirmed");
expect(row!.issued_order_items.map((i) => i.description)).toContain(
"Flushnutá položka",
);
});
it("a status-only transition payload keeps working (confirmed -> completed)", async () => { it("a status-only transition payload keeps working (confirmed -> completed)", async () => {
const order = await mkIssued({}); const order = await mkIssued({});
await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "sent" });
@@ -656,12 +701,15 @@ describe("renderIssuedOrderHtml", () => {
const issuer = { name: "Jan Novák" }; const issuer = { name: "Jan Novák" };
// sklad_suppliers shape: single Text address blob + ico/dic columns. // sklad_suppliers shape: structured address + ico/dic columns.
const supplier = { const supplier = {
name: "Dodavatel s.r.o.", name: "Dodavatel s.r.o.",
ico: "12345678", ico: "12345678",
dic: "CZ12345678", dic: "CZ12345678",
address: "Průmyslová 5\n190 00 Praha", street: "Průmyslová 5",
city: "Praha",
postal_code: "190 00",
country: "Česká republika",
}; };
it("renders the PO number, items, and both party names (PO direction)", () => { it("renders the PO number, items, and both party names (PO direction)", () => {
@@ -685,7 +733,7 @@ describe("renderIssuedOrderHtml", () => {
expect(html).toContain("Odběratel"); expect(html).toContain("Odběratel");
}); });
it("renders the supplier address (split on newlines) plus IČO and DIČ", () => { it("renders the structured supplier address plus IČO and DIČ", () => {
const html = renderIssuedOrderHtml( const html = renderIssuedOrderHtml(
order, order,
items, items,
@@ -694,25 +742,26 @@ describe("renderIssuedOrderHtml", () => {
"cs", "cs",
issuer, issuer,
); );
// The single Text address blob becomes one address line per newline. // street / "PSČ Město" / country — one address line each.
expect(html).toContain('<div class="address-line">Průmyslová 5</div>'); expect(html).toContain('<div class="address-line">Průmyslová 5</div>');
expect(html).toContain('<div class="address-line">190 00 Praha</div>'); expect(html).toContain('<div class="address-line">190 00 Praha</div>');
expect(html).toContain('<div class="address-line">Česká republika</div>');
expect(html).toContain("IČ: 12345678"); expect(html).toContain("IČ: 12345678");
expect(html).toContain("DIČ: CZ12345678"); expect(html).toContain("DIČ: CZ12345678");
}); });
it("renders a no-newline address as a single line and skips missing IČO/DIČ", () => { it("skips empty address parts and missing IČO/DIČ", () => {
const html = renderIssuedOrderHtml( const html = renderIssuedOrderHtml(
order, order,
items, items,
{ name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" }, { name: "Jednořádkový", street: "Ulice 1", city: "Praha" },
null, null,
"cs", "cs",
issuer, issuer,
); );
expect(html).toContain( expect(html).toContain('<div class="address-line">Ulice 1</div>');
'<div class="address-line">Ulice 1, 100 00 Praha</div>', // City without PSČ renders alone, no stray separator.
); expect(html).toContain('<div class="address-line">Praha</div>');
expect(html).not.toContain("IČ: "); expect(html).not.toContain("IČ: ");
expect(html).not.toContain("DIČ: "); expect(html).not.toContain("DIČ: ");
}); });

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

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

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

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

@@ -85,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",
}); });
@@ -97,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");
} }
}); });
@@ -240,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", () => {

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

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

@@ -214,7 +214,8 @@ function SortableItemRow({
placeholder="Volitelný" placeholder="Volitelný"
InputProps={{ readOnly }} InputProps={{ readOnly }}
multiline multiline
rows={2} minRows={2}
maxRows={16}
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }} slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
sx={{ "& textarea": { resize: "vertical" } }} sx={{ "& textarea": { resize: "vertical" } }}
/> />
@@ -352,7 +353,8 @@ function SortableItemRow({
placeholder="Podrobný popis (volitelný)" placeholder="Podrobný popis (volitelný)"
InputProps={{ readOnly }} InputProps={{ readOnly }}
multiline multiline
rows={2} minRows={2}
maxRows={16}
slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }} slotProps={{ htmlInput: { maxLength: itemDescriptionMaxLength } }}
sx={{ sx={{
"& textarea": { "& textarea": {

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,
@@ -1334,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

@@ -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 */
} }

View File

@@ -23,6 +23,10 @@ export interface StoredChatMessage {
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
created_at: string; created_at: string;
/** Parsed content_json — the assistant turn's tool trace, when present. */
meta?: {
tools?: { name: string; label: string; ok: boolean }[];
} | null;
} }
export const aiUsageOptions = () => export const aiUsageOptions = () =>

View File

@@ -25,9 +25,10 @@ export interface Supplier {
name: string; name: string;
ico: string | null; ico: string | null;
dic: string | null; dic: string | null;
address: string | null; street: string | null;
email: string | null; city: string | null;
phone: string | null; postal_code: string | null;
country: string | null;
} }
export interface IssuedOrderItem { export interface IssuedOrderItem {

View File

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

View File

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

View File

@@ -5,8 +5,10 @@ import apiFetch from "../../utils/api";
export interface ProjectNote { export interface ProjectNote {
id: number; id: number;
content: string; content: string;
user_id: number | null;
user_name: string; user_name: string;
created_at: string; created_at: string;
edited_at: string | null;
} }
export interface ProjectData { export interface ProjectData {

View File

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

View File

@@ -835,7 +835,7 @@ export default function Attendance() {
onChange={(val) => handleSwitchProject(val || null)} onChange={(val) => handleSwitchProject(val || null)}
disabled={switchingProject} disabled={switchingProject}
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.project_number} ${p.name}`, label: `${p.project_number} ${p.name}`,

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,7 @@ import {
formatCurrency, formatCurrency,
formatDate, formatDate,
numberOr, numberOr,
restoreQuillBlankLines,
todayLocalStr, todayLocalStr,
} from "../utils/formatters"; } from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers"; import { normalizeDateStr } from "../utils/attendanceHelpers";
@@ -317,7 +318,8 @@ function SortableInvoiceRow({
onChange={(e) => onUpdate(index, "item_description", e.target.value)} onChange={(e) => onUpdate(index, "item_description", e.target.value)}
placeholder="Volitelný" placeholder="Volitelný"
multiline multiline
rows={2} minRows={2}
maxRows={16}
sx={{ "& textarea": { resize: "vertical" } }} sx={{ "& textarea": { resize: "vertical" } }}
/> />
<Box <Box
@@ -418,7 +420,8 @@ function SortableInvoiceRow({
} }
placeholder="Podrobný popis (volitelný)" placeholder="Podrobný popis (volitelný)"
multiline multiline
rows={2} minRows={2}
maxRows={16}
sx={{ sx={{
"& textarea": { "& textarea": {
fontSize: "0.8rem", fontSize: "0.8rem",
@@ -618,7 +621,10 @@ export default function InvoiceDetail() {
const customers = customersQuery.data ?? []; const customers = customersQuery.data ?? [];
const bankAccountsQuery = useQuery(bankAccountsOptions()); const bankAccountsQuery = useQuery(bankAccountsOptions());
const bankAccounts = bankAccountsQuery.data ?? []; const bankAccounts = useMemo(
() => bankAccountsQuery.data ?? [],
[bankAccountsQuery.data],
);
const invoiceQuery = useQuery(invoiceDetailOptions(id)); const invoiceQuery = useQuery(invoiceDetailOptions(id));
const invoice = invoiceQuery.data ?? null; const invoice = invoiceQuery.data ?? null;
@@ -653,8 +659,9 @@ export default function InvoiceDetail() {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
useEffect(() => { useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => { return () => {
blobTimeoutsRef.current.forEach(clearTimeout); blobTimeouts.forEach(clearTimeout);
}; };
}, []); }, []);
@@ -1163,7 +1170,16 @@ export default function InvoiceDetail() {
mb: 3, mb: 3,
}} }}
> >
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> {/* flexWrap: long document titles must drop below the Zpět button
on phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button <Button
component={RouterLink} component={RouterLink}
to="/invoices?tab=issued" to="/invoices?tab=issued"
@@ -1659,7 +1675,9 @@ export default function InvoiceDetail() {
{(s.content || "").replace(/<[^>]*>/g, "").trim() && ( {(s.content || "").replace(/<[^>]*>/g, "").trim() && (
<RichTextView <RichTextView
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(s.content || ""), __html: DOMPurify.sanitize(
restoreQuillBlankLines(s.content),
),
}} }}
/> />
)} )}
@@ -1679,7 +1697,9 @@ export default function InvoiceDetail() {
invoice.internal_notes !== "<p><br></p>" ? ( invoice.internal_notes !== "<p><br></p>" ? (
<RichTextView <RichTextView
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(invoice.internal_notes), __html: DOMPurify.sanitize(
restoreQuillBlankLines(invoice.internal_notes),
),
}} }}
/> />
) : ( ) : (
@@ -1724,7 +1744,16 @@ export default function InvoiceDetail() {
mb: 3, mb: 3,
}} }}
> >
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> {/* flexWrap: long document titles must drop below the Zpět button
on phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button <Button
component={RouterLink} component={RouterLink}
to="/invoices?tab=issued" to="/invoices?tab=issued"
@@ -1777,23 +1806,28 @@ export default function InvoiceDetail() {
</Box> </Box>
</Box> </Box>
<Box sx={headerActionsSx}> <Box sx={headerActionsSx}>
{isEdit && invoice && hasPermission("invoices.view") && ( {/* Drafts have no number → /file 404s; hide the button like
<Button OfferDetail/IssuedOrderDetail do. */}
onClick={() => handleViewPdf(invoice.language || "cs")} {isEdit &&
variant="outlined" invoice &&
color="inherit" invoice.invoice_number &&
disabled={pdfLoading} hasPermission("invoices.view") && (
startIcon={ <Button
pdfLoading ? ( onClick={() => handleViewPdf(invoice.language || "cs")}
<CircularProgress size={16} color="inherit" /> variant="outlined"
) : ( color="inherit"
FileIcon disabled={pdfLoading}
) startIcon={
} pdfLoading ? (
> <CircularProgress size={16} color="inherit" />
Zobrazit fakturu ) : (
</Button> FileIcon
)} )
}
>
Zobrazit fakturu
</Button>
)}
{/* ── Create mode: two-button save ── */} {/* ── Create mode: two-button save ── */}
{!isEdit && ( {!isEdit && (
<> <>

View File

@@ -40,6 +40,7 @@ import {
LoadingState, LoadingState,
FilterBar, FilterBar,
Tabs, Tabs,
TabPanel,
PageHeader, PageHeader,
PageEnter, PageEnter,
type DataColumn, type DataColumn,
@@ -247,6 +248,9 @@ export default function Invoices() {
} else { } else {
setStatsMonth((m) => m - 1); setStatsMonth((m) => m - 1);
} }
// Back to page 1 — the server never clamps, so keeping e.g. page 3 in a
// sparser month shows an empty table (same reset as the filter handlers).
setPage(1);
}; };
const nextMonth = () => { const nextMonth = () => {
@@ -257,6 +261,7 @@ export default function Invoices() {
} else { } else {
setStatsMonth((m) => m + 1); setStatsMonth((m) => m + 1);
} }
setPage(1);
}; };
const [deleteConfirm, setDeleteConfirm] = useState<{ const [deleteConfirm, setDeleteConfirm] = useState<{
@@ -706,7 +711,7 @@ export default function Invoices() {
</Box> </Box>
</Box> </Box>
{activeTab === "received" ? ( <TabPanel value="received" current={activeTab} sx={{ pt: 0 }}>
<Suspense fallback={<LoadingState />}> <Suspense fallback={<LoadingState />}>
<ReceivedInvoices <ReceivedInvoices
statsMonth={statsMonth} statsMonth={statsMonth}
@@ -715,7 +720,8 @@ export default function Invoices() {
setUploadOpen={setReceivedUploadOpen} setUploadOpen={setReceivedUploadOpen}
/> />
</Suspense> </Suspense>
) : ( </TabPanel>
<TabPanel value="issued" current={activeTab} sx={{ pt: 0 }}>
<> <>
{renderKpi()} {renderKpi()}
@@ -808,7 +814,7 @@ export default function Invoices() {
loading={deleting} loading={deleting}
/> />
</> </>
)} </TabPanel>
</PageEnter> </PageEnter>
); );
} }

View File

@@ -207,9 +207,10 @@ export default function IssuedOrderDetail() {
name: form.supplier_name || `Dodavatel #${form.supplier_id}`, name: form.supplier_name || `Dodavatel #${form.supplier_id}`,
ico: null, ico: null,
dic: null, dic: null,
address: null, street: null,
email: null, city: null,
phone: null, postal_code: null,
country: null,
}, },
]; ];
} }
@@ -253,7 +254,7 @@ export default function IssuedOrderDetail() {
const editable = !readOnly; const editable = !readOnly;
const canExport = hasPermission("orders.view"); const canExport = hasPermission("orders.view");
const { markClean } = useUnsavedChangesGuard( const { isDirty, markClean } = useUnsavedChangesGuard(
{ form, items, sections }, { form, items, sections },
!isEdit || dataReady, !isEdit || dataReady,
); );
@@ -505,11 +506,23 @@ export default function IssuedOrderDetail() {
else void handleSubmit(); else void handleSubmit();
}; };
// ─── Status transition (status-ONLY payload — a full-form resubmit on a // ─── Status transition. With unsaved edits on an editable (sent) order
// non-editable order now 400s server-side) ─── // the transition routes through handleSubmit so the edits are PERSISTED
// with the status — a status-only payload would silently drop them, and
// the markClean below would re-baseline the guard so even the beforeunload
// warning disappears (the order is then read-only: edits unrecoverable).
// Clean (or non-editable) orders keep the status-only payload — a
// full-form resubmit on a non-editable order 400s server-side. ───
const handleStatusChange = async () => { const handleStatusChange = async () => {
if (!statusConfirm.status || statusChanging) return; if (!statusConfirm.status || statusChanging) return;
const newStatus = statusConfirm.status; const newStatus = statusConfirm.status;
if (editable && isDirty) {
setStatusConfirm({ show: false, status: null });
// handleSubmit validates, sends the full payload + status, archives
// the PDF and re-baselines the guard with what was actually persisted.
await handleSubmit(newStatus);
return;
}
setStatusChanging(newStatus); setStatusChanging(newStatus);
try { try {
const result = await statusMutation.mutateAsync({ status: newStatus }); const result = await statusMutation.mutateAsync({ status: newStatus });
@@ -572,7 +585,16 @@ export default function IssuedOrderDetail() {
mb: 3, mb: 3,
}} }}
> >
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> {/* flexWrap: long document titles must drop below the Zpět button on
phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button <Button
component={RouterLink} component={RouterLink}
to="/orders?tab=vydane" to="/orders?tab=vydane"

View File

@@ -133,6 +133,15 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [supplierFilter, setSupplierFilter] = useState<number | "">(""); const [supplierFilter, setSupplierFilter] = useState<number | "">("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Reset to page 1 when the parent moves to another month — the kept page
// index would otherwise request e.g. page 2 of a sparser month and render
// an empty list. Adjusted during render (adjust-state-on-prop-change
// pattern); a remount via `key` would also clear search/filters.
const [prevMonthYear, setPrevMonthYear] = useState(`${month}-${year}`);
if (`${month}-${year}` !== prevMonthYear) {
setPrevMonthYear(`${month}-${year}`);
setPage(1);
}
// Track first successful load so later refetches (filter/status/page change) // Track first successful load so later refetches (filter/status/page change)
// keep the table visible instead of flashing the full-page skeleton. // keep the table visible instead of flashing the full-page skeleton.
const hasLoadedOnce = useRef(false); const hasLoadedOnce = useRef(false);

View File

@@ -620,7 +620,16 @@ export default function OfferDetail() {
mb: 3, mb: 3,
}} }}
> >
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> {/* flexWrap: long document titles must drop below the Zpět button
on phones instead of overflowing the viewport. */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button <Button
component={RouterLink} component={RouterLink}
to="/offers" to="/offers"

View File

@@ -12,6 +12,7 @@ import {
type CustomField, type CustomField,
} from "../lib/queries/offers"; } from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import AresAdornment, { type AresCompany } from "../components/AresLookup";
import { import {
Button, Button,
Card, Card,
@@ -267,6 +268,21 @@ export default function OffersCustomers() {
return key; return key;
}; };
// ARES prefill — overwrite the company fields with registry data (the
// button is an explicit user action, so overwriting is the expected UX).
const fillFromAres = (c: AresCompany) => {
setForm((prev) => ({
...prev,
name: c.name || prev.name,
street: c.street,
city: c.city,
postal_code: c.postal_code,
country: c.country,
company_id: c.ico,
vat_id: c.dic || "",
}));
};
const openCreateModal = () => { const openCreateModal = () => {
setEditingCustomer(null); setEditingCustomer(null);
setForm({ setForm({
@@ -535,6 +551,15 @@ export default function OffersCustomers() {
setForm((prev) => ({ ...prev, name: e.target.value })) setForm((prev) => ({ ...prev, name: e.target.value }))
} }
placeholder="Název firmy / jméno" placeholder="Název firmy / jméno"
InputProps={{
endAdornment: (
<AresAdornment
mode="name"
query={form.name}
onFill={fillFromAres}
/>
),
}}
/> />
</Field> </Field>
<Field label="Ulice"> <Field label="Ulice">
@@ -596,6 +621,15 @@ export default function OffersCustomers() {
company_id: e.target.value, company_id: e.target.value,
})) }))
} }
InputProps={{
endAdornment: (
<AresAdornment
mode="ico"
query={form.company_id}
onFill={fillFromAres}
/>
),
}}
/> />
</Field> </Field>
<Field label="DIČ"> <Field label="DIČ">

View File

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

View File

@@ -5,7 +5,14 @@ import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import { Button, Tabs, PageHeader, PageEnter, LoadingState } from "../ui"; import {
Button,
Tabs,
TabPanel,
PageHeader,
PageEnter,
LoadingState,
} from "../ui";
import OrdersReceived from "./ReceivedOrders"; import OrdersReceived from "./ReceivedOrders";
const IssuedOrders = lazy(() => import("./IssuedOrders")); const IssuedOrders = lazy(() => import("./IssuedOrders"));
@@ -170,18 +177,19 @@ export default function Orders() {
</Box> </Box>
</Box> </Box>
{activeTab === "vydane" ? ( <TabPanel value="vydane" current={activeTab} sx={{ pt: 0 }}>
<Suspense fallback={<LoadingState />}> <Suspense fallback={<LoadingState />}>
<IssuedOrders month={statsMonth} year={statsYear} /> <IssuedOrders month={statsMonth} year={statsYear} />
</Suspense> </Suspense>
) : ( </TabPanel>
<TabPanel value="prijate" current={activeTab} sx={{ pt: 0 }}>
<OrdersReceived <OrdersReceived
month={statsMonth} month={statsMonth}
year={statsYear} year={statsYear}
createOpen={createOpen} createOpen={createOpen}
setCreateOpen={setCreateOpen} setCreateOpen={setCreateOpen}
/> />
)} </TabPanel>
</PageEnter> </PageEnter>
); );
} }

View File

@@ -10,6 +10,10 @@ import {
getCell, getCell,
getCells, getCells,
type ModalMode as HookModalMode, type ModalMode as HookModalMode,
type PlanEntryBody,
type PlanEntryPatchBody,
type PlanOverrideBody,
type PlanOverridePatchBody,
} from "../hooks/usePlanWork"; } from "../hooks/usePlanWork";
import type { PlanCellModalMode } from "../components/PlanCellModal"; import type { PlanCellModalMode } from "../components/PlanCellModal";
import PlanGrid from "../components/PlanGrid"; import PlanGrid from "../components/PlanGrid";
@@ -182,7 +186,13 @@ export default function PlanWork() {
if (bulkForm.user_ids.length === 0) return; if (bulkForm.user_ids.length === 0) return;
setBulkSubmitting(true); setBulkSubmitting(true);
try { try {
const data: any = await bulkCreate.mutateAsync({ // Counts can arrive either at the envelope root or nested under .data.
type BulkSummary = {
created_days?: number;
skipped_days?: number;
users?: number;
};
const data = (await bulkCreate.mutateAsync({
user_ids: bulkForm.user_ids.map(Number), user_ids: bulkForm.user_ids.map(Number),
date_from: bulkForm.date_from, date_from: bulkForm.date_from,
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from, date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
@@ -190,12 +200,12 @@ export default function PlanWork() {
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null, project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
category: bulkForm.category, category: bulkForm.category,
note: bulkForm.note, note: bulkForm.note,
}); })) as BulkSummary & { data?: BulkSummary };
// apiCall returns the full envelope { success, data, message }; // apiCall returns the full envelope { success, data, message };
// the summary counts are under .data // the summary counts are under .data
const summary = data?.data ?? data; const summary: BulkSummary = data?.data ?? data;
const skipped = const skipped =
summary?.skipped_days > 0 (summary?.skipped_days ?? 0) > 0
? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).` ? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).`
: ""; : "";
alert.success( alert.success(
@@ -392,7 +402,7 @@ export default function PlanWork() {
// optimistic cache patch the hook already applied. // optimistic cache patch the hook already applied.
const saveEntry = useCallback( const saveEntry = useCallback(
async (body: any) => { async (body: PlanEntryBody) => {
try { try {
await createEntry.mutateAsync(body); await createEntry.mutateAsync(body);
// Pulse the first day of the new entry — for multi-day entries // Pulse the first day of the new entry — for multi-day entries
@@ -409,7 +419,7 @@ export default function PlanWork() {
); );
const updateEntryFn = useCallback( const updateEntryFn = useCallback(
async (id: number, body: any) => { async (id: number, body: PlanEntryPatchBody) => {
// Find the user for this entry so we know which cell to pulse / // Find the user for this entry so we know which cell to pulse /
// which cache to roll back. The hook's optimistic patch already // which cache to roll back. The hook's optimistic patch already
// discovered this; we read it from the current grid. // discovered this; we read it from the current grid.
@@ -473,7 +483,7 @@ export default function PlanWork() {
); );
const saveOverride = useCallback( const saveOverride = useCallback(
async (body: any) => { async (body: PlanOverrideBody) => {
try { try {
await createOverride.mutateAsync(body); await createOverride.mutateAsync(body);
firePulse(body.user_id, body.shift_date); firePulse(body.user_id, body.shift_date);
@@ -488,7 +498,7 @@ export default function PlanWork() {
); );
const updateOverrideFn = useCallback( const updateOverrideFn = useCallback(
async (id: number, body: any) => { async (id: number, body: PlanOverridePatchBody) => {
// Find the user/date for the pulse + rollback. // Find the user/date for the pulse + rollback.
let userId: number | null = null; let userId: number | null = null;
let date: string | null = null; let date: string | null = null;
@@ -667,19 +677,29 @@ export default function PlanWork() {
</Box> </Box>
)} )}
{/* Mobile: stacked full-width rows (same pattern as headerActionsSx on
the detail pages); sm+ keeps the single wrapping toolbar row. The
paired controls (Dnes/arrows, Týden/Měsíc) stay together as one
full-width row each. */}
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
flexWrap: "wrap", flexDirection: { xs: "column", sm: "row" },
alignItems: "center", flexWrap: { sm: "wrap" },
alignItems: { xs: "stretch", sm: "center" },
gap: 1.5, gap: 1.5,
mb: 2, mb: 2,
}} }}
> >
{/* Nav buttons grouped so they stay on one row when the toolbar {/* Nav buttons grouped so they stay on one row when the toolbar
wraps on mobile. */} wraps on mobile. */}
<Box sx={{ display: "inline-flex", gap: 1 }}> <Box sx={{ display: "flex", gap: 1 }}>
<Button variant="outlined" color="inherit" onClick={goToToday}> <Button
variant="outlined"
color="inherit"
onClick={goToToday}
sx={{ flex: { xs: 1, sm: "0 0 auto" } }}
>
Dnes Dnes
</Button> </Button>
<Button <Button
@@ -701,8 +721,8 @@ export default function PlanWork() {
</Box> </Box>
<Box <Box
sx={{ sx={{
flex: 1, flex: { sm: 1 },
minWidth: 220, minWidth: { sm: 220 },
display: "flex", display: "flex",
justifyContent: "center", justifyContent: "center",
}} }}
@@ -727,7 +747,11 @@ export default function PlanWork() {
<Box <Box
role="group" role="group"
aria-label="Měřítko zobrazení" aria-label="Měřítko zobrazení"
sx={{ display: "inline-flex", gap: 1 }} sx={{
display: "flex",
gap: 1,
"& > button": { flex: { xs: 1, sm: "0 0 auto" } },
}}
> >
<Button <Button
variant={view === "week" ? "contained" : "outlined"} variant={view === "week" ? "contained" : "outlined"}

View File

@@ -1,22 +1,22 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useApiMutation } from "../lib/queries/mutations"; import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom"; import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem"; import MenuItem from "@mui/material/MenuItem";
import { import {
projectDetailOptions, projectDetailOptions,
type ProjectData,
type ProjectNote, type ProjectNote,
} from "../lib/queries/projects"; } from "../lib/queries/projects";
import { userListOptions, type User as ApiUser } from "../lib/queries/users"; import { userListOptions, type User as ApiUser } from "../lib/queries/users";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import ProjectFileManager from "../components/ProjectFileManager"; import ProjectFileManager from "../components/ProjectFileManager";
import NoteCard from "../components/NoteCard";
import { import {
Button, Button,
Card, Card,
@@ -49,17 +49,6 @@ const STATUS_COLORS: Record<
zruseny: "default", zruseny: "default",
}; };
function formatNoteDate(dateStr: string) {
if (!dateStr) return "";
const d = new Date(dateStr);
const day = d.getDate();
const month = d.getMonth() + 1;
const year = d.getFullYear();
const hours = String(d.getHours()).padStart(2, "0");
const mins = String(d.getMinutes()).padStart(2, "0");
return `${day}. ${month}. ${year} ${hours}:${mins}`;
}
interface User { interface User {
id: number; id: number;
name: string; name: string;
@@ -86,28 +75,12 @@ const BackIcon = (
</svg> </svg>
); );
const TrashIcon = (
<svg
width="16"
height="16"
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 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
);
export default function ProjectDetail() { export default function ProjectDetail() {
const { id } = useParams(); const { id } = useParams();
const alert = useAlert(); const alert = useAlert();
const { hasPermission, isAdmin } = useAuth(); const { hasPermission, isAdmin, user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ProjectForm>({ const [form, setForm] = useState<ProjectForm>({
@@ -278,6 +251,30 @@ export default function ProjectDetail() {
} }
}; };
// Throws on failure so NoteCard keeps its editor open with the draft.
const saveNoteEdit = async (noteId: number, content: string) => {
// Raw apiFetch: the body must carry ONLY content (strict schema), the
// note id lives in the URL — useApiMutation sends its whole input.
let res: Response;
try {
res = await apiFetch(`${API_BASE}/projects/${id}/notes/${noteId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
} catch {
alert.error("Chyba připojení");
throw new Error("network");
}
const body = await res.json().catch(() => null);
if (!res.ok) {
alert.error(body?.error || "Uložení selhalo");
throw new Error(body?.error || "Uložení selhalo");
}
qc.invalidateQueries({ queryKey: ["projects"] });
alert.success("Poznámka upravena");
};
if (isPending) { if (isPending) {
return <LoadingState />; return <LoadingState />;
} }
@@ -300,7 +297,17 @@ export default function ProjectDetail() {
mb: 3, mb: 3,
}} }}
> >
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> {/* flexWrap: the title must drop below the Zpět button on phones
instead of being squeezed beside it (mirrors Offer/Invoice
detail headers). */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button <Button
component={RouterLink} component={RouterLink}
to="/projects" to="/projects"
@@ -310,9 +317,22 @@ export default function ProjectDetail() {
> >
Zpět Zpět
</Button> </Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}> <Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4"> <Typography variant="h4">
Projekt {project.project_number} Projekt
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{project.project_number}
</Box>
</Typography> </Typography>
<StatusChip <StatusChip
label={STATUS_LABELS[project.status] || project.status} label={STATUS_LABELS[project.status] || project.status}
@@ -493,61 +513,18 @@ export default function ProjectDetail() {
{(notes.length > 0 || project.notes) && ( {(notes.length > 0 || project.notes) && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{notes.map((note) => ( {notes.map((note) => (
<Box <NoteCard
key={note.id} key={note.id}
sx={{ authorName={note.user_name}
p: 1.5, createdAt={note.created_at}
bgcolor: "action.hover", editedAt={note.edited_at}
borderRadius: 2, content={note.content || ""}
position: "relative", canEdit={user?.id === note.user_id}
}} canDelete={isAdmin}
> deleting={deletingNoteId === note.id}
<Box onSave={(content) => saveNoteEdit(note.id, content)}
sx={{ onDelete={() => handleDeleteNote(note.id)}
display: "flex", />
justifyContent: "space-between",
alignItems: "flex-start",
gap: 1,
}}
>
<Box sx={{ flex: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mb: 0.5,
}}
>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{note.user_name}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatNoteDate(note.created_at)}
</Typography>
</Box>
<Typography
variant="body2"
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
>
{note.content}
</Typography>
</Box>
{isAdmin && (
<IconButton
size="small"
color="error"
onClick={() => handleDeleteNote(note.id)}
title="Smazat poznámku"
aria-label="Smazat poznámku"
disabled={deletingNoteId === note.id}
sx={{ flexShrink: 0 }}
>
{TrashIcon}
</IconButton>
)}
</Box>
</Box>
))} ))}
</Box> </Box>
)} )}

View File

@@ -554,7 +554,7 @@ export default function Projects() {
setCreateForm({ ...createForm, customer_id: value }) setCreateForm({ ...createForm, customer_id: value })
} }
options={[ options={[
{ value: "", label: " bez zákazníka " }, { value: "", label: "Vyberte zákaznika" },
...(customersQuery.data ?? []).map((c) => ({ ...(customersQuery.data ?? []).map((c) => ({
value: String(c.id), value: String(c.id),
label: c.name, label: c.name,
@@ -571,7 +571,7 @@ export default function Projects() {
setCreateForm({ ...createForm, responsible_user_id: value }) setCreateForm({ ...createForm, responsible_user_id: value })
} }
options={[ options={[
{ value: "", label: " nepřiřazeno " }, { value: "", label: "Vyberte zaměstnance" },
...(usersQuery.data ?? []) ...(usersQuery.data ?? [])
.filter((u) => u.is_active) .filter((u) => u.is_active)
.map((u) => ({ .map((u) => ({

View File

@@ -1,6 +1,5 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
@@ -17,6 +16,7 @@ import {
} from "../utils/formatters"; } from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers"; import { normalizeDateStr } from "../utils/attendanceHelpers";
import useTableSort from "../hooks/useTableSort"; import useTableSort from "../hooks/useTableSort";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { import {
companySettingsOptions, companySettingsOptions,
type CompanySettingsData, type CompanySettingsData,
@@ -45,6 +45,7 @@ import {
StatCard, StatCard,
EmptyState, EmptyState,
LoadingState, LoadingState,
Pagination,
type DataColumn, type DataColumn,
type StatCardColor, type StatCardColor,
} from "../ui"; } from "../ui";
@@ -262,14 +263,33 @@ export default function ReceivedInvoices({
const { data: supplierNames = [] } = useQuery(supplierListOptions()); const { data: supplierNames = [] } = useQuery(supplierListOptions());
const companySettings = useQuery(companySettingsOptions()).data; const companySettings = useQuery(companySettingsOptions()).data;
// List query — auto-refetches when filters change const [page, setPage] = useState(1);
const listQuery = useQuery( // Reset to page 1 when the parent moves to another month — the kept page
// index would otherwise request e.g. page 2 of a sparser month and render
// an empty list. Adjusted during render (adjust-state-on-prop-change
// pattern); a remount via `key` would also clear the search field.
const [prevMonthYear, setPrevMonthYear] = useState(
`${statsMonth}-${statsYear}`,
);
if (`${statsMonth}-${statsYear}` !== prevMonthYear) {
setPrevMonthYear(`${statsMonth}-${statsYear}`);
setPage(1);
}
// List query — auto-refetches when filters change. Paginated: the server
// caps at 25/page, so without the page param rows 26+ were unreachable.
const {
items: invoices,
pagination,
isPending: listPending,
} = usePaginatedQuery<ReceivedInvoice>(
receivedInvoiceListOptions({ receivedInvoiceListOptions({
month: statsMonth, month: statsMonth,
year: statsYear, year: statsYear,
search, search,
sort, sort,
order, order,
page,
}), }),
); );
@@ -290,13 +310,10 @@ export default function ReceivedInvoices({
}), }),
); );
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
const invoices = listQuery.data?.data ?? [];
// Track first successful load (used to suppress the skeleton on later // Track first successful load (used to suppress the skeleton on later
// refetches). Set in an effect rather than during render to avoid a // refetches). Set in an effect rather than during render to avoid a
// render-phase ref mutation. // render-phase ref mutation.
const hasData = !!(listQuery.data || statsQuery.data); const hasData = !!(pagination || statsQuery.data);
useEffect(() => { useEffect(() => {
if (hasData) hasLoadedOnce.current = true; if (hasData) hasLoadedOnce.current = true;
}, [hasData]); }, [hasData]);
@@ -337,7 +354,7 @@ export default function ReceivedInvoices({
}, },
}); });
const showListSkeleton = listQuery.isPending && !hasLoadedOnce.current; const showListSkeleton = listPending && !hasLoadedOnce.current;
const [uploadFiles, setUploadFiles] = useState<File[]>([]); const [uploadFiles, setUploadFiles] = useState<File[]>([]);
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([]); const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([]);
@@ -345,8 +362,9 @@ export default function ReceivedInvoices({
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => { return () => {
blobTimeoutsRef.current.forEach(clearTimeout); blobTimeouts.forEach(clearTimeout);
}; };
}, []); }, []);
@@ -810,73 +828,75 @@ export default function ReceivedInvoices({
<> <>
{renderKpi()} {renderKpi()}
<motion.div <Card>
initial={{ opacity: 0, y: 12 }} <FilterBar>
animate={{ opacity: 1, y: 0 }} <Box sx={{ flex: "1 1 320px" }}>
transition={{ duration: 0.25, delay: 0.08 }} <TextField
> value={search}
<Card> onChange={(e) => {
<FilterBar> setSearch(e.target.value);
<Box sx={{ flex: "1 1 320px" }}> setPage(1);
<TextField
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Hledat podle dodavatele nebo čísla faktury..."
fullWidth
/>
</Box>
</FilterBar>
{showListSkeleton ? (
<LoadingState />
) : (
<DataTable<ReceivedInvoice>
columns={columns}
rows={invoices}
rowKey={(inv) => inv.id}
rowSx={rowSx}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
search ? (
<EmptyState title="Žádné faktury neodpovídají hledání." />
) : (
<EmptyState
title="Žádné přijaté faktury v tomto měsíci."
description={
hasPermission("invoices.create")
? "Nahrajte faktury tlačítkem výše."
: undefined
}
/>
)
}
/>
)}
{(listTotalsQuery.data?.length ?? 0) > 0 && (
<Box
sx={{
display: "flex",
justifyContent: "flex-end",
mt: 1.5,
px: 1,
gap: 1,
color: "text.secondary",
fontSize: "0.9rem",
}} }}
placeholder="Hledat podle dodavatele nebo čísla faktury..."
fullWidth
/>
</Box>
</FilterBar>
{showListSkeleton ? (
<LoadingState />
) : (
<DataTable<ReceivedInvoice>
columns={columns}
rows={invoices}
rowKey={(inv) => inv.id}
rowSx={rowSx}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
search ? (
<EmptyState title="Žádné faktury neodpovídají hledání." />
) : (
<EmptyState
title="Žádné přijaté faktury v tomto měsíci."
description={
hasPermission("invoices.create")
? "Nahrajte faktury tlačítkem výše."
: undefined
}
/>
)
}
/>
)}
{(listTotalsQuery.data?.length ?? 0) > 0 && (
<Box
sx={{
display: "flex",
justifyContent: "flex-end",
mt: 1.5,
px: 1,
gap: 1,
color: "text.secondary",
fontSize: "0.9rem",
}}
>
<span>Celkem:</span>
<Box
component="span"
sx={{ fontWeight: 700, color: "text.primary" }}
> >
<span>Celkem:</span> {formatMultiCurrency(listTotalsQuery.data ?? [])}
<Box
component="span"
sx={{ fontWeight: 700, color: "text.primary" }}
>
{formatMultiCurrency(listTotalsQuery.data ?? [])}
</Box>
</Box> </Box>
)} </Box>
</Card> )}
</motion.div> <Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
{/* Upload Modal */} {/* Upload Modal */}
<Modal <Modal
@@ -1130,11 +1150,9 @@ export default function ReceivedInvoices({
? "Zavřít" ? "Zavřít"
: "Uložit" : "Uložit"
} }
cancelText={ // Read-only (paid) detail has only a close action — a single "Zavřít"
editInvoice && editInvoice._originalStatus === "paid" // button, not a redundant cancel button beside it.
? "Zavřít" hideCancel={editInvoice?._originalStatus === "paid"}
: "Zrušit"
}
loading={saving} loading={saving}
> >
{editInvoice && {editInvoice &&

View File

@@ -625,7 +625,7 @@ export default function OrdersReceived({
setCreateForm({ ...createForm, customer_id: value }) setCreateForm({ ...createForm, customer_id: value })
} }
options={[ options={[
{ value: "", label: "— bez zákazníka" }, { value: "", label: "Vyberte zákazníka" },
...(customersQuery.data ?? []).map((c) => ({ ...(customersQuery.data ?? []).map((c) => ({
value: String(c.id), value: String(c.id),
label: c.name, label: c.name,

View File

@@ -117,8 +117,29 @@ const MODULE_LABELS: Record<string, string> = {
customers: "Zákazníci", customers: "Zákazníci",
users: "Uživatelé", users: "Uživatelé",
settings: "Nastavení", settings: "Nastavení",
ai: "AI asistent",
}; };
// Display order of the permission-module groups in the role modal. Before
// this list the order fell out of DB insertion ids, which differ between dev
// (reseeded) and prod (migration order). Reorder by rearranging this array;
// modules missing from it (e.g. added by a future migration before this list
// is updated) fall to the very end so they stay visible.
const MODULE_ORDER = [
"ai",
"attendance",
"trips",
"vehicles",
"offers",
"orders",
"projects",
"invoices",
"warehouse",
"customers",
"users",
"settings",
];
interface Permission { interface Permission {
id: number; id: number;
name: string; name: string;
@@ -313,7 +334,7 @@ export default function Settings() {
}, [sysSettingsData, sysFormInitialized]); }, [sysSettingsData, sysFormInitialized]);
const saveSystemSettingsMutation = useApiMutation< const saveSystemSettingsMutation = useApiMutation<
typeof sysForm, Partial<typeof sysForm>,
{ message?: string; error?: string } { message?: string; error?: string }
>({ >({
url: () => `${API_BASE}/company-settings`, url: () => `${API_BASE}/company-settings`,
@@ -403,7 +424,24 @@ export default function Settings() {
const handleSaveSystemSettings = async () => { const handleSaveSystemSettings = async () => {
try { try {
await saveSystemSettingsMutation.mutateAsync(sysForm); // Send ONLY the fields this tab edits. sysForm also carries
// numbering/currency fields captured once at init (sysFormInitialized
// never resets), and the backend applies every defined field — sending
// the whole form would silently revert a Firma-tab numbering edit made
// in the meantime with stale values.
await saveSystemSettingsMutation.mutateAsync({
break_threshold_hours: sysForm.break_threshold_hours,
break_duration_short: sysForm.break_duration_short,
break_duration_long: sysForm.break_duration_long,
clock_rounding_minutes: sysForm.clock_rounding_minutes,
invoice_alert_email: sysForm.invoice_alert_email,
leave_notify_email: sysForm.leave_notify_email,
smtp_from: sysForm.smtp_from,
smtp_from_name: sysForm.smtp_from_name,
max_login_attempts: sysForm.max_login_attempts,
lockout_minutes: sysForm.lockout_minutes,
max_requests_per_minute: sysForm.max_requests_per_minute,
});
} catch (e) { } catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení"); alert.error(e instanceof Error ? e.message : "Chyba připojení");
} }
@@ -677,7 +715,25 @@ export default function Settings() {
</Box> </Box>
); );
} }
const si = systemInfo as Record<string, any>; type NasInfo = { configured?: boolean; path?: string | null };
type SystemInfo = {
app_version?: string;
node_version?: string;
platform?: string;
uptime?: string;
environment?: string;
timezone?: string;
memory?: {
rss?: string;
heap_used?: string;
heap_total?: string;
system_free?: string;
system_total?: string;
};
database?: { status?: string; migrations_applied?: number | string };
nas?: Record<string, NasInfo | undefined>;
};
const si = systemInfo as SystemInfo;
const Row = ({ const Row = ({
label, label,
children, children,
@@ -762,7 +818,7 @@ export default function Settings() {
["Faktury", si.nas?.invoices], ["Faktury", si.nas?.invoices],
["Objednávky", si.nas?.orders], ["Objednávky", si.nas?.orders],
["Nabídky", si.nas?.offers], ["Nabídky", si.nas?.offers],
] as [string, Record<string, any>][] ] as [string, NasInfo | undefined][]
).map(([label, info]) => ( ).map(([label, info]) => (
<Row key={label} label={label}> <Row key={label} label={label}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
@@ -1243,8 +1299,12 @@ export default function Settings() {
{Object.entries(permissionGroups) {Object.entries(permissionGroups)
.sort(([a, aPerms], [b, bPerms]) => { .sort(([a, aPerms], [b, bPerms]) => {
if (a === "settings") return 1; const ai = MODULE_ORDER.indexOf(a);
if (b === "settings") return -1; const bi = MODULE_ORDER.indexOf(b);
const aKey = ai === -1 ? MODULE_ORDER.length : ai;
const bKey = bi === -1 ? MODULE_ORDER.length : bi;
if (aKey !== bKey) return aKey - bKey;
// Both unknown: stable fallback by DB insertion order.
const aMin = Math.min(...aPerms.map((p) => p.id)); const aMin = Math.min(...aPerms.map((p) => p.id));
const bMin = Math.min(...bPerms.map((p) => p.id)); const bMin = Math.min(...bPerms.map((p) => p.id));
return aMin - bMin; return aMin - bMin;

View File

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

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