Compare commits

...

80 Commits

Author SHA1 Message Date
BOHA
593dfc356d chore(release): v2.4.41 — UX quick wins (freshness, confirmations, mobile tabs, Odin DnD)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:25:24 +02:00
BOHA
0172c9a442 feat(odin): multi-invoice review scrolls; drag & drop attachments
- Review panel: flex children no longer shrink (flexShrink 0) — with 2+
  extracted invoices the cards kept compressing to fit 35vh, squashing the
  field grids with nothing to scroll; panel now scrolls, maxHeight 40vh.
- Drag & drop onto the chat column: depth-counted dragenter/leave (no
  flicker), Files-only, refused while busy, PDF/image filter with a Czech
  hint toast when nothing usable was dropped, channel-alpha overlay with
  dashed primary border ('Přetáhněte soubory sem'), staged through the same
  path as the paperclip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:22:26 +02:00
BOHA
81b4cb51e7 fix(trips): reject on-behalf trip creation without trips.manage
POST /trips honored body.user_id for any trips.record holder — impersonation
on create (reads were already scoped). Now an explicit Czech 403 unless the
caller has the manager capability; manager on-behalf flow unchanged. +4 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:22:26 +02:00
BOHA
a274a49e90 fix(ui): project status convention, linked-order chip, diacritics, lazy dashboard
- PROJECT_STATUS added to documentStatus.ts (aktivni=info, dokonceny=success,
  zruseny=error — app convention); Projects/ProjectDetail local maps deleted;
  status Select derives its MenuItems from the shared map.
- ProjectDetail linked-order status now rendered via the received-order map
  (was the project map with zero key overlap → raw DB token).
- Cancelled label unified across received/issued orders in documentStatus.ts.
- 'Chyba pripojeni' → 'Chyba připojení' (Dashboard, AuthContext).
- Dashboard lazy-loaded like every other route — its 7 subcomponents leave
  the Login critical path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:22:26 +02:00
BOHA
9c192e79e7 feat(invoices/orders): confirm before paid, draft PDF gate, debounced search, distinct create labels
- Marking an invoice paid (issued + received lists) now confirms via
  ConfirmDialog with loading guard — was a single unguarded chip click on a
  terminal transition; chips got affordance tooltips.
- Invoices list PDF icon hidden for drafts (no number → /file 404s),
  matching Offers/IssuedOrders.
- Search debounced (300ms) on Invoices/ReceivedInvoices (list + totals).
- Orders tabs create buttons renamed 'Nová vydaná/přijatá objednávka'
  (were identical for two document types); issued empty-state CTA aligned;
  draft delete dialog uses documentNumberLabel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:22:06 +02:00
BOHA
4d0ec53514 feat(shell): scrollable status tabs, per-page tab titles, skip-to-content, Czech MUI locale
- Shared Tabs: variant=scrollable + auto scroll buttons — the 5 status-filter
  tabs no longer clip unreachable at ~360px (desktop rendering unchanged).
- TitleSync: browser tab shows the active page ('Faktury · BOHA'; ambiguous
  labels qualified with their section, e.g. 'Záznam – Docházka · BOHA');
  index.html fallback now 'BOHA Admin'.
- Skip-to-content link (visually hidden, visible on focus) + id/tabIndex on
  <main> — keyboard users skip the 248px sidebar.
- MUI csCZ locale merged into the theme (kills built-in English strings like
  'No options'); Czech noOptionsText on Customer/Supplier pickers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:22:06 +02:00
BOHA
87e644eef8 fix(freshness): converge invalidation sets; global query-error toast; silent-toggle feedback
- OrderDetail/InvoiceDetail/LeaveRequests mutations now invalidate the same
  domain sets as their list-page twins (orders+offers+projects+invoices /
  +projects / +users+dashboard) — no more stale project/order labels after
  a detail-page action.
- USER_INVALIDATE hoisted to lib/queries/users.ts; DashProfile self-edit now
  refreshes trips/attendance/leave/projects like an admin edit.
- Global QueryCache.onError toast: failed first-loads no longer masquerade as
  empty lists (data-present refetches stay silent; Unauthorized skipped; 4s
  rate limit; meta.suppressGlobalErrorToast opt-out used by offer/issued-order
  detail + the local TOTP QR query).
- Users/Vehicles active-toggle mutations toast on error (was silent revert);
  clickable status chips got affordance tooltips (incl. warehouse parity).
- AuditLog: refetchOnMount always + staleTime 0 (mutations write audit rows
  but nothing invalidates the key); search debounced; dead auditLogOptions
  module removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:21:50 +02:00
BOHA
6428044624 docs: UX & consistency review (104 verified findings, independently re-verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:00:51 +02:00
BOHA
67ddfb2d5d chore: gitignore local Claude tooling and personal scratch files
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:23:08 +02:00
BOHA
81293ae543 chore(release): v2.4.40 — optional line items on issued orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:03:54 +02:00
BOHA
237ebf3ef8 feat(issued-orders): make line items optional (issue by sections alone)
Items are no longer required on issued orders — an order can be issued
purely from its rich-text sections (Obsah).

- DocumentItemsEditor: opt-in allowEmpty prop lets the list go to zero
  rows (default false keeps the offers/invoices at-least-one-item rule).
- IssuedOrderDetail: allowEmpty on the editor; a saved order with no
  items reopens empty (not a blank starter row); submit guard now
  requires at least one item OR a non-empty section, not an item.
- PDF: with no items the billing heading, items table and total row are
  omitted entirely — a sections-only order shows only its Obsah.
- Service: finalize (draft->sent) and create-as-non-draft reject a
  completely blank order (no items AND no sections) with a Czech 400
  (empty_document); drafts may still be saved empty as WIP.
- Tests: sections-only finalize + blank-order rejection + sections-only
  PDF render; existing finalize fixtures given minimal content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:36:35 +02:00
BOHA
983a1408f1 docs(release): clarify nginx reload is only for nginx config changes, not every deploy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:44:47 +02:00
BOHA
ae4a51bf7d chore(release): v2.4.39 — per-document custom-field print selection
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:40:16 +02:00
BOHA
fb480d886d fix(documents): gate selected_custom_fields behind editable guard; bound index cap to column width
Addresses review: add selected_custom_fields to offers/issued-orders
NON_STATUS_UPDATE_FIELDS so a non-editable document rejects selection
edits (status-only transitions still pass); tighten Zod cap 200->50 to
stay within VARCHAR(255).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:31:58 +02:00
BOHA
59f555059b fix(documents): include custom-field selection in offer/issued-order unsaved-changes guard
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:22:36 +02:00
BOHA
3d784adf5d feat(documents): per-document custom-field print picker on offer/issued-order/invoice detail
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:20:51 +02:00
BOHA
44cfea22ca feat(documents): shared CustomFieldsPrintPicker + detail query types
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:17:23 +02:00
BOHA
bce0280846 feat(documents): PDF prints only the document's selected company custom fields
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:15:26 +02:00
BOHA
0d9b5e9570 feat(documents): persist & expose selected_custom_fields in services
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:11:10 +02:00
BOHA
2b3266a1cc feat(documents): accept selected_custom_fields in document schemas
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:08:44 +02:00
BOHA
2f084174df feat(documents): selected-custom-fields encode/decode helpers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:07:35 +02:00
BOHA
5551786da2 feat(documents): add selected_custom_fields column to quotations/issued_orders/invoices
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:06:10 +02:00
BOHA
09f8dc63bf docs(plan): per-document custom-field print selection implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:52:03 +02:00
BOHA
8dec785d13 docs(spec): per-document custom-field print selection (offers/issued orders/invoices)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 19:44:32 +02:00
BOHA
4674ff3389 fix(dashboard): equal-height cards — stretch + fill instead of ragged
The dashboard content grids used alignItems:"start", so cards in a row sized to
their own content (ragged bottoms). Switch to alignItems:"stretch" so each row
shares one height, and make the wrapped cards actually fill the cell:

- DashProfile / DashSessions: motion-div + Card height:100% (the inner card was
  shorter than its stretched cell).
- Right column (Aktivní projekty + Nabídky): cards flex:1 so the stacked pair
  fills the column to match the neighbouring single cards.

CSS-only; no logic change. tsc clean, lint 0, 669 tests pass. Verified live via
Playwright (3-col row cells equal at 607; bottom 2-col cards equal at 257).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:34:17 +02:00
BOHA
4bb7de7247 feat(app): detect new deploy and prompt refresh (+ lazy-chunk recovery)
When a new version is deployed, open tabs no longer silently run stale code
until a manual F5.

- Server stamps every response with X-App-Version (src/middleware/version.ts,
  onSend hook in server.ts).
- Client bakes its build version via Vite define (__APP_VERSION__); apiFetch
  compares the header to it (no polling — piggybacks existing traffic) and
  latches a mismatch in a small store (appVersion.ts).
- UpdateBanner: a persistent bottom Snackbar "Je k dispozici nová verze
  aplikace. — Obnovit" → location.reload(). User-initiated, so an in-progress
  form is never interrupted.
- lazyWithReload: every React.lazy page reloads once if its hashed chunk 404s
  after a deploy (sessionStorage-guarded against loops), fixing the "old chunk
  gone" error screen.

Approach chosen after researching current SPA practice (version header + banner
beats polling / a service worker for a non-PWA admin app).

Tests: +4 (server header, client store latch/notify). Full suite 669 pass,
tsc -b clean, lint 0. Verified live via Playwright (header emitted; banner
appears on a simulated mismatch). Detection applies to deploys from the NEXT
release onward (current clients lack the detection code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:19:46 +02:00
BOHA
719d1f2659 feat(documents): per-item Sleva (% discount) on offers + invoices
Adds an optional per-line-item percentage discount ("Sleva") to offers and
issued invoices, on both the detail-page editor and the PDF.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:31:59 +02:00
203 changed files with 27640 additions and 5455 deletions

9
.gitignore vendored
View File

@@ -21,5 +21,14 @@ dist-client/
.claude/worktrees/ .claude/worktrees/
.claude/settings.local.json .claude/settings.local.json
# Local Claude tooling (personal, not shared)
.claude/commands/
.claude/skills/
.claude/workflows/
CLAUDE-FABLE-5.md
# Personal scratch (planning docs, etc.)
simon/
# Superpowers brainstorm mockups # Superpowers brainstorm mockups
.superpowers/ .superpowers/

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/`.
--- ---

407
POSSIBLE_IMPROVEMENTS.md Normal file
View File

@@ -0,0 +1,407 @@
# Possible Improvements — UX & Consistency Review
_A prioritized UX review of boha-app, grounded in 104 verified, code-referenced findings._
> **How this was produced (2026-06-24):** a multi-agent audit fanned out 16 independent
> reviewers across 5 feature domains and 11 cross-cutting UX dimensions (155 raw findings),
> deduplicated them into 118 unique items, then **adversarially re-checked every finding
> against the actual code** — dropping 14 that didn't hold up or contradicted a deliberate
> documented decision (CLAUDE.md). What remains are 104 findings each confirmed in the
> source, with file references kept intact so they can be acted on directly. Read §1§3 for
> the opinion and the plan; §4 is the full catalogue by theme; §5 is suggested sequencing.
>
> **Independently re-verified (2026-06-24):** a second adversarial pass (9 parallel
> verifiers, different model) re-checked all 104 findings against the source:
> **100 confirmed, 4 partial, 0 refuted.** The partials were factual nits (a couple of
> miscounts, one wrong fix target, one mis-cited import), corrected inline in this
> document. The pass also surfaced one new backend finding (trips create authz — see §4.9)
> and upgraded confidence on the AttendanceAdmin and warehouse-models findings.
---
## 1. Executive Summary
boha-app is a **mature, broad, and genuinely well-built** back-office system. The bones are excellent: there is a real component kit (`src/admin/ui/`), shared document modules, a single-source-of-truth status module (`documentStatus.ts`), a global React Query cache with disciplined invalidation conventions, timezone-safe date handling, edit-locking on documents, and an accessibility-aware theme. Most of the "hard" platform work is done and done well. This is not a rescue job — it is a **polish-and-converge job**.
That said, the owner asked the right question. The single dominant theme across these findings is **consistency debt: the app does the same thing several different ways, and the variants disagree in ways the daily user feels.** Almost every cluster below is a story of "module A got it right, module B is an older or forked copy that drifted." The codebase even documents this pattern against itself — `documentStatus.ts` exists _because_ per-page status maps drifted; `useUnsavedChangesGuard` exists _because_ the dirty-check was copy-pasted; CLAUDE.md explicitly says "extend, never fork." The findings show those rules are followed in the newest modules (offers, issued orders) and quietly violated in the older twins (invoices, orders, attendance-admin, projects, warehouse).
Why this matters: the user's mental model breaks at the seams. Marking an invoice paid from the **detail** page leaves a project's order-status label stale, but doing it from the **list** page refreshes it. The "create" button **navigates to a page** on one Orders tab and **opens a modal** on the other. A failed list fetch shows "no records yet" — which can convince a user a customer has zero invoices when the server actually errored. Validation errors appear **inline under the field** on the supplier modal but **as a 4-second toast** on its mirror-image customer modal. None of these is catastrophic alone; collectively they make a strong app feel "not quite finished" and erode trust in what's on screen.
**The five highest-leverage moves, in order:**
1. **Stop showing stale data after writes.** Define one canonical React Query invalidation set per document family and apply it to _both_ list and detail mutations. Today detail-page edits invalidate fewer domains than their list twins (`OrderDetail`, `InvoiceDetail`, `DashProfile`, leave-cancel). Small effort, removes a whole class of "why is this number wrong" confusion. _(Cluster: data-freshness)_
2. **Guard the irreversible actions that currently aren't.** Marking an invoice **"Zaplaceno"** is a single unconfirmed chip click and is _terminal_ — the one truly irreversible document transition with **no** confirm, while every reversible status change has one. Same inversion in the warehouse: **"Potvrdit"** (posts stock) has no confirm but its **undo** does. And the **Aktivní/Neaktivní** chips are secret one-click toggles that fail silently on error. _(Cluster: destructive actions)_
3. **Fix list-page failure and loading states.** No list page reads `isError`, and there is no global `QueryCache.onError`, so failed fetches fall through to the **empty state** — actively misleading. Add a global error toast plus a distinct inline error. _(Cluster: loading/empty/error)_
4. **Make the app usable on a phone.** The shared `Tabs` uses MUI's non-scrolling `variant="standard"`, so the 5 status-filter tabs on Offers/Invoices simply **clip off-screen and become unreachable** at ~360px — a one-prop fix that touches every status filter. Modals never go full-screen on phones. _(Cluster: mobile)_
5. **Converge the shared primitives the app already owns.** Route the forked dirty-guards, status maps, empty states, page headers, save buttons, and PDF openers back through their canonical implementations. This is the structural fix that prevents the other four from re-drifting. _(Cluster: forked modules)_
The rest of this report groups all findings by theme, gives a prioritized action table, and then lists every finding with its file references intact so they can be acted on directly.
---
## 2. Top Themes
### Theme A — Data-freshness drift (the trust killer)
React Query invalidation is the app's nervous system, and CLAUDE.md lays out clear rules ("invalidate every domain that embeds the data"). The newest modules obey; older and raw-`apiFetch` paths don't. **List pages and their own detail pages invalidate different domain sets** because list pages use manual `invalidate` arrays while detail pages use `useApiMutation` arrays, and the two drifted independently. The result is subtle: a value that is correct after editing it one way is stale after editing it the identical other way. Converge on **one named invalidation constant per family** and reuse it on both surfaces. The dashboard already solved the "many domains feed me" case with `refetchOnMount: "always"` — apply the same backstop to the audit log.
### Theme B — Destructive & risky actions are gated by consequence-inverted logic
The pattern across the app is sound — `ConfirmDialog` everywhere — but the gating is **inversely correlated with actual consequence** in the few places it matters most. The _irreversible_ transitions (invoice→paid, warehouse "Potvrdit" posts stock) have **no** guard, while their _reversible_ counterparts (cancel document) get a full danger confirm. Several state changes hide as **chip toggles with no affordance** and **no `onError`**, so a rejected toggle silently reverts with zero feedback. And the "wipe the entire audit log" case uses the same low-key modal as "trim 30-day rows." Converge on: irreversibility → stronger friction, never weaker; every mutating control announces itself and reports its errors.
### Theme C — Forms & save UX speak three dialects
Validation is **inline-under-field** in some forms and **transient toast** in others — and _mirrored screens disagree_ (supplier modal inline vs customer modal toast; trip editor inline vs admin-trip editor toast; received-invoice bulk-review inline vs its single-edit toast). Enter submits page forms but **never** submits a shared-`Modal` form. Save buttons **spin** in document pages but only **swap text** elsewhere, and dual-save warehouse forms can't tell you which action is running. Failed validation never **moves focus** to the bad field. Converge on: inline `<Field error>` for validation, toasts only for transport/server failures, `<form onSubmit>` in the shared Modal, one save affordance, and focus-the-first-error.
### Theme D — Unsaved-work protection is partial and fork-ridden
The shared `useUnsavedChangesGuard` is used by exactly two pages; `OrderDetail` and `InvoiceDetail` **re-implement it inline**, and `ProjectDetail`, `CompanySettings`, all three warehouse forms, and `AttendanceCreate` have **no guard at all** — a refresh discards a 15-line receipt silently. Worse, _every_ guard only catches `beforeunload`, so clicking the in-app **"Zpět"** or a sidebar link — the most common way to leave — discards edits with no prompt (this one needs a router upgrade). And `InvoiceDetail` has **no edit-lock** while its siblings do, so two users can clobber each other's invoice line items.
### Theme E — Loading, empty & error states have no single grammar
Three different loading behaviors for the _same_ "change the month" action (dim / nothing / flash). Empty states built two ways (`<EmptyState>` vs hand-rolled `Box+Typography`). 40 pages **blank the whole page to a centered spinner** (layout shift) instead of keeping chrome; there are **zero** real MUI `<Skeleton>`s despite identifiers literally named `showListSkeleton`. The dashboard hand-rolls its own spinner. And the big one: **failed fetches render as empty**, not as errors.
### Theme F — Navigation & information architecture has thin orientation
Every browser tab reads **"Admin"** (no per-page title), there are no breadcrumbs, the back control is built **four different ways**, and `Received-order` detail sends you back to the **wrong Orders tab**. Master data is scattered across four nav sections; "Zákazníci" lives at the misleading URL `/offers/customers`; `settings.templates` opens the gear then **bounces you to the dashboard**; and there are three different "access denied" experiences. None blocks work, but together they make deep pages feel un-anchored.
### Theme G — Mobile is a second-class citizen in specific, fixable spots
Status-filter tabs **clip and become unreachable** on phones. Modals never go full-screen. Long full-page editors keep **Save only at the top**, so phone users scroll all the way back up to save. Warehouse line-items lose the labeled card-per-row layout the document editor has — worst exactly where shop-floor mobile use is likely. `FilterBar` children mix fixed and growing flex bases, leaving dead space.
### Theme H — Microcopy & localization inconsistency
The goods-receipt module is named **four ways** (Příjmy / Nový příjem / Příjmové doklady / Příjemka) and "Příjmy" also means _financial income_. "Cancelled" is spelled two ways in the same status file. Create verbs split three ways. The busy label has five competing forms plus a `"Chyba pripojeni"` diacritics typo. Autocompletes leak English **"No options"** in an otherwise fully-Czech app. Several prices bypass cs-CZ formatting (period decimals, no currency).
### Theme I — Accessibility gaps in the hot paths
Clickable table rows aren't keyboard-operable (warehouse records literally can't be opened by keyboard). Search boxes are placeholder-only with no accessible name across ~13 pages. The desktop line-item grid is unlabeled while the mobile card is fully labeled. No skip-to-content link, so keyboard users tab the whole sidebar on every navigation.
### Theme J — Bundle & performance
MUI + Emotion sit in the **781 kB entry chunk**, so every patch release busts the cached UI vendor code and daily users re-download ~232 kB gzip of unchanged MUI. The Dashboard and its 7 subcomponents are bundled into the entry chunk and downloaded **on the Login screen**. No route-transition progress bar, no link prefetch.
---
## 3. Prioritized Recommendations
Effort: **S** = hours · **M** = a focused day or two · **L** = multi-day / structural.
### Quick wins — high impact, low effort
| # | Change | Where | Impact | Effort |
| --- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------ | ------ |
| 1 | Make status-filter `Tabs` scrollable so they stop clipping off-screen on phones | `src/admin/ui/Tabs.tsx:28-49` (one prop fixes all) | High | S |
| 2 | Add a confirm (or undo toast) + hover affordance to the **"mark invoice paid"** chip — the only unguarded irreversible transition | `Invoices.tsx:339-359,550-562`, `ReceivedInvoices.tsx:668-680` | High | S |
| 3 | Converge detail-page invalidation sets onto the list-page (full) sets | `OrderDetail.tsx:162,168,177`; `InvoiceDetail.tsx:1054,1067,1073`; `DashProfile.tsx:225-226`; `LeaveRequests.tsx:90` | Medium | S |
| 4 | Gate the Invoices **list** PDF icon on `invoice_number` so drafts stop dead-ending in a 404 | `Invoices.tsx:616-626` | Medium | S |
| 5 | Point `Received-order` detail Zpět / redirects at `/orders?tab=prijate` | `OrderDetail.tsx:116-121,275-287,397-405` | Medium | S |
| 6 | Add a global `QueryCache.onError` toast so failed fetches stop masquerading as empty | `src/admin/lib/queryClient.ts` | High | S |
| 7 | Debounce search on Invoices / ReceivedInvoices / AuditLog (reuse `useDebounce`) | `Invoices.tsx:282`, `ReceivedInvoices.tsx:250`, `AuditLog.tsx:202` | Medium | S |
| 8 | Add global MUI `csCZ` `localeText` (kills English "No options" everywhere) | `src/admin/ui/MuiProvider.tsx:15`; pickers `CustomerPicker.tsx:49`, `SupplierPicker.tsx:49` | Medium | S |
| 9 | Add `onError` toast to Vehicles/Users active-toggle mutations (silent failure today) | `Vehicles.tsx:146-160`, `Users.tsx:162-171` | Medium | S |
| 10 | Add a per-page `document.title` (`<TitleSync>`) so tabs/bookmarks aren't all "Admin" | `index.html:22`, `AppShell`, `navData.tsx` | Medium | S |
| 11 | Use `PROJECT_STATUS`/`ORDER_STATUS` maps for project + linked-order chips (currently wrong colors / raw DB tokens) | `Projects.tsx:50`, `ProjectDetail.tsx:43,577`, `documentStatus.ts` | Medium | S |
| 12 | Fix `"Chyba pripojeni"` diacritics typo; unify cancelled-label spelling | `Dashboard.tsx:127`, `AuthContext.tsx:256,314`; `documentStatus.ts:49,58` | Low | S |
| 13 | Add a skip-to-content link + `id="main-content"` on `<main>` | `AppShell.tsx:130-145,210-223` | Medium | S |
| 14 | Lazy-load Dashboard so its 7 subcomponents leave the Login critical path | `AdminApp.tsx:14-15`, `Dashboard.tsx:16` | Medium | S |
| 15 | Relabel the two Orders-tab "Vytvořit objednávku" buttons (identical for two doc types) | `Orders.tsx:125,129` | Medium | S |
### High-impact — worth a focused effort
| # | Change | Where | Impact | Effort |
| --- | -------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------ | ------ |
| 16 | Add unsaved-changes guards to the heavy editors that have none; de-fork `OrderDetail`/`InvoiceDetail` onto the shared hook | `ProjectDetail`, `CompanySettings`, `Warehouse*Form`, `AttendanceCreate`; `useUnsavedChangesGuard.ts` | High | M |
| 17 | Render an inline error (distinct from empty) when `isError`, with retry | `usePaginatedQuery.ts:47`, all list pages | High | M |
| 18 | Standardize validation on inline `<Field error>`; fix the mirrored screens that disagree | `WarehouseSuppliers` vs `OffersCustomers`; `Trips` vs `TripsAdmin`; `ReceivedInvoices` bulk vs single | Medium | M |
| 19 | Wrap shared `Modal` in `<form onSubmit>` so Enter submits every dialog | `src/admin/ui/Modal.tsx:84,92` | Medium | M |
| 20 | Route the three forked invoice PDF openers through `useDocumentPdf`/`useDocumentListPdf` | `Invoices.tsx:361`, `InvoiceDetail.tsx:1175`, `ReceivedInvoices.tsx:561` | Medium | M |
| 21 | Replace inline English/technical error strings with `apiErrorMessage(...)` across ~32 pages | `mutations.ts:30-36`; per-page catch blocks | Medium | M |
| 22 | Make `Modal` full-screen on phones; fix leave-modal hardcoded 2-col date grid | `Modal.tsx:67-75`, `Attendance.tsx:1195-1199` | Medium | S |
| 23 | Give warehouse line-items the labeled card-per-row mobile layout the document editor has | `WarehouseIssueForm.tsx:513-567` vs `DocumentItemsEditor.tsx:170-342` | Medium | M |
| 24 | Add keyboard semantics to `DataTable` clickable rows (role/tabindex/Enter-Space) | `DataTable.tsx:285-303,137-161` | Medium | M |
| 25 | Add a "Nová žádost" create action to "Moje žádosti" (lift the leave modal out of Attendance) | `LeaveRequests.tsx:241`, `Attendance.tsx:936,978` | Medium | M |
| 26 | Route hand-rolled headers through `PageHeader`/`headerActionsSx` (~20 pages) so mobile buttons stack | `PageHeader.tsx:19`; `Projects`, `Users`, `Vehicles`, `TripsAdmin`, `AttendanceAdmin`, `Settings`, `CompanySettings` | Medium | M |
| 27 | Confirm before warehouse **"Potvrdit"** / **"Uložit a potvrdit"** (posts stock irreversibly) | `WarehouseReceiptDetail.tsx:354-356`, `WarehouseReceiptForm.tsx:445` | Medium | S |
| 28 | Split `@mui/*`+`@emotion/*`(+quill) into a long-lived `vendor-mui` chunk | `vite.config.ts` | Medium | M |
| 29 | Give list-page search inputs an accessible name + `type="search"` (shared `SearchField`) | `Offers.tsx:787`, `Invoices.tsx:741`, +11 pages | Medium | S |
### Strategic — larger bets
| # | Change | Where | Impact | Effort |
| --- | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | ------ | ------ |
| 30 | Add edit-locking (migration + route trio) to `InvoiceDetail` (and order notes) to stop multi-user clobbering | `invoices.ts`, `orders.ts`, schema; wire `useDocumentLock`/`LockBanner` | High | L |
| 31 | Migrate `InvoiceDetail` off its fork onto shared `DocumentItemsEditor`/`useDocumentPdf`/dirty-guard (add `showVat` flag) | `InvoiceDetail.tsx:241-541`, `DocumentItemsEditor.tsx` | Medium | L |
| 32 | Block dirty in-app navigation (requires `createBrowserRouter`/`RouterProvider` migration, then centralize in the guard) | `main.tsx`, `useUnsavedChangesGuard.ts` | Medium | L |
| 33 | Move `AttendanceAdmin` reads off raw `apiFetch` onto React Query (kills the `setTimeout(300)` refetch dance) | `useAttendanceAdmin.ts:823,871,1057` | Medium | L |
| 34 | Real skeleton/page-shell first paint instead of full-page spinner on 40 pages | `LoadingState.tsx`, list pages; `skeleton-boha` skill exists | Medium | L |
| 35 | Route-transition top progress bar + sidebar link prefetch | `AppShell.tsx:222`, `AdminApp.tsx` | Medium | M |
| 36 | Consolidate duplicated status/label maps into `documentStatus.ts` (warehouse/projects/leave) | `documentStatus.ts`; 6 warehouse files, `Projects`, `Leave*` | Medium | M |
---
## 4. Detailed Findings by Area
### 4.1 Data-freshness — stale data after mutations
- **Detail-page mutations invalidate fewer domains than their list twins.** `OrderDetail` status/notes/delete (`OrderDetail.tsx:162,168,177`) invalidate only `[orders,invoices]` while the `ReceivedOrders` list delete (`ReceivedOrders.tsx:175`) uses `[orders,offers,projects,invoices]`. `InvoiceDetail` save/status/delete (`InvoiceDetail.tsx:1054,1067,1073`) use `[invoices,orders]` while the `Invoices` list adds `projects` (`Invoices.tsx:328,352`). **Why it matters:** marking an invoice paid or changing an order status from the _detail_ page leaves `ProjectDetail`'s `order_status` label (`ProjectDetail.tsx:571`) and the source offer stale until staleTime/refocus. **Fix:** define one canonical set per family (e.g. `[offers,orders,issued-orders,projects,invoices]`) and apply to both surfaces; `OfferDetail`/`Offers` are the consistent reference to copy.
- **Self-profile edit refreshes fewer domains than an admin editing the same user.** `DashProfile` (`DashProfile.tsx:225-226`) invalidates only `[dashboard,users]`, but admin Users edit uses `USER_INVALIDATE=[users,trips,attendance,leave-requests,leave,projects]` (`Users.tsx:117-124`). **Why:** your display name is embedded in trips/attendance/leave/projects (none keyed under `[users]`), so renaming yourself leaves it stale there. **Fix:** export `USER_INVALIDATE` from a shared module and reuse it in `DashProfile`.
- **Cancelling a leave request omits the `[users]` balance invalidation that create/approve include.** Cancel (`LeaveRequests.tsx:90`) invalidates `[leave-requests,leave,attendance]`; create (`Attendance.tsx:305`) and approve/reject (`LeaveApproval.tsx:164,177`) add `users,dashboard`. **Why:** the genuinely-stale gap is `[users]` balance/history when an approved future request is cancelled (the `[dashboard]` part self-heals via `refetchOnMount`). **Fix:** hoist a shared `LEAVE_INVALIDATE` set across all three flows.
- **Audit Log is never invalidated by the mutations that write audit rows.** `logAudit()` runs on every mutation, but `[audit-log]` is invalidated only by plan flows (`usePlanWork.ts:167`) and the page's own cleanup (`AuditLog.tsx:191`). **Why:** recent actions don't appear within the 30s default staleTime. **Fix:** add `refetchOnMount:'always'` (and `staleTime:0`) to the AuditLog page's inline `useQuery` (`AuditLog.tsx:135`), mirroring `dashboardOptions` — don't sprinkle `[audit-log]` into dozens of arrays. _(Verification note: `auditLogOptions` in `auditLog.ts` is dead code — nothing imports it; either delete it or wire the page onto it and put the option there.)_
- **Odin invoice import invalidates only `[invoices]`, not `[suppliers]`/`[company-settings]`.** `OdinChat.tsx:362` vs manual `[invoices,suppliers,company-settings]` (`ReceivedInvoices.tsx:331,341`). **Why:** an Odin import that introduces a new vendor name won't appear in the `[suppliers]`-keyed autocomplete until staleTime expires. **Fix:** use the same set on both paths (they POST to the same endpoint).
- **Received-invoice supplier picker is disjoint from the warehouse/issued-order supplier registry.** Received invoices use a free-text `string[]` under `[suppliers]``/received-invoices/suppliers` (`common.ts:22-28`); issued orders/warehouse use the structured `sklad_suppliers` table (`issued-orders.ts:76`, `warehouse.ts:241`). **Why:** a vendor added in Warehouse→Suppliers never shows when entering a received invoice, and vice-versa — same conceptual entity, two disjoint stores. **Fix:** don't force an FK (keep one-off vendors as quick free-text), but **union** `sklad_suppliers` names into the received-invoice autocomplete source.
### 4.2 Destructive & risky actions — confirmation and undo
- **Marking an invoice "Zaplaceno" is a single unconfirmed chip click — the only irreversible transition with no guard.** In both invoice lists the chip instantly PUTs `status=paid` with no confirm and no undo (`Invoices.tsx:339-359,550-562`, `ReceivedInvoices.tsx:668-680`); for issued invoices "paid" is terminal (detail goes read-only, row undeletable, `InvoiceDetail.tsx:1220-1314`). Every _detail_ page gates status changes behind a `ConfirmDialog`, but this one doesn't, and the chip only differs from a static one by a pointer cursor. **Fix:** route the chip click through a `ConfirmDialog` ("Označit fakturu … jako zaplacenou?") or an undo toast, and add a hover/tooltip affordance.
- **Aktivní/Neaktivní chips are hidden one-click toggles with no affordance, and fail silently.** On Vehicles, Users, Warehouse Locations and Suppliers the `StatusChip` is secretly an `onClick` toggle with no tooltip/label (`Vehicles.tsx:263`, `Users.tsx:288`). Deactivation fires on one click while the less-destructive delete gets a full `ConfirmDialog`. The Vehicles/Users toggle mutations declare only `onSuccess`, **no `onError`** and no try/catch (`Vehicles.tsx:146-160`, `Users.tsx:162-171`), so a rejected toggle gives zero feedback and the chip silently reverts. **Fix:** make activation an explicit labelled control (switch/menu action) with a tooltip; add a deactivation confirm for user/vehicle; add `onError` toasts. (`WarehouseLocations`/`WarehouseSuppliers` already toast via try/catch — copy them.)
- **`ConfirmDialog` in-flight `loading` is applied inconsistently — some dialogs allow duplicate submits.** `IssuedOrderDetail` passes `loading` to both status and delete dialogs (`:958`); `OrderDetail`/`InvoiceDetail` pass it to delete but not status (`OrderDetail.tsx:780-790`, `InvoiceDetail.tsx:2453-2463`); Vehicles delete (`:417-429`), `AttendanceAdmin` delete (raw `apiFetch`, no `isPending`, `useAttendanceAdmin.ts:1293-1320`) and CompanySettings bank delete omit it entirely → a second click fires a duplicate request. **Fix:** pass `loading={mutation.isPending}` to every `ConfirmDialog`; convert `AttendanceAdmin`'s raw delete to `useApiMutation`; standardize on `IssuedOrderDetail`'s keep-open "Zpracovávám…" convention.
- **Irreversible "Potvrdit" (posts stock) has no confirm, while its reversible "Zrušit doklad" undo does.** `WarehouseReceiptDetail.tsx:354-356` commits stock movements on one click; `:508-517` requires a danger confirm to reverse it. Same single-click commit in the form's "Uložit a potvrdit" (`WarehouseReceiptForm.tsx:445`). **Why:** the gating is inverted relative to consequence; a misclick commits stock. **Fix:** add a `ConfirmDialog` before "Potvrdit" (and ideally the form's commit).
- **Cancellation confirms show two near-identical "Zrušit" buttons.** When the confirmed action is itself a cancellation, both the dismiss and the confirm start with "Zrušit" (`WarehouseReceiptDetail.tsx:508-516`, `LeaveRequests.tsx:260-268`) — same leading word, opposite meanings (the buttons do differ visually: contained vs text). **Fix:** relabel the dismiss to "Zpět"/"Ne" via `cancelText` (`ConfirmDialog.tsx:31-33`).
- **Wiping the ENTIRE audit log uses the same low-key modal as trimming old rows.** "Vše" sits in the same dropdown as "30 dní"/"90 dní" and the only warning is a 0.6-opacity caption (`AuditLog.tsx:297-319,207-218`). The backend deliberately requires a special literal to wipe everything (`routes/admin/audit-log.ts:92-109`), but the UI auto-supplies it with no extra friction. **Fix:** when "Vše" is selected, switch to danger styling, show a prominent warning, ideally require typing a confirmation word.
- **Error toasts auto-dismiss after 4s identically to successes, with no history.** `AlertContext.tsx:53-70` defaults every severity to 4000ms; a long Czech validation message vanishes as fast as "Uloženo," with no toast history (`AlertContainer.tsx:20-27`). **Fix:** key the default off severity — error/warning longer or sticky-until-dismissed, success ~4s.
### 4.3 Error & message feedback fidelity
- **Raw English/technical error strings leak into Czech toasts on ~30 pages.** A helper `apiErrorMessage(err, fallback)` converts client-generated English ("Unauthorized", "Failed to fetch", "Invalid JSON response", "Request failed (NNN)") into a Czech fallback (`mutations.ts:30-36,69-84`), but only `OfferDetail`/`IssuedOrderDetail` use it. ~30 pages do `alert.error(e instanceof Error ? e.message : "Chyba připojení")` (`Vehicles.tsx:203`, `OrderDetail.tsx:191`), passing the raw English through on dropped connections / non-JSON 500s / 401s. **Fix:** replace the inline pattern with `apiErrorMessage(e, "<Czech fallback>")` across the ~32 files. **Do not** add a blanket `useApiMutation` default `onError` — pages call `mutateAsync` inside try/catch and also reset local state there, so a default handler would double-toast.
### 4.4 Unsaved-changes & data-loss protection
- **The dirty guard is absent on heavy editors and forked twice.** Shared `useUnsavedChangesGuard` is used only by `OfferDetail`/`IssuedOrderDetail`; `OrderDetail` (`:130-143`) and `InvoiceDetail` (`:942-958`) re-implement `isDirty`+`beforeunload` inline; `ProjectDetail`, `CompanySettings`, `WarehouseIssueForm/ReceiptForm/InventoryForm` and `AttendanceCreate` register **no guard at all** — a refresh discards everything typed. **Fix:** route the two forks through the shared hook and add it to the six unguarded editors.
- **Dirty guards block only tab-close, not in-app navigation.** Every guard registers only `beforeunload` (`useUnsavedChangesGuard.ts:25-33`), which doesn't fire on React-Router navigation — so the in-app "Zpět" or a sidebar link discards edits silently. **Fix (structural):** the app uses classic `<BrowserRouter>` (react-router 6.30.3) where `useBlocker`/`usePrompt` aren't available — this needs a migration to `createBrowserRouter`/`RouterProvider` (or a custom nav-intercept context), then centralize the blocker inside the shared hook.
- **Always-editable page forms offer no explicit Discard.** Modals have "Zrušit" and `WarehouseItemDetail` has an edit/cancel toggle that restores the snapshot (`:247-269`), but `ProjectDetail` (only Uložit+Smazat, `:343`) and `CompanySettings` (only Uložit, `:647`) give no way to abandon edits — you must reload (and they don't even warn). **Fix:** add a "Zrušit změny" button that restores the loaded snapshot from query cache.
- **Invoices have no edit-lock while offers and issued orders do (multi-user clobbering).** `OfferDetail`/`IssuedOrderDetail` acquire a server lock, heartbeat, render `LockBanner` and force read-only when another user holds it; `InvoiceDetail` imports none of it (`:22-88`), so two users can clobber each other's line items (last-save-wins). Same gap for received-order notes on `OrderDetail`. **Fix (structural):** the _client_ pieces exist (`useDocumentLock`+`LockBanner`) but the _server_ trio does **not** — needs a migration adding `locked_by`/`locked_at` to invoices (and orders), a lock/heartbeat/unlock route trio + `locked_by` enrichment in the detail endpoint (mirror `issued-orders.ts:138-245`), then wire the existing hook + banner.
### 4.5 Form validation & submission
- **Validation is inline in some forms, transient toasts in others — and mirrored screens disagree.** Inline `<Field error>` camp: Trips, Users, Vehicles, WarehouseSuppliers, OfferDetail. Toast camp: ProjectDetail, AttendanceCreate, CompanySettings, OffersCustomers, TripsAdmin, ReceivedInvoices-edit. The disagreements: supplier modal inline (`WarehouseSuppliers.tsx:266-270`) vs its mirror customer modal toast (`OffersCustomers.tsx:338-341`); regular trip editor inline `end_km>start_km` (`Trips.tsx:294-300`) vs admin trip editor toast (`TripsAdmin.tsx:485-490`); received-invoice bulk-review inline per-row (`:431-446`) vs single-edit toast (`:508-519`). **Fix:** standardize on inline `<Field error>`; reserve toasts for server/transport failures.
- **Enter submits page forms but never any shared-Modal form.** `Modal` renders children in `DialogContent` with an `onClick` button (default `type=button`) and no surrounding `<form>` (`Modal.tsx:84,92`), so Enter never submits a modal. `OfferDetail` even hand-rolled an `onKeyDown` Enter workaround (`:1059-1061`). **Fix:** wrap `DialogContent`+`DialogActions` in `<form onSubmit>`, make the primary action `type=submit`, mark secondary/in-content buttons `type=button`, and remove the workaround.
- **Failed validation doesn't move focus to the first invalid field nor announce to AT.** Handlers just `setErrors` and return (`Vehicles.tsx:192-197`); the `Field` error is a plain caption wired via `aria-describedby`/`aria-invalid`, not `aria-live`/`role=alert` (`Field.tsx:81-89`). In a long modal the error can be off-screen. **Fix:** after `setErrors`, focus+`scrollIntoView` the first errored field and render the inline error with `role=alert`; centralize in `Field.tsx`/`Modal.tsx`.
- **Required-asterisk marking is inconsistent with what's validated; DateField/TimeField can't show an error border.** `Field` paints the asterisk only when `required` is passed (`Field.tsx:74`), but it's applied unevenly: `WarehouseIssueForm` validates Projekt-required with no asterisk (`:474` vs `:328-329`); `ProjectDetail` validates Název-required with no asterisk (`:377` vs `:192`); `DateField`/`TimeField` take no `error` prop so a failed required date only reddens helper text, not the border. **Fix:** make `required` the single source of truth (drives asterisk + ideally validation); thread an `error` prop through `DateField`/`TimeField`.
- **Save-button feedback differs (spinner vs text swap); dual-save warehouse forms don't show which action is running.** Document pages render a `CircularProgress` and track `savingAction` so only the clicked button spins (`OfferDetail.tsx:764`); most other forms just swap to text (`ProjectDetail.tsx:346`). On `WarehouseIssueForm` both save buttons swap to identical "Ukládání..." and disable (`:455-465`) — you can't tell which is in flight. **Fix:** adopt one affordance app-wide (spinner+disabled with per-action targeting); spin only the clicked warehouse action.
- **Note typed in the create-project modal is saved to the legacy column and shown as non-editable.** The "Přidat projekt" modal's Poznámka writes `projects.notes` (`Projects.tsx:164`, `projects.service.ts:222`), but `ProjectDetail` renders that column read-only under "Starší poznámka (před zavedením systému)" (`:477,491`). So a note typed at creation is instantly mislabeled as legacy and uneditable, while the real editable `project_notes` system lives only on the detail page. **Fix:** drop the Poznámka field from the create modal, or route the create-time note through `createProjectNote`.
### 4.6 Loading, empty & error states
- **Failed list fetches silently render the empty state.** `usePaginatedQuery` exposes `isError`/`error` (`:47-48`) but no list page reads them, and `queryClient.ts` has no `QueryCache.onError`. So a 500/network drop falls through to the `DataTable` empty slot — Projects shows "Zatím nejsou žádné projekty" (`:480`). Detail pages handle errors loudly; `WarehouseReports.tsx:350` shows an inline `<Alert severity=error>`. **Fix:** add a `QueryCache.onError` toast and/or render an inline error distinct from empty when `isError`, with retry wired to `refetch`.
- **Empty states built two ways, and clickable-CTA vs text-only split.** Shared `<EmptyState>` is used by document/warehouse lists, but Projects/Vehicles/Users hand-roll `Box(textAlign,py:6)+Typography+Button` (`Projects.tsx:471/480`, `Vehicles.tsx:323`, `Users.tsx:350`), reinventing the `action` prop. Offers renders a clickable "Vytvořit první nabídku" (`:835`) while Invoices/ReceivedInvoices only name the button in description text (`Invoices.tsx:770`). **Fix:** migrate hand-rolled empties to `<EmptyState title description action>` and standardize the clickable-create-CTA convention.
- **Empty-state microcopy is inconsistent** (fragments vs sentences, trailing-period split, three filtered-empty phrasings, 2nd person). Fragments with no period (`AuditLog.tsx:368`, `WarehouseReports.tsx:223,364`) vs full sentences (`AttendanceHistory.tsx:653`). Filtered-empty worded three ways: "neodpovídají filtru" (`Offers.tsx:827`) / "neodpovídají filtru." (`Projects.tsx:473`) / "neodpovídají hledání." (`ReceivedInvoices.tsx:859`). Nothing-yet mixes "Zatím nejsou žádné X." with 2nd-person "Zatím nemáte žádné žádosti" (`LeaveRequests.tsx:253`). **Fix:** adopt one canonical filtered-empty string, one unfiltered-empty string, one trailing-period rule.
- **Changing a month/filter gives three different loading behaviors.** Document/warehouse lists dim the card via `isFetching` opacity (`Offers.tsx:815`). `TripsHistory`/`TripsAdmin` keep stale data with NO feedback (`:304-306`/`:810-812`). `AttendanceHistory` uses plain `useQuery` with no `placeholderData`, so a month change flips `isPending` and swaps the whole table for a centered spinner (`:523,646`). **Fix:** standardize on the `isFetching` card-dim; give Trips screens the dim and `AttendanceHistory` `keepPreviousData`.
- **No real skeletons; 40 list pages blank the whole page to a centered spinner (layout shift); "skeleton" identifiers are misnamed.** The only first-paint affordance is `LoadingState` (centered spinner, `LoadingState.tsx:10-22`); 40 pages early-return it before rendering chrome (`Projects.tsx:267`, `Offers.tsx:492`), so title/tabs/filter bar vanish then snap in. There are zero MUI `<Skeleton>`s despite identifiers like `showListSkeleton` (`ReceivedInvoices.tsx:357`). **Fix:** render `PageHeader`+`FilterBar` immediately and confine the spinner (or a real DataTable skeleton — the `skeleton-boha` skill exists) to the table area; at minimum rename the misleading identifiers.
- **Redundant sequential/stacked spinners.** First visit to a lazy section shows the Suspense fallback spinner (chunk download, `AdminApp.tsx:112`), then the page's own `isPending` shows a SECOND centered spinner. Separately `ReceivedInvoices` never early-returns: `renderKpi` shows `<LoadingState/>` (`:752`) AND the list shows one (`:846`) — two stacked spinners on cold load, where sibling `Invoices` shows one. **Fix:** render a single unified loading state per page (have lazy pages supply their own shell as the Suspense fallback).
- **Dashboard hand-rolls its loading spinner and mixes empty-state styles.** `Dashboard.tsx:313-316` and `DashSessions.tsx:161-164` inline `Box+CircularProgress` (different padding/size, no aria-label) vs the shared `<LoadingState/>`. `DashTodayPlan`/`DashSessions` hand-roll Typography empties while `DashActivityFeed`/`DashAttendanceToday` use `<EmptyState>`. On the most-visited page. **Fix:** use `<LoadingState/>` and standardize card empties on `<EmptyState>`.
- **Profile, 2FA and active sessions are gated behind the heavy dashboard aggregate query.** `DashProfile` and `DashSessions` render only inside `{!dashLoading && (...)}` (`Dashboard.tsx:505`), where `dashLoading` is the multi-domain aggregate (attendance+offers+invoices+orders+projects+leave). Neither block depends on that data — `DashProfile`'s 2FA comes from `totpStatusOptions`, `DashSessions` has its own query (`:74`). **Fix:** render `DashProfile`/`DashSessions` independently; gate only the data-dependent cards.
- **Autocomplete pickers show English "No options."** `MuiProvider` sets only the date-picker locale, no global `csCZ` `localeText` (`:15-22`); `CustomerPicker.tsx:49`/`SupplierPicker.tsx:49` omit `noOptionsText`, so empty search shows MUI's built-in English. `ItemPicker.tsx:61` correctly sets "Žádné položky." **Fix:** add MUI `csCZ` component `localeText` (fixes every Autocomplete/pagination at once) or at minimum `noOptionsText` on the two pickers.
### 4.7 List-page consistency
- **Invoices/ReceivedInvoices/AuditLog search fires a query per keystroke (no debounce).** Offers/IssuedOrders/ReceivedOrders/Projects/Warehouse use `useDebounce(value,300)`, but `Invoices.tsx:282`, `ReceivedInvoices.tsx:250` and `AuditLog.tsx:202` feed the raw value into the query key — laggier and hammering the API on the busiest financial screens. **Fix:** wrap in `useDebounce` (or a shared `SearchField`).
- **DataTable `onRowClick` rows aren't keyboard-operable; row-click only exists on warehouse lists.** Warehouse Items/Issues/Inventory/Receipts open via clicking the row (their _only_ entry), but Offers/Invoices/Orders/Projects/Users/Vehicles don't set `onRowClick`. Worse, the clickable `TableRow` has no `tabIndex`/`role`/`onKeyDown` (`DataTable.tsx:285-303,137-161`), so a keyboard user **cannot** open a warehouse record. `OdinSidebar` already has the correct pattern (`:126-140`). **Fix:** add `role="button"`, `tabIndex=0`, Enter/Space handling when `onRowClick` is set; then pick one row convention.
- **Column sorting absent on warehouse-document, audit-log and trips tables that look sortable.** `DataTable` renders sort headers only when columns carry `sortKey` and the page wires `onSort`. Offers/Invoices/Orders/Projects/WarehouseItems do; `WarehouseIssues.tsx:258`, `AuditLog.tsx:364`, `TripsAdmin.tsx:815` and WarehouseReceipts don't — you can't reorder warehouse docs by date/status nor the audit log by user, with no cue distinguishing a sortable header from a dead one. **Fix:** wire `useTableSort`+`sortKey`+`onSort` into the large paginated tables.
- **Sibling Order tabs disagree on the "all" status label.** `IssuedOrders` uses "Všechny" (`:60`), `ReceivedOrders` "Všechny stavy" (`:54`) under the same parent Orders tabs. **Fix:** unify the label (don't force the Tabs-vs-Select control change — IssuedOrders deliberately keeps a Select).
- **Filter baseline diverges: search/date/reset missing where needed.** WarehouseIssues/Receipts have search+status+date-range+reset; `WarehouseInventory.tsx:149` has only a status dropdown (no search despite `session_number`); `WarehouseReservations.tsx:354` has no text search or date filter; `AuditLog.tsx:321` (5 filters) and `TripsAdmin.tsx:734` (3 filters) have **no reset**. **Fix:** standardize a list-filter baseline; add the missing search/date/reset.
- **Offers/IssuedOrders filter by customer/supplier but the Invoices list cannot.** Offers has a customer Select (`:797-812`), IssuedOrders a supplier Select (`:400-415`), but the equally customer-centric Invoices list offers only free-text search (`:739-751`). **Fix:** add a customer Select (load via `offerCustomersOptions`, wire `customer_id` into list+totals; may need a small backend add).
- **Count subtitle missing on Projects/Users/Vehicles/Customers and the Přijaté tab; count copy splits `czechPlural` vs hand-rolled ternary.** Projects/Users/Vehicles show a bare title (`Projects.tsx:427`, `Users.tsx:338`, `Vehicles.tsx:311`), Customers a static description (`:492`), and Vydané shows a count line but Přijaté doesn't. The plural is hand-rolled five times (`WarehouseIssues.tsx:112-114`) where `czechPlural()` exists (`formatters.ts:69`). **Fix:** add a result-count subtitle via `PageHeader`; replace the hand-rolled ternaries with `czechPlural()`.
- **Many pages hand-roll the title bar instead of `PageHeader`, so multi-button headers wrap raggedly on mobile.** `PageHeader`'s `headerActionsSx` makes header buttons stack full-width on phones (`PageHeader.tsx:19-30`), but ~20 pages hand-roll `Box+Typography h4 + raw action Box` (Projects, Users, Vehicles, TripsAdmin, Attendance, AttendanceAdmin, Settings, CompanySettings). The multi-button ones (AttendanceAdmin Tisk/Vyplnit/Přidat `:179-198`; TripsAdmin Tisk/Vozidla `:699-731`) wrap into a ragged half-width grid on phones. **Fix:** route through `<PageHeader>` or wrap the action Box in `headerActionsSx`.
- **Three forked invoice PDF openers → inconsistent feedback and different error copy.** Shared `useDocumentListPdf`/`useDocumentPdf` pre-opens the tab, shows a per-row spinner, guards double-clicks, handles 401 and revokes the blob URL (`useDocumentPdf.ts:37-86`); Offers/IssuedOrders use it. But Invoices reimplements it inline (`:361`), ReceivedInvoices reimplements with **no** loading state (`:561`), and InvoiceDetail forks its own (`:1175`). Same failure yields three different sentences. **Fix:** route all three through the shared hook (make the error string a parameter — received-invoice `openFile` streams a stored upload, not a generated PDF).
- **Invoices LIST shows a PDF button on draft invoices, dead-ending in a 404.** Offers/IssuedOrders hide the PDF icon for drafts and InvoiceDetail's own header hides it on drafts (`:1890`), but the Invoices list renders it for every invoice gated only on permission (`:616-626`). **Fix:** gate on `inv.invoice_number` too, matching `Offers.tsx:660`/`IssuedOrders.tsx:303`.
- **Customers and Suppliers directories use opposite data/search/pagination models.** Built as mirrors, but `OffersCustomers` fetches the entire list once and filters client-side with no debounce/pagination (`:159,380`), while `WarehouseSuppliers` is server-paginated, debounced, page-sized with real Pagination (`:139,449`). **Fix:** align Customers on the Suppliers model (server pagination/search), preserving instant responsiveness via `keepPreviousData`/quick debounce.
### 4.8 Navigation & information architecture
- **Received-order detail returns you to the wrong Orders tab.** Received orders live under Orders→"Přijaté", but `OrderDetail`'s Zpět/post-delete/fetch-error redirects all go to `/orders` with no tab, and Orders defaults to "vydane" (`Orders.tsx:78-79`) — so you land on the issued-orders list (a different doc type). IssuedOrderDetail does it right with `/orders?tab=vydane`. **Fix:** point `OrderDetail`'s links (`:116,280,399`) at `/orders?tab=prijate`.
- **The "Zpět" back control is built four different ways.** No shared back primitive: document detail puts a labeled outlined Zpět on the LEFT (`OfferDetail.tsx:643`); warehouse detail puts it on the RIGHT inside PageHeader (`WarehouseItemDetail.tsx:372`); warehouse forms use an icon-only IconButton on the left (`WarehouseReceiptForm.tsx:423`); `AttendanceCreate` uses a text "← Zpět na správu" on the right (`:173`). All hardcode the list route. **Fix:** add an optional `back={{to,label}}`/`onBack` prop to `PageHeader` rendering one canonical left-aligned icon+label control.
- **No per-page browser title — every tab/bookmark/history entry reads "Admin."** `index.html:22` hardcodes `<title>Admin</title>` and nothing updates it (zero `document.title` refs). With multiple tabs open (common here), the tab strip/history/bookmarks all read "Admin." **Fix:** add a `<TitleSync>` setting `document.title` per route from `navData.tsx` labels (`+ " · BOHA"`).
- **No route breadcrumbs on deep detail/form pages.** Deep destinations (`/warehouse/items/:id`, `/warehouse/receipts/:id/edit`, `/orders/issued/:id`) give only one back link to a single hardcoded list, with no "Sklad Položky <item>" trail. Combined with the missing title, deep-page orientation is thin. **Fix:** add a lightweight section list record breadcrumb derived from route + `navData` metadata.
- **`settings.templates` gates the Nastavení nav item but Settings bounces such users to the dashboard.** The gear shows if the user holds any of five permissions including `settings.templates`, but `canAccessSettings` checks only roles/company/system/banking and `Navigate`-to-`/` otherwise (`Settings.tsx:199-200,421-423`). So a templates-only user sees the gear, clicks, and is teleported away. Meanwhile the offer-templates editor (`/offers/templates`, `OffersTemplates.tsx:137`) has **no** nav entry — only a "Šablony" button buried in the Offers header (`Offers.tsx:750`). **Fix:** drop `settings.templates` from the Nastavení gate (`navData.tsx:486-492`) and give the templates editor a discoverable home.
- **Three different "access denied" experiences.** ~40 pages render the polished `<Forbidden/>` (403 + explanation + "Zpět na Dashboard"); Settings silently `Navigate`-to-`/` (`:421-423`); Odin shows a bare unstyled line of text (`Odin.tsx:9-17`). **Fix:** standardize on `<Forbidden/>` everywhere.
- **"Zákazníci" master data sits under the misleading URL `/offers/customers`.** Customers are a top-level entity referenced by offers, orders and invoices, yet the route implies a sub-resource of offers — visible in code as the `matchExclude:['/offers/customers','/offers/templates']` hack the Nabídky nav item needs (`navData.tsx:250`). **Fix:** move customers to a top-level `/customers` (redirect from the old URL), removing the hack.
- **Master-data lists are scattered across four nav sections with no cross-links.** Zákazníci under Administrativa, Dodavatelé under Sklad, Vozidla under Kniha jízd, Uživatelé under Systém (`navData.tsx:226,297,448,470`). Salient consequence: issued orders (Administrativa) pull suppliers from `sklad_suppliers` (Sklad), so fixing a supplier means jumping to Sklad. **Fix:** at minimum add cross-links (e.g. "spravovat dodavatele" from the issued-order supplier picker → `/warehouse/suppliers`).
- **Sklad nav is a flat 10-item list mixing daily ops with one-time config.** Ten ungrouped items (`navData.tsx:314-463`) intermix `warehouse.operate` daily drivers with rarely-touched `warehouse.manage` config (Kategorie/Lokace/Dodavatelé). **Fix:** split into operations vs "nastavení skladu" subgroups (or move the three config screens behind one entry).
- **One "Vytvořit objednávku" button navigates to a page on one tab and opens a modal on the other.** On Objednávky: Vydané routes to `/orders/issued/new`, Přijaté opens a modal (`Orders.tsx:119-132`, `ReceivedOrders.tsx:606`). Across modules, offers/invoices/issued orders use routed full-page forms while received orders create in a modal. **Fix:** pick one pattern per tier or split into two distinct labelled actions.
- **No skip-to-content link — keyboard users tab the whole sidebar on every page.** A permanent 248px sidebar with many links renders before `<main>` (`AppShell.tsx:130-145,210-223`), with no skip link and no id on `<main>`. **Fix:** add a visually-hidden "Přeskočit na obsah" link (visible on focus) targeting `id="main-content"`.
### 4.9 Discoverability & workflow friction
- **"Moje žádosti" has no create action.** Its header has no actions and its empty state literally instructs filing on the Docházka page (`LeaveRequests.tsx:241,254`); the only way to open the leave form is a button buried on the punch screen (`Attendance.tsx:936,978`). **Fix:** add a "Nová žádost" button to `LeaveRequests` opening the same modal; lift the modal into a shared component.
- **Dashboard "Vaše dnešní zařazení" card shows the project as plain text — every other dash card links onward.** `DashTodayPlan` renders `projectLabel` as plain text (`:69`) while other cards link "Vše →"/"Detail →." **Fix:** wrap `projectLabel` in a `RouterLink` to `/projects/:id` (id is already in `TodayPlan`); optionally add a "Plán prací →" header button.
- **Issue detail shows the consumed reservation as a raw "ID: n" database identifier** (`WarehouseIssueDetail.tsx:204`), unlike the project reference beside it (a real link). **Fix:** at minimum render "R{id}" to match `ReservationPicker`'s own label (`:65`).
- **`ReservationPicker` is dead code — a manual Výdejka can't draw down an existing reservation.** A ready-made picker exists (`ReservationPicker.tsx:14`) but is imported nowhere; the issue form only sets `reservation_id` from router prefill (`WarehouseIssueForm.tsx:183`). **Fix:** render it in the issue-form line (gated on item_id+project_id, mirroring `BatchSelect`) — or delete the unused component.
- **Audit log surfaces only the one-line description — the stored old/new values are on the wire but never shown.** `audit_logs` stores `old_values`/`new_values` and the route returns full rows (`routes/admin/audit-log.ts:64-74`), but the page's row type omits them and rows aren't expandable (`AuditLog.tsx:97-105,229-275`). The most valuable part of an audit trail is invisible. **Fix:** add the fields to the type and open a detail modal rendering old→new.
- **Odin's empty state never hints it can query real system data.** The landing hero only says "Zeptejte se na cokoli, nebo přiložte fakturu…" (`OdinThread.tsx:82-149`), giving no hint Odin can query invoices/offers/orders/projects/warehouse via its read-only tools. **Fix:** add 3-4 clickable example-prompt chips that prefill the composer (`OdinChat.tsx:218-219`).
- **Keyboard shortcuts ship undiscoverable; `ShortcutsHelp` is a permanent no-op.** `ShortcutsHelp` is mounted but returns null (`:1-7`, `AppShell.tsx:226`), while Ctrl+Enter (note save), Enter (Odin send), Enter (plan category) ship with no help. **Fix:** build the "?"-opens-cheat-sheet overlay, or as a cheap first step add `title` hints (copy `OdinComposer`'s `title="Odeslat (Enter)"` onto `NoteCard`'s Ctrl+Enter).
- **Plan grid resets to the current week on every remount.** `usePlanWork` inits view/anchor to `new Date()` with no persistence (`:112-113`), so leaving and returning to `/plan` loses your scroll position. **Fix:** persist view+anchor (URL query or localStorage).
- **"Zpět na správu" / post-save links pass `?month=YYYY-MM` but AttendanceAdmin ignores it.** `AttendanceCreate` (`:141`) and `AttendanceLocation` (`:211`) link to `/attendance/admin?month=…`, but `useAttendanceAdmin` never reads the URL (hard-inits month to current). **Fix:** seed the initial month from `useSearchParams()` and keep it synced.
- **Receipt/Issue details never show a document-level total.** They compute per-line "Celkem" but the `DataTable` has no footer/sum (`WarehouseReceiptDetail.tsx:250`, `WarehouseIssueDetail.tsx:182`); forms show no running total. Every other money-bearing document leads with a prominent total. **Fix:** add a summary line (sum of qty×unit_price via `formatCurrency`) under the Položky table and a live running total in the forms.
- **Inventory form only creates a draft; Receipt/Issue forms offer "Uložit a potvrdit" in one action.** `WarehouseInventoryForm` offers only "Vytvořit inventuru" (`:204`); to post you must go to the detail and click "Potvrdit inventuru" (`:209`). **Fix:** add a "Vytvořit a potvrdit" button (the form already collects `actual_qty`), OR surface a hint explaining the deliberate two-step variance-review flow.
- **Trips admin can't enter a trip on behalf of a driver, though attendance admin can create for any employee.** `TripsAdmin` offers only Tisk + a Vozidla link (`:710`), no "add trip," and its edit modal has no driver picker. The `/trips` POST already accepts `body.user_id` (`trips.ts:341`). **Fix:** add an "Přidat jízdu" action with a driver selector, mirroring `AttendanceAdmin`.
- **⚠ Found during verification (backend, not UX): `POST /trips` accepts `body.user_id` without a `trips.manage` check.** Any user holding only `trips.record` can create a trip attributed to another user's id (`src/routes/admin/trips.ts:341`, `trips.schema.ts:13`) — reads are correctly scoped (`buildTripsWhere` limits non-managers to their own trips) but the create path is not. Minor impersonation-on-create authz gap. **Fix:** ignore `body.user_id` (force `authData.userId`) unless the caller holds `trips.manage`.
- **Personal Trips "Záznam" shows company-wide trips/stats for managers.** Unlike "Moje historie" (filters by `userId`), the personal Trips page calls `tripListOptions`/`tripStatsOptions` with no `userId` and renders a "Řidič" column (`Trips.tsx:166,359`) — surfacing all drivers' trips and company-wide totals, unlike the strictly-personal attendance punch screen. **Fix:** pass `userId` (matching `TripsHistory`), or relabel/drop the Řidič column. Keep list and stats on one scope.
### 4.10 Forked/duplicated shared modules & code drift
- **`InvoiceDetail` forks the shared document editor (and drops the item-description maxLength cap).** Offers/issued orders compose shared `DocumentItemsEditor`/`useDocumentPdf`/`useUnsavedChangesGuard`; `InvoiceDetail` reimplements all three (`:241-541,1175-1196,942-958`). Divergence: the invoice item description textarea has **no maxLength** while the shared editor caps it (`DocumentItemsEditor.tsx:390`) — an over-long description 500s at Prisma instead of capping client-side. **Fix:** add a `showVat`/`vatOptions` flag to `DocumentItemsEditor` (respecting "VAT only on invoices") and migrate `InvoiceDetail` onto it plus the shared hooks.
- **Status label/color maps duplicated across warehouse, projects and leave.** `documentStatus.ts` exists specifically to kill per-page maps, yet DRAFT/CONFIRMED/CANCELLED recur across 6 warehouse files (`WarehouseIssues.tsx:35-48`), `Projects.tsx:44-57`/`ProjectDetail.tsx:37-50` are byte-identical, and `LeaveRequests.tsx:25-55`/`LeaveApproval.tsx:36-66` are byte-identical. **Fix:** add `WAREHOUSE_DOC_STATUS`, `MOVEMENT_TYPE`, `RESERVATION_STATUS`, `PROJECT_STATUS`, `LEAVE_STATUS`/`LEAVE_TYPE` to `documentStatus.ts` and import them.
- **`AttendanceAdmin` fetches outside React Query (raw `apiFetch` in `useEffect` + manual refetch dance).** `useAttendanceAdmin` loads projects/users/records via raw `apiFetch` with its own `setData`/`setLoading` (`:823,871`), so it doesn't participate in `['attendance']` invalidations — each mutation must invalidate AND manually re-run `fetchData` with a `setTimeout(300)` (`:1057-1063`). The single biggest structural inconsistency and a latent stale-data risk. **Fix:** migrate reads to React Query options.
- **Orphaned standalone attendance-create page diverges from the admin modal (no project logs).** Adding a record exists twice: a standalone full-page form at `/attendance/create` AND `ShiftFormModal` (`AttendanceAdmin.tsx:194`). The modal supports per-shift project-time logs; the standalone page can't (`AttendanceCreate.tsx:63-147`), and nothing navigates to it. **Fix:** delete `AttendanceCreate.tsx` and its route — the modal is the live superset.
- **"Vytvořit objednávku" modal uses two different file pickers.** The Offers-list version uses the shared `FileUpload`; the Offer-detail version reimplements it with a raw `<input type=file>` + manual readout + custom remove button (`OfferDetail.tsx:1066-1112` vs `Offers.tsx:894-929`). Same dialog, two attachment UIs. **Fix:** replace the raw input with `FileUpload` (gains drag-drop/validation).
- **Warehouse entities use three different create/edit interaction models.** Items edit inline on the detail page via a toggle (`WarehouseItemDetail.tsx:112`); Receipts/Issues use a full-page Form + read-only Detail; Inventory has a Form for creation only; Reservations/Categories/Locations/Suppliers use a list-page Modal. Items (a flat record like Suppliers) using inline edit is the main outlier. **Fix:** document one convention (line-item records = Form+Detail; flat lookups = list modal) and reconcile the Items outlier — treat as a documented rule, not a forced risky migration.
- **Inconsistent "record not found": Item detail redirects away, siblings keep a stable URL.** `WarehouseItemDetail` toasts + `navigate('/warehouse/items')` (`:153-158`); `WarehouseIssueDetail`/`ReceiptDetail`/`InventoryDetail` render an in-place "nebyla nalezena" EmptyState with a Back button and keep the URL (`:122`/`:200`/`:99`). **Fix:** align Item detail to the in-place EmptyState pattern.
- **Settings save model is inconsistent.** The System tab has **two** "Uložit" buttons doing the same thing (`Settings.tsx:997-1007,1193-1199`); other cards have no save; 2FA saves instantly; on Firma, bank accounts/logo apply instantly while company info/numbering/currency need the bottom save (`CompanySettings.tsx:990-1013,1457-1461`). The user can't predict whether a change is live or pending. **Fix:** drop one duplicate System Save; visually distinguish auto-apply vs pending controls.
- **Attendance location page formats datetime with `new Date()` instead of the timezone-safe shared helper.** `AttendanceLocation` defines a local `formatDatetimeLocal` parsing with `new Date()` (`:176`); the rest of attendance uses the timezone-safe `formatDatetime` (`attendanceHelpers.ts:42`), so the same timestamp renders differently. **Fix:** build a regex-based timezone-safe formatter that keeps the year (the location detail legitimately wants the year, which `formatDatetime` omits).
- **Document editor uses two reorder gestures side by side.** Line items reorder via a drag handle (`DocumentItemsEditor.tsx:350-364`), but sections (`SectionsEditor.tsx:200-219`) and company custom fields (`CompanySettings.tsx:1049-1067`) use up/down arrows — on the same page. The drag-only items are also less keyboard-discoverable. **Fix:** add up/down arrow buttons alongside the line-item drag handle (matching SectionsEditor) — also fixes the keyboard-affordance gap.
- **Login re-implements the theme toggle.** The shell uses shared `<ThemeToggle/>` (animated crossfade, title + aria-label); Login builds its own IconButton with hand-duplicated SVGs, only a title, no aria-label, no animation (`Login.tsx:264-313` vs `ThemeToggle.tsx:36-57`). The first screen every user sees is less accessible. **Fix:** reuse the shared `<ThemeToggle/>`.
### 4.11 Visual & theming consistency
- **Projects status-chip colors diverge from the app-wide convention (and duplicate the label map).** `documentStatus.ts` establishes success=done/paid, error=cancelled, info=open. Projects breaks all three — ACTIVE=green, COMPLETED=**blue**, CANCELLED=**grey** (`Projects.tsx:50`, `ProjectDetail.tsx:43`). So a finished project shows blue while a finished order shows green. **Fix:** add a `PROJECT_STATUS` map (aktivni→info, dokonceny→success, zruseny→error) and consume via `statusLabel`/`statusColor`.
- **Linked order status on Project detail renders as a raw untranslated token.** `ProjectDetail` shows `STATUS_LABELS[project.order_status]` but `STATUS_LABELS` is the _project_ map while `order_status` is `prijata`/`v_realizaci`/… — zero key overlap (`:577`), so a completed order shows "(dokoncena)" lowercase plain text. **Fix:** render via `statusLabel(ORDER_STATUS, project.order_status)` (ideally a `StatusChip`).
- **Settings admin-role callout uses a solid `info.light`/`info.dark` fill (the forbidden .light-fill anti-pattern).** The "Administrátor má vždy plný přístup" box is hand-styled with `bgcolor:'info.light'`/`color:'info.dark'` (`Settings.tsx:1228-1229`) — the single solid palette-.light fill in the admin, violating the documented channel-alpha rule; in dark mode it's garish low-contrast blue-on-blue. **Fix:** use the kit `<Alert severity='info'>` (`Alert.tsx:8`), or a channel-alpha wash.
- **Dashboard "Vystavit fakturu" quick action reuses the destructive error/red palette.** `DashQuickActions` sets color `'danger'` → MUI error (`:296`,`:59`), the same red as every Delete button, so "Issue invoice" reads as destructive beside green/blue/orange create actions. _(Caveat: red is also the invoice PDF brand family (#de3a3a), and "Odchod" uses `danger` too — this may be deliberate branding; treat as optional.)_ **Fix:** if not intentional, change to a non-destructive color (info/primary); reserve error/red for deletes.
- **Plan `DayInRangeModal` shows raw ISO dates while the rest of the plan dialogs format cs-CZ.** It prints "2026-06-28 2026-07-02" (`PlanCellModal.tsx:466,474,495`) while ViewModal/DayPanel format "28. 6. 2026." **Fix:** wrap the dates in `formatDate()`.
- **Project delete confirm differs between list and detail.** The list ConfirmDialog always renders the "Smazat i soubory na disku" checkbox even for projects with no NAS folder, with terse copy, no name, no irreversibility warning (`Projects.tsx:508,516`); the detail gates the checkbox on `has_nas_folder` and uses richer copy + "Tato akce je nevratná." (`ProjectDetail.tsx:621,626`). **Fix:** gate the list checkbox on `has_nas_folder` and use the same copy (name + warning).
### 4.12 Microcopy & localization
- **Goods-receipt module named four ways; "Příjmy" also reads as financial income.** Nav "Příjmy" (`navData.tsx:348`), overview button "Nový příjem" (`Warehouse.tsx:203`), list title "Příjmové doklady" + "Nový doklad" (`WarehouseReceipts.tsx:170,178`), detail/audit "Příjemka" (`WarehouseReceiptDetail.tsx:204`, `entityTypeLabels.ts:29`). Issues mirror this (Výdeje/Výdejky/Výdejka). "Příjmy" is the standard Czech word for _income_, so the nav label is actively misleading. **Fix:** settle on "Příjemka"/"Výdejka" everywhere.
- **Saving/busy labels inconsistent; one "Chyba pripojeni" diacritics typo; Modal overrides caller `submitText` mid-save.** Competing forms: "Ukládám…" (Modal, AttendanceCreate, NoteCard, DashProfile), "Ukládání..." (most pages), "Zpracovávám…" vs "Zpracovávám...", plus "Vytváření..."/"Odesílám..."; ellipsis sometimes "…" sometimes three dots. The connection-error fallback is "Chyba pripojeni" (no diacritics) on the dashboard (`Dashboard.tsx:127`, `AuthContext.tsx:256,314`) vs "Chyba připojení" elsewhere. Modal hardcodes `loading?'Ukládám…':submitText` (`:96`), overriding "Vytvořit uživatele" mid-save. **Fix:** fix the typo (outlier vs ~90 sites); standardize the busy label + Unicode ellipsis; the Modal hardcoding is acceptable for save forms (lower priority).
- **"Cancelled" order status spelled two ways in `documentStatus.ts`.** Received orders use `stornovana`→"Stornována" (`:49`); issued orders use `cancelled`→"Stornovaná" (`:58`) — side-by-side under the same `/orders` page. These are display labels (safe to change). **Fix:** pick one Czech form for both.
- **Add-line button uses literal "+ Přidat položku" in document editors vs icon + "Přidat položku" in warehouse forms.** `DocumentItemsEditor.tsx:596` (literal plus, no startIcon) vs warehouse forms with `startIcon={PlusIcon}` (`WarehouseIssueForm.tsx:601` etc.). **Fix:** standardize the affordance and label.
- **Create/edit modal submit labels mix entity-specific, bare, and default wording.** "Vytvořit zákazníka"/"Uložit změny" (`OffersCustomers.tsx:543`), "Vytvořit uživatele" (`Users.tsx:369`), bare "Vytvořit"/"Uložit" (`OffersTemplates.tsx:361`), and Vehicles falls back to the Modal default "Uložit" (`Modal.tsx:36`). **Fix:** adopt one rule (create="Vytvořit", edit="Uložit změny") and set it as the Modal default.
- **Create-action verb differs across analogous list pages; both Orders tabs share the identical "Vytvořit objednávku" label.** Imperative "Přidat X" (master data) vs "Nový/Nová X" (documents) vs "Vytvořit objednávku" (Orders, identical on both tabs for two different doc types, `Orders.tsx:125,129`); the empty-state CTA verb mirrors and compounds this. **Fix:** disambiguate the two Orders tabs first ("Nová vydaná/přijatá objednávka"), then pick one verb convention and align the empty-state CTA.
- **Several numbers/prices bypass cs-CZ formatting; file-size unit casing disagrees (kB vs KB).** Offers item-template price uses `Number().toFixed(2)`→"1234.50" (period, no separator/currency, `OffersTemplates.tsx:287`); warehouse batch picker builds "…toFixed(2) Kč" (`WarehouseIssueForm.tsx:160`); vacation/sick hour balances use `.toFixed(1)`→"12.5" (`AttendanceBalances.tsx:307`); zeros render "0 Kč" vs "0,00 Kč." File-size helper duplicated with "kB" (`FileUpload.tsx:9`) vs "KB" (`OfferDetail.tsx:1072`). **Fix:** route money through `formatCurrency` and decimals through cs-CZ/Intl; extract one `formatFileSize()` with one unit casing.
- **Leave-request modal day/hour estimate ignores public holidays.** The modal previews "X pracovních dnů" using `calculateBusinessDays` (MonFri only, `Attendance.tsx:498,1242`), but the rest of the system is holiday-aware (server `leave-requests.ts:99` excludes weekends AND holidays). A vacation spanning e.g. 1 May over-counts vs the stored `total_days`/`total_hours`. **Fix:** make the modal estimate holiday-aware (reuse the holiday helper).
### 4.13 Mobile / responsive
- **Status-filter tab bars clip on phones — shared `Tabs` has no scrollable variant.** `Tabs.tsx:28-49` uses MUI's default `variant="standard"` (overflow hidden, no scroll). At ~360px the 5 offer/invoice tabs and 4 project tabs overflow the centered Box and get clipped with no scroll affordance — effectively unreachable. **Fix:** set `variant="scrollable"`, `scrollButtons="auto"`, `allowScrollButtonsMobile` — one change fixes every status filter and the issued/received toggle.
- **Shared Modal never goes full-screen on phones; leave date grid is hardcoded two-column.** `Modal` renders a plain `fullWidth+maxWidth` Dialog with no responsive `fullScreen` (`:67-75`), so the ~9-field Trip form, the admin shift form and the leave form become a narrow scrolling box at 360px. The leave modal's Od/Do grid is hardcoded `minmax(0,1fr) minmax(0,1fr)` (`Attendance.tsx:1195-1199`) while every other date-pair grid is responsive `{xs:'1fr',sm:'1fr 1fr'}` (`ShiftFormModal.tsx:254`, `Trips.tsx:528-535`). **Fix:** add `fullScreen={useMediaQuery(down('sm'))}` to Modal; fix the leave grid.
- **Long full-page editors keep Save only in the top header (no sticky save); placement differs page-to-page.** Document detail editors place all Save/Create/Activate in the top `headerActionsSx` slot (`OfferDetail.tsx:688-828`), so after scrolling through header fields/picker/line-items/rich-text/notes a phone user must scroll back to the top to save. `AttendanceCreate` pins Zrušit/Uložit at the **bottom** (`:362-374`); modals pin submit in DialogActions — three placements. **Fix:** add a sticky action bar to long editors and standardize placement.
- **Warehouse issue/receipt line items lack the labeled card-per-row mobile layout the document editor has.** `DocumentItemsEditor` has a dedicated `isMobile` branch — a bordered card per row with "Položka N" index, labeled fields, per-row total, drag/remove (`:170-342`). `WarehouseIssueForm.tsx:513-567` just collapses a grid to `xs:'1fr'` with placeholder-only fields, no border/labels/separator — once a value is typed the placeholder vanishes and rows blur together. Worst exactly where shop-floor mobile use is likely. **Fix:** reuse the card-per-row pattern (ideally a shared mobile line-item card).
- **FilterBar children mix fixed-vs-grow flex bases, leaving dead space on mobile.** `AttendanceAdmin.tsx:202,205` use `flex:'0 0 180px'`/`'0 0 220px'` and `AttendanceHistory.tsx:516` uses `flex:'0 0 180px'` (fixed, never grow), so on a phone they sit narrow with dead space beside them, while `Offers.tsx:786` uses `flex:'1 1 320px'` which fills the row. **Fix:** adopt one convention (e.g. `flex:'1 1 <min>px'`) or have `FilterBar` stretch children full-width below `sm`.
### 4.14 Accessibility
- **Desktop document line-item grid inputs are unlabeled while the mobile card layout labels every field.** The mobile branch passes explicit labels (Popis, Množství, Jednotka, Jedn. cena, Sleva %); the desktop table renders the same TextFields with **no** label/aria-label, relying on visual `<th>` headers not programmatically associated with the inputs; the desktop "V ceně" Checkbox is unnamed (`DocumentItemsEditor.tsx:374-440,445-450`). A screen-reader user editing an offer on desktop hears a column of unlabeled "edit text" boxes. **Fix:** add an `aria-label` to each desktop cell control (e.g. `Množství, položka ${index+1}`).
- **List-page search boxes are placeholder-only inputs with no accessible name.** Raw TextFields with only a placeholder (not an accessible name) across ~13 pages (`Offers.tsx:787`, `Invoices.tsx:741`, `WarehouseItems.tsx:180`, `Projects.tsx:454`, `IssuedOrders.tsx:386`). **Fix:** add `aria-label="Hledat"` + `type="search"` — best via a shared `SearchField` (also adds the native clear button and mobile search keyboard).
- **(Cross-listed) DataTable clickable rows aren't keyboard-operable; no skip-to-content link.** See §4.7 (row semantics) and §4.8 (skip link).
### 4.15 Performance & bundle
- **MUI + Emotion live in the 781 kB entry chunk, so every deploy busts the cached UI vendor code.** `vite.config.ts` `manualChunks` only carves out react/react-dom/react-router (436 kB) and framer-motion (147 kB); the entire MUI library + Emotion fall into the entry chunk (781 kB raw / 232 kB gzip). With frequent same-day patch releases, daily users re-download ~232 kB gzip of unchanged MUI every deploy. **Fix:** split `@mui/*`+`@emotion/*`(+quill) into a long-lived `vendor-mui` chunk.
- **framer-motion (147 kB) is a static dependency of the eager AppShell, loading on first paint incl. Login.** `AppShell`/`PageEnter`/`ThemeToggle` statically import framer-motion (`PageHeader` deliberately does not); `AppShell` is eagerly imported by `AdminApp` (`:8`, `AppShell.tsx:101`). Login pays for it too. **Fix:** migrate to `LazyMotion` + the lightweight `m` component (`domAnimation`) — note Login and the Dash\* components import framer-motion directly, so lazy-gating only `PageEnter` won't remove it from the login critical path.
- **Dashboard + its 7 Dash\* subcomponents are bundled into the entry chunk, downloaded on the Login screen.** Dashboard and Login are statically imported in `AdminApp` (`:14-15`), so an unauthenticated user hitting `/login` downloads the entire authenticated dashboard's code before typing a password. **Fix:** lazy-load Dashboard via `lazyWithReload` like every other route; keep Login eager.
- **No route-transition progress indicator and no link prefetch — heavy pages blank to a spinner on click.** Clicking a sidebar item blanks main content to a centered spinner while the chunk downloads (PlanWork 44 kB, InvoiceDetail 35 kB, AttendanceLocation 155 kB with Leaflet), with no top progress bar and no prefetch (zero `prefetchQuery`/`onMouseEnter`/`preload` hits). A `ProgressBar` exists but is reused only as an attendance-fund meter. **Fix:** add an unobtrusive top `LinearProgress` during route transitions and prefetch the lazy chunk (+ primary query) on sidebar link hover/focus.
---
## 5. Suggested Sequencing
**Sprint 1 — "Stop lying to the user" (mostly Quick Wins, ~2-3 days).**
Ship the data-freshness convergence (one invalidation set per family), the global `QueryCache.onError` so failed fetches stop showing as empty, the scrollable mobile tabs, the invoice-paid confirm, the silent-toggle `onError` toasts, the draft-PDF 404 gate, the wrong-Orders-tab redirect, the search debounces, the `csCZ` localeText, the `document.title`, the project/order status-color fixes, and the diacritics/label typos. These are individually small, collectively high-trust, and low-risk. Pair each with a quick Playwright check on the dev server (the user runs it).
**Sprint 2 — "Converge the primitives" (the structural anti-drift work, ~1 week).**
De-fork the dirty-guards and add them to the six unguarded editors; route the invoice PDF openers through `useDocumentPdf`; consolidate the status maps into `documentStatus.ts`; standardize validation on inline `<Field error>` (starting with the mirrored screens); add `<form onSubmit>` to Modal; migrate hand-rolled headers to `PageHeader`; replace inline English errors with `apiErrorMessage`. This is where you stop the Sprint-1 fixes from re-drifting, and it's the cleanest fit for the codebase's "extend, never fork" rule.
**Sprint 3 — "Mobile + a11y + warehouse parity" (~1 week).**
Full-screen modals, sticky save bars, warehouse card-per-row line items, keyboard-operable rows, skip link, search-field accessible names, warehouse document totals, the leave "Nová žádost" action, and the warehouse "Potvrdit" confirm.
**Strategic backlog (schedule deliberately, each is multi-day and one needs a migration).**
Invoice edit-locking (DB migration + route trio — coordinate the migration per CLAUDE.md: ask the user to stop the dev server, apply to `app_test` too). The router upgrade for in-app dirty-nav blocking. `AttendanceAdmin` onto React Query. `InvoiceDetail` onto the shared editor. Real skeletons. Bundle splitting + route prefetch. Treat the navigation/IA items (breadcrumbs, master-data nav grouping, `/customers` move) as a focused IA pass once the above settle.
A note on scope discipline: nearly every item here is "make B match the already-correct A," so the safest pattern is to **read the reference implementation first** (offers/issued orders for documents, `WarehouseSuppliers` for lists, `documentStatus.ts` for status) and converge onto it — rather than inventing new patterns. That keeps the diff small and the regression surface low, which matches the user's same-day-patch-release rhythm.

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

View File

@@ -46,6 +46,17 @@ without explicit user confirmation.**
errors from prior pids). errors from prior pids).
9. Clean up tarballs locally and in `/tmp/` on the server. 9. Clean up tarballs locally and in `/tmp/` on the server.
**nginx is NOT part of a normal release.** nginx is only a reverse proxy in
front of the pm2 app (`proxy_pass` → `127.0.0.1:3001`); a code release changes
app code, which `pm2 restart app-ts` picks up — nginx has nothing new to read.
Do **not** run `sudo nginx -t && sudo systemctl reload nginx` every deploy. Run
it ONLY when you actually edit nginx config (`/etc/nginx/…` — `server_name`,
ports, `proxy_pass` upstream, TLS cert paths, locations/redirects/headers,
`client_max_body_size`, rate limits, etc.). When you do change it, always
`nginx -t` first (validates syntax, changes nothing) THEN `systemctl reload
nginx` (graceful re-read, keeps active connections; a bad config on reload is
rejected and the old one keeps running).
Risky releases (framework jumps, FK/constraint-altering migrations) get a Risky releases (framework jumps, FK/constraint-altering migrations) get a
read-only pre-flight first: `prisma migrate status` on prod, verify FK read-only pre-flight first: `prisma migrate status` on prod, verify FK
constraint names the migration DROPs, check no data violates new constraint names the migration DROPs, check no data violates new

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,789 @@
# Per-document custom-field print selection — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let each offer, issued order, and invoice choose — on its detail page — which company custom fields print in its PDF sender block; default none selected.
**Architecture:** A new nullable `selected_custom_fields` column (JSON-array-in-VARCHAR) on `quotations`, `issued_orders`, `invoices`. A shared backend util encodes/decodes the index array. Create/update services persist it; detail services decode it to `number[]`. The PDF `buildAddressLines()` company-block builder gains an optional selected-indices filter. A shared React picker reused by the three detail pages drives the selection.
**Tech Stack:** Prisma 7 / MySQL, Fastify 5, Zod 4, Vitest (real `app_test` DB), React 19 + MUI v7 + React Query.
**Spec:** `docs/superpowers/specs/2026-06-16-per-document-custom-field-selection-design.md`
---
## Conventions for this plan
- This shell is non-interactive — `prisma migrate dev` is forbidden. Use the `migrate diff` recipe (Task 1).
- **Before the migration, ask the user to stop their dev server and wait for confirmation** (it holds DB connections). Do not run the migration until they confirm.
- Server tests run against `app_test` via `.env.test` (`npm test`). Apply the migration to `app_test` too or the suite breaks.
- `npm run typecheck` = `tsc -b --noEmit`. `npm run lint` must stay at 0 errors.
- Selection semantics in `buildAddressLines`: param **omitted/`undefined`** ⇒ show ALL custom fields (unchanged behavior — used for customer/supplier blocks). Param is an **array** (possibly empty) ⇒ show ONLY those indices (used for the company/sender block; empty ⇒ none).
---
## File Structure
- **Modify** `prisma/schema.prisma` — add `selected_custom_fields String?` to `quotations`, `issued_orders`, `invoices`.
- **Create** `prisma/migrations/<ts>_add_selected_custom_fields/migration.sql`.
- **Modify** `src/utils/custom-fields.ts` — add `encodeSelectedCustomFields` / `parseSelectedCustomFields`.
- **Create** `src/__tests__/selected-custom-fields.test.ts` — util + integration coverage.
- **Modify** `src/schemas/offers.schema.ts`, `issued-orders.schema.ts`, `invoices.schema.ts` — new optional array field.
- **Modify** `src/services/offers.service.ts`, `issued-orders.service.ts`, `invoices.service.ts` — persist on create/update, decode in detail.
- **Modify** `src/routes/admin/offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts` — filter company custom lines.
- **Modify** `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts` — add `selected_custom_fields: number[]` to detail interfaces.
- **Create** `src/admin/components/document/CustomFieldsPrintPicker.tsx` — shared picker.
- **Modify** `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx` — render picker, wire into save payload.
---
## Task 1: Schema column + migration
**Files:**
- Modify: `prisma/schema.prisma` (models `quotations`, `issued_orders`, `invoices`)
- Create: `prisma/migrations/<yyyyMMddHHmmss>_add_selected_custom_fields/migration.sql`
- [ ] **Step 1: Ask the user to stop their dev server**
Post: "Please stop your dev server so I can apply a migration, and tell me when it's stopped." Wait for confirmation before any later `migrate deploy` step.
- [ ] **Step 2: Add the column to each model in `prisma/schema.prisma`**
Add this line to the `quotations` model (near other scalar columns, e.g. after `language`):
```prisma
selected_custom_fields String? @db.VarChar(255)
```
Add the identical line to the `issued_orders` model and to the `invoices` model.
- [ ] **Step 3: Generate the migration SQL**
Run (Git Bash):
```bash
cd /d/cortex/boha-app-ts
TS=$(date +%Y%m%d%H%M%S)
mkdir -p "prisma/migrations/${TS}_add_selected_custom_fields"
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script \
> "prisma/migrations/${TS}_add_selected_custom_fields/migration.sql"
```
Expected: a `migration.sql` containing three `ALTER TABLE … ADD COLUMN selected_custom_fields VARCHAR(255) NULL` statements (one per table). Open it and confirm it touches ONLY `quotations`, `issued_orders`, `invoices` and adds nothing else (no BOM — if it was written via PowerShell, strip the BOM).
- [ ] **Step 4: Apply to dev DB + regenerate client (after user confirmed server stopped)**
```bash
npx prisma migrate deploy
npx prisma generate
```
Expected: "All migrations have been applied" and a regenerated client.
- [ ] **Step 5: Apply to the test DB**
```bash
DATABASE_URL="$(grep -m1 '^DATABASE_URL' .env.test | cut -d= -f2- | tr -d '"')" npx prisma migrate deploy
```
Expected: the same migration applied to `app_test`. (If the env parsing is awkward on this shell, temporarily set `DATABASE_URL` to the app_test URL from `.env.test` and run `npx prisma migrate deploy`.)
- [ ] **Step 6: Typecheck**
Run: `npm run typecheck`
Expected: PASS (the new Prisma field is now known to the client).
- [ ] **Step 7: Commit**
```bash
git add prisma/schema.prisma prisma/migrations
git commit -m "feat(documents): add selected_custom_fields column to quotations/issued_orders/invoices"
```
---
## Task 2: Backend encode/decode util (TDD)
**Files:**
- Modify: `src/utils/custom-fields.ts`
- Test: `src/__tests__/selected-custom-fields.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/__tests__/selected-custom-fields.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import {
encodeSelectedCustomFields,
parseSelectedCustomFields,
} from "../utils/custom-fields";
describe("selected custom fields encode/decode", () => {
it("encodes a non-empty index array to a JSON string", () => {
expect(encodeSelectedCustomFields([0, 2])).toBe("[0,2]");
});
it("encodes empty / non-array to null", () => {
expect(encodeSelectedCustomFields([])).toBeNull();
expect(encodeSelectedCustomFields(undefined)).toBeNull();
expect(encodeSelectedCustomFields("nope")).toBeNull();
});
it("dedupes, sorts, and drops invalid entries before encoding", () => {
expect(encodeSelectedCustomFields([2, 0, 2, -1, 1.5, 3])).toBe("[0,2,3]");
});
it("parses a stored string back to a number array", () => {
expect(parseSelectedCustomFields("[0,2]")).toEqual([0, 2]);
});
it("parses null / malformed to an empty array", () => {
expect(parseSelectedCustomFields(null)).toEqual([]);
expect(parseSelectedCustomFields("")).toEqual([]);
expect(parseSelectedCustomFields("{garbage")).toEqual([]);
expect(parseSelectedCustomFields('"x"')).toEqual([]);
});
it("parses already-array input (defensive) and filters invalid", () => {
expect(parseSelectedCustomFields([0, "1", 2, -3] as unknown)).toEqual([
0, 2,
]);
});
});
```
- [ ] **Step 2: Run it to confirm failure**
Run: `npm test -- selected-custom-fields`
Expected: FAIL — `encodeSelectedCustomFields is not a function`.
- [ ] **Step 3: Implement the helpers**
Append to `src/utils/custom-fields.ts`:
```ts
/**
* Per-document selection of which COMPANY custom fields print on a PDF.
* Stored positionally (matching the `custom_<i>` keys the PDF builder emits)
* as a JSON array string, e.g. "[0,2]". Null/empty means "none selected".
*/
export function encodeSelectedCustomFields(indices: unknown): string | null {
const clean = normalizeIndices(indices);
return clean.length > 0 ? JSON.stringify(clean) : null;
}
/** Decode the stored selection (string OR defensive array) into a clean number[]. */
export function parseSelectedCustomFields(raw: unknown): number[] {
if (raw == null) return [];
if (Array.isArray(raw)) return normalizeIndices(raw);
if (typeof raw !== "string" || raw.trim() === "") return [];
try {
return normalizeIndices(JSON.parse(raw));
} catch {
// Malformed JSON in a selection column degrades to "none" (expected
// condition — a hand-edited/legacy row should never 500 a PDF render).
return [];
}
}
function normalizeIndices(input: unknown): number[] {
if (!Array.isArray(input)) return [];
const set = new Set<number>();
for (const v of input) {
if (typeof v === "number" && Number.isInteger(v) && v >= 0) set.add(v);
}
return [...set].sort((a, b) => a - b);
}
```
- [ ] **Step 4: Run the test to confirm it passes**
Run: `npm test -- selected-custom-fields`
Expected: PASS (all util cases).
- [ ] **Step 5: Commit**
```bash
git add src/utils/custom-fields.ts src/__tests__/selected-custom-fields.test.ts
git commit -m "feat(documents): selected-custom-fields encode/decode helpers"
```
---
## Task 3: Zod schemas
**Files:**
- Modify: `src/schemas/offers.schema.ts`
- Modify: `src/schemas/issued-orders.schema.ts`
- Modify: `src/schemas/invoices.schema.ts`
- [ ] **Step 1: Offers schema**
In `src/schemas/offers.schema.ts`, inside `CreateQuotationSchema`, add after the `sections` line (line 50):
```ts
// Positional indices of company custom fields to print on this document's
// PDF. Omitted/empty ⇒ none. Update schema derives via .partial().
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
```
(`UpdateQuotationSchema` already derives from this via `.partial().omit({ quotation_number: true })` — no change needed there.)
- [ ] **Step 2: Issued-orders schema**
In `src/schemas/issued-orders.schema.ts`, inside `CreateIssuedOrderSchema`, add after the `sections` field:
```ts
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
```
(`UpdateIssuedOrderSchema` derives via `.partial()` — no change.)
- [ ] **Step 3: Invoices schema**
In `src/schemas/invoices.schema.ts`, add the same line to BOTH `CreateInvoiceSchema` (after its `sections` field) and `UpdateInvoiceSchema` (after its `sections` field — invoices define the two schemas separately, so it must be added in both):
```ts
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
```
- [ ] **Step 4: Typecheck**
Run: `npm run typecheck`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/schemas/offers.schema.ts src/schemas/issued-orders.schema.ts src/schemas/invoices.schema.ts
git commit -m "feat(documents): accept selected_custom_fields in document schemas"
```
---
## Task 4: Services — persist on write, decode in detail (TDD)
**Files:**
- Modify: `src/services/offers.service.ts`
- Modify: `src/services/issued-orders.service.ts`
- Modify: `src/services/invoices.service.ts`
- Test: `src/__tests__/selected-custom-fields.test.ts` (extend)
- [ ] **Step 1: Add the import to all three services**
At the top of each of the three service files, add (or extend the existing import from `../utils/custom-fields`):
```ts
import {
encodeSelectedCustomFields,
parseSelectedCustomFields,
} from "../utils/custom-fields";
```
- [ ] **Step 2: Offers — persist on create**
In `createOffer` (`src/services/offers.service.ts`), inside `tx.quotations.create({ data: { … } })`, add after `scope_description`:
```ts
selected_custom_fields: encodeSelectedCustomFields(
body.selected_custom_fields,
),
```
- [ ] **Step 3: Offers — persist on update**
In `updateOffer`, inside the `const data = { … }` object (after `scope_description`, before `modified_at`), add:
```ts
selected_custom_fields:
body.selected_custom_fields !== undefined
? encodeSelectedCustomFields(body.selected_custom_fields)
: undefined,
```
(`undefined` = "key absent in payload, leave column untouched" — matches the sibling header fields.)
- [ ] **Step 4: Offers — decode in detail**
In `getOffer`, in the returned object (after `valid_transitions`), add:
```ts
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
```
- [ ] **Step 5: Issued orders — persist + decode**
In `src/services/issued-orders.service.ts`:
- In `createIssuedOrder`, inside `tx.issued_orders.create({ data: { … } })`, add:
```ts
selected_custom_fields: encodeSelectedCustomFields(
body.selected_custom_fields,
),
```
- In `updateIssuedOrder`, inside the header `data` object passed to `issued_orders.update`, add:
```ts
selected_custom_fields:
body.selected_custom_fields !== undefined
? encodeSelectedCustomFields(body.selected_custom_fields)
: undefined,
```
- In `getIssuedOrder`, in the returned object (after `valid_transitions`), add:
```ts
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
```
- [ ] **Step 6: Invoices — persist + decode**
In `src/services/invoices.service.ts`:
- In `createInvoice`, inside `tx.invoices.create({ data: { … } })`, add after `internal_notes`:
```ts
selected_custom_fields: encodeSelectedCustomFields(
body.selected_custom_fields,
),
```
- In `updateInvoice`, inside the first `if (editable) { … }` block (after the `tax_date` handling, still inside the block), add:
```ts
if (body.selected_custom_fields !== undefined)
data.selected_custom_fields = encodeSelectedCustomFields(
body.selected_custom_fields,
);
```
- In `getInvoice`, in the returned object (after `valid_transitions`), add:
```ts
selected_custom_fields: parseSelectedCustomFields(rest.selected_custom_fields),
```
- [ ] **Step 7: Extend the test with an offers round-trip (real DB)**
Append to `src/__tests__/selected-custom-fields.test.ts`. Match the existing suite's fixture style — import the offers service directly and clean up. Use a far-future placeholder where the suite does; if an existing offers test helper exists, prefer it. Minimal version:
```ts
import { createOffer, getOffer } from "../services/offers.service";
import { prisma } from "../config/prisma"; // adjust to the project's prisma export path
describe("offers selected_custom_fields round-trip", () => {
const created: number[] = [];
afterAll(async () => {
if (created.length)
await prisma.quotations.deleteMany({ where: { id: { in: created } } });
});
it("persists selection on create and decodes it on detail", async () => {
const res = (await createOffer({
status: "draft",
selected_custom_fields: [2, 0, 0],
})) as { id: number };
created.push(res.id);
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
expect(row?.selected_custom_fields).toBe("[0,2]");
const detail = await getOffer(res.id);
expect(detail?.selected_custom_fields).toEqual([0, 2]);
});
it("stores null when selection is empty", async () => {
const res = (await createOffer({
status: "draft",
selected_custom_fields: [],
})) as { id: number };
created.push(res.id);
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
expect(row?.selected_custom_fields).toBeNull();
});
});
```
> Before running: confirm the prisma import path (`../config/prisma` vs `../config/db` — grep an existing test). Adjust the import to match.
- [ ] **Step 8: Run tests**
Run: `npm test -- selected-custom-fields`
Expected: PASS (util + offers round-trip). If FK constraints require a customer, the `customer_id`-less draft path above avoids them.
- [ ] **Step 9: Typecheck + commit**
```bash
npm run typecheck
git add src/services/offers.service.ts src/services/issued-orders.service.ts src/services/invoices.service.ts src/__tests__/selected-custom-fields.test.ts
git commit -m "feat(documents): persist & expose selected_custom_fields in services"
```
---
## Task 5: PDF rendering — filter company custom lines (TDD)
**Files:**
- Modify: `src/routes/admin/offers-pdf.ts`
- Modify: `src/routes/admin/issued-orders-pdf.ts`
- Modify: `src/routes/admin/invoices-pdf.ts`
- Test: `src/__tests__/selected-custom-fields.test.ts` (extend, if a PDF render is unit-testable; otherwise assert via the helper — see Step 6)
- [ ] **Step 1: Offers PDF — add the filter param to `buildAddressLines`**
In `src/routes/admin/offers-pdf.ts`, change the signature (line 28-32):
```ts
function buildAddressLines(
entity: Record<string, unknown> | null,
isSupplier: boolean,
t: (key: string) => string,
selectedCustomFields?: number[],
): AddressResult {
```
Then change the custom-field loop (lines 88-96) to skip non-selected indices when a selection array is provided:
```ts
const filterCustom = Array.isArray(selectedCustomFields);
cfData.forEach((cf, i) => {
if (filterCustom && !selectedCustomFields!.includes(i)) return;
const cfName = (cf.name || "").trim();
const cfValue = (cf.value || "").trim();
const showLabel = cf.showLabel !== false;
if (cfValue) {
fieldMap[`custom_${i}`] =
showLabel && cfName ? `${cfName}: ${cfValue}` : cfValue;
}
});
```
- [ ] **Step 2: Offers PDF — pass the document's selection into the COMPANY call**
The offer's sender/company block is `supp` (`isSupplier: true` on `settings`). Update that call (lines 209-213) to pass the parsed selection; leave the customer call (`cust`) unchanged so customer custom fields still show in full:
```ts
const supp = buildAddressLines(
settings as unknown as Record<string, unknown>,
true,
t,
parseSelectedCustomFields(
(quotation as { selected_custom_fields?: unknown }).selected_custom_fields,
),
);
```
Add the import at the top of the file:
```ts
import { parseSelectedCustomFields } from "../../utils/custom-fields";
```
> Confirm `quotation` (the `OfferForPdf` payload the render function receives) is selected with the default field set so `selected_custom_fields` is present. It's a scalar column on `quotations`, so the default `findUnique`/`include` returns it — no `select` narrowing to adjust here.
- [ ] **Step 3: Issued-orders PDF — same change on the COMPANY (buyer) block**
In `src/routes/admin/issued-orders-pdf.ts`:
- Add `selectedCustomFields?: number[]` as the last param of `buildAddressLines` and apply the identical `filterCustom` guard in its custom-field loop.
- The company block is `buyer = buildAddressLines(settings, true, t)` (line 316). Change to:
```ts
const buyer = buildAddressLines(
settings,
true,
t,
parseSelectedCustomFields(
(order as { selected_custom_fields?: unknown }).selected_custom_fields,
),
);
```
- Leave `buildSupplierLines(...)` untouched (supplier fields keep showing in full).
- Add `import { parseSelectedCustomFields } from "../../utils/custom-fields";`.
> Confirm the render function's `order` param carries `selected_custom_fields`. If the route fetches the order via a narrow `select`, add `selected_custom_fields: true` to it; if it uses `include`/default scalars, it's already present. Grep the route's `issued_orders.findUnique`/`findFirst` before assuming.
- [ ] **Step 4: Invoices PDF — same change on the COMPANY (supplier-of-invoice) block**
In `src/routes/admin/invoices-pdf.ts`:
- Add `selectedCustomFields?: number[]` as the last param of `buildAddressLines` and apply the identical `filterCustom` guard.
- The company block is `supp = buildAddressLines(settings, true, t)` (line 457). Change to:
```ts
const supp = buildAddressLines(
settings,
true,
t,
parseSelectedCustomFields(
(invoice as { selected_custom_fields?: unknown }).selected_custom_fields,
),
);
```
- Leave `cust = buildAddressLines(customer, false, t)` untouched.
- Note the separate `settings.custom_fields` read lower down (the "supplier email/web" extraction near line 469) is a DIFFERENT concern (pulls an email for a header line) — **do not** filter that; leave it as-is.
- Add `import { parseSelectedCustomFields } from "../../utils/custom-fields";`.
> Confirm `invoice` is fetched with default scalars (it is — `prisma.invoices.findUnique` without a narrowing `select` in this route), so `selected_custom_fields` is present.
- [ ] **Step 5: Typecheck + lint**
Run: `npm run typecheck && npm run lint`
Expected: PASS, 0 lint errors.
- [ ] **Step 6: Add a focused render assertion (extend the test file)**
If the three PDF modules export their HTML render function (e.g. offers exports `renderOfferHtml`), add a test that renders with a stubbed settings object carrying two company custom fields and asserts only the selected one appears. Mock `html-to-pdf` per the suite convention; you're asserting on the returned HTML string, not a real PDF. Example for offers (adapt names to the actual export):
```ts
import { renderOfferHtml } from "../routes/admin/offers-pdf"; // confirm export name
it("offer PDF prints only selected company custom fields", () => {
const settings = {
name: "Naše Firma s.r.o.",
custom_fields: JSON.stringify({
fields: [
{ name: "Tel.", value: "123", showLabel: true },
{ name: "Web", value: "example.cz", showLabel: true },
],
field_order: [],
}),
};
const quotation = {
customers: null,
quotation_items: [],
scope_sections: [],
status: "draft",
currency: "CZK",
language: "cs",
selected_custom_fields: "[0]",
};
const html = renderOfferHtml(quotation as never, settings as never);
expect(html).toContain("Tel.: 123");
expect(html).not.toContain("example.cz");
});
```
If the render function is NOT exported / not unit-testable without a DB, SKIP this step rather than forcing it — the util test (Task 2) plus the service round-trip (Task 4) already cover the data path; note in the commit that PDF filtering was verified manually. Do not export internals solely to test them if that breaks the module's encapsulation; prefer a manual verification note.
- [ ] **Step 7: Commit**
```bash
git add src/routes/admin/offers-pdf.ts src/routes/admin/issued-orders-pdf.ts src/routes/admin/invoices-pdf.ts src/__tests__/selected-custom-fields.test.ts
git commit -m "feat(documents): PDF prints only the document's selected company custom fields"
```
---
## Task 6: Frontend — detail query types + shared picker
**Files:**
- Modify: `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts`
- Create: `src/admin/components/document/CustomFieldsPrintPicker.tsx`
- [ ] **Step 1: Add the field to the three detail interfaces**
- `src/admin/lib/queries/offers.ts` — add to `OfferDetailData`:
```ts
selected_custom_fields: number[];
```
- `src/admin/lib/queries/issued-orders.ts` — add to `IssuedOrderDetail`:
```ts
selected_custom_fields: number[];
```
- `src/admin/lib/queries/invoices.ts` — add to `InvoiceDetail`:
```ts
selected_custom_fields?: number[];
```
- [ ] **Step 2: Create the shared picker component**
Create `src/admin/components/document/CustomFieldsPrintPicker.tsx`:
```tsx
import { FormControlLabel, Checkbox, Box, Typography } from "@mui/material";
import type { CompanySettingsCustomField } from "../../lib/queries/settings";
interface Props {
/** Company custom field definitions, in positional order (index = print key). */
fields: CompanySettingsCustomField[];
/** Currently selected positional indices. */
selected: number[];
/** Read-only (document not editable / locked by another user). */
disabled?: boolean;
onChange: (next: number[]) => void;
}
/**
* Per-document picker: choose which COMPANY custom fields print on this
* document's PDF. Selection is positional (matches the PDF `custom_<i>` keys).
* Renders nothing when the company has defined no custom fields.
*/
export default function CustomFieldsPrintPicker({
fields,
selected,
disabled = false,
onChange,
}: Props) {
const printable = fields.filter((f) => (f.value || "").trim());
if (printable.length === 0) return null;
const toggle = (idx: number, checked: boolean) => {
const set = new Set(selected);
if (checked) set.add(idx);
else set.delete(idx);
onChange([...set].sort((a, b) => a - b));
};
return (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Vlastní pole na PDF
</Typography>
{fields.map((f, idx) => {
if (!(f.value || "").trim()) return null;
const label = (f.name || "").trim() ? `${f.name}: ${f.value}` : f.value;
return (
<FormControlLabel
key={idx}
control={
<Checkbox
size="small"
checked={selected.includes(idx)}
disabled={disabled}
onChange={(e) => toggle(idx, e.target.checked)}
/>
}
label={<Typography variant="body2">{label}</Typography>}
/>
);
})}
</Box>
);
}
```
> Note: indices are keyed off the FULL `fields` array (not the filtered `printable`) so they stay aligned with the PDF's `custom_<i>`, which also enumerates the full array. Empty-value fields are skipped visually but still consume their index.
- [ ] **Step 3: Typecheck**
Run: `npm run typecheck`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/admin/lib/queries/offers.ts src/admin/lib/queries/issued-orders.ts src/admin/lib/queries/invoices.ts src/admin/components/document/CustomFieldsPrintPicker.tsx
git commit -m "feat(documents): shared CustomFieldsPrintPicker + detail query types"
```
---
## Task 7: Frontend — wire the picker into the three detail pages
**Files:**
- Modify: `src/admin/pages/OfferDetail.tsx`
- Modify: `src/admin/pages/IssuedOrderDetail.tsx`
- Modify: `src/admin/pages/InvoiceDetail.tsx`
For EACH page, the same four edits. Detail below uses OfferDetail; repeat the pattern for the other two (their company-settings query and edit-lock/editable flags already exist on the page — reuse them; do not add new queries).
- [ ] **Step 1: Import the picker (all three pages)**
```ts
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
```
- [ ] **Step 2: Seed local state from the loaded document**
Add state near the page's other editable-field state, and seed it when the document loads (follow the page's existing seeding pattern — if it copies server data into state in an effect or on query success, add this alongside; if it derives form state via `useState` initializers keyed on the query, match that):
```ts
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>([]);
// when the detail query resolves (same place other fields are seeded):
// setSelectedCustomFields(data.selected_custom_fields ?? []);
```
Respect Rules of Hooks: declare this `useState` with the other hooks, before any early `return`.
- [ ] **Step 3: Render the picker in the editable header/meta area**
Place near the other document-level settings (currency/language). `companySettings` is already loaded on the page via `companySettingsOptions()`. Use the page's existing "is this document editable / not locked by another user" boolean for `disabled` (e.g. `!canEdit` or the locked flag the page already computes):
```tsx
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={!canEdit}
onChange={setSelectedCustomFields}
/>
```
> Use whatever the page already calls its edit-gate (e.g. `canEdit`, `editable`, `isLockedByOther`). Do NOT invent a new permission — reuse the page's existing flag so the picker locks exactly when the rest of the form does.
- [ ] **Step 4: Include the selection in the save payload**
Find where the page builds its update/create payload (the object passed to the save mutation) and add:
```ts
selected_custom_fields: selectedCustomFields,
```
- [ ] **Step 5: Repeat Steps 1-4 for `IssuedOrderDetail.tsx` and `InvoiceDetail.tsx`**
Same edits; the company-settings query, edit-gate flag, and save payload all already exist on each page.
- [ ] **Step 6: Typecheck + lint**
Run: `npm run typecheck && npm run lint`
Expected: PASS, 0 lint errors (watch `react-hooks/rules-of-hooks` — the new `useState` must precede any early return).
- [ ] **Step 7: Commit**
```bash
git add src/admin/pages/OfferDetail.tsx src/admin/pages/IssuedOrderDetail.tsx src/admin/pages/InvoiceDetail.tsx
git commit -m "feat(documents): per-document custom-field print picker on offer/issued-order/invoice detail"
```
---
## Task 8: Full verification
- [ ] **Step 1: Run the whole server suite**
Run: `npm test`
Expected: PASS (no regressions; new selected-custom-fields cases green).
- [ ] **Step 2: Typecheck + lint + build**
```bash
npm run typecheck
npm run lint
npm run build
```
Expected: all PASS; client builds.
- [ ] **Step 3: Manual smoke (user-driven, dev server is theirs)**
Ask the user to: open an offer detail with company custom fields defined → tick one field → save → open its PDF and confirm only that field prints in the company block; confirm an untouched/older document prints no custom fields. Repeat for an issued order and an invoice.
- [ ] **Step 4: Final state check**
```bash
git status
git log --oneline -8
```
Expected: clean tree, the feature commits present.
---
## Self-review notes (addressed)
- **Spec coverage:** storage column (T1), encode/decode (T2), schemas (T3), service persist+decode (T4), PDF filter (T5), frontend types+picker (T6), detail-page wiring (T7), tests throughout + full verify (T8). Order confirmations deliberately untouched. ✅
- **Default = none:** `buildAddressLines` filters only when passed an array; the company call always passes the parsed selection (default `[]`), so nothing prints until ticked. Customer/supplier calls omit the param ⇒ unchanged. ✅
- **Positional identity:** indices key off the full `fields` array on both render and picker sides (T5 note, T6 Step 2 note). ✅
- **Type consistency:** `encodeSelectedCustomFields` / `parseSelectedCustomFields` used with identical signatures across services and PDF routes; detail interfaces expose `number[]`. ✅
- **Open confirmations flagged inline** (prisma import path in tests; whether each PDF route narrows its document `select`) — each has a grep-first instruction rather than an assumption.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,211 @@
# Per-document custom-field print selection
**Date:** 2026-06-16
**Status:** Approved design — ready for implementation plan
**Scope:** offers (quotations), issued orders, invoices
---
## Problem
Company custom fields are defined in company settings (`company_settings.custom_fields`,
a JSON blob of `{name, value, showLabel}[]` plus a `field_order`). They render in the
**sender/company block** of document PDFs via `buildAddressLines(settings, …)`.
Today **every** custom field with a value prints on **every** document PDF (offer, issued
order, invoice). The only existing per-field control is the company-settings checkbox
**"Zobrazit název v PDF"** (`showLabel`), which decides whether a printed field shows as
`Name: Value` or just `Value` — it does **not** control whether the field appears at all.
The user wants per-document control: on each offer / issued order / invoice **detail page**,
tick which company custom fields actually print on that specific document's PDF.
## Goals
- Each offer, issued order, and invoice independently selects which company custom fields
print in its PDF sender block.
- Selection lives on the document and is editable on its detail page.
- Default is **none selected** — existing documents and new drafts print no custom fields
until fields are deliberately ticked.
- The company-settings `showLabel` ("Zobrazit název v PDF") checkbox is **unchanged**.
## Non-goals
- Order confirmations ("orders" / `orders-pdf.ts`) are **out of scope** — they keep current
behavior (print all custom fields).
- No change to how custom fields are _defined_ in company settings.
- No stable per-field IDs — selection is **positional** (see Decisions).
## Decisions (locked)
1. **Default = none selected.** Existing rows get `NULL`; new documents start empty. A
document prints custom fields only for the indices it explicitly stores.
2. **Positional identity.** Selection stores field **positions** (`0,1,2…`) matching the
existing `custom_<i>` keys in the PDF code. No settings-structure change, no backfill.
Accepted caveat: reordering/deleting a custom field in company settings can shift what a
saved document's stored indices point at. Low risk — company fields rarely change, and
finalized PDFs are archived on NAS (served as-is, not re-rendered). The user accepted this
over the more robust stable-ID approach.
3. **Scope = 3 document types** (offers, issued orders, invoices). Order confirmations excluded.
4. **`showLabel` retained** in company settings, untouched.
---
## Design
### 1. Data storage
Add one nullable column to each of `quotations`, `issued_orders`, `invoices`:
| Column | Type | Meaning |
| ------------------------ | --------- | ------------------------------------------------------------- |
| `selected_custom_fields` | `VARCHAR` | JSON array of selected indices, e.g. `"[0,2]"`; `NULL` = none |
Stored as JSON-in-text, consistent with the existing `company_settings.custom_fields` /
`customers.custom_fields` pattern. `NULL`/empty ⇒ no custom fields print.
One tracked migration per the project's non-interactive `prisma migrate diff` recipe
(CLAUDE.md). **Apply to `app_test` as well** (`DATABASE_URL=<app_test> npx prisma migrate
deploy`) or the suite breaks. Commit `schema.prisma` + migration folder together.
### 2. Backend — schemas
Add to each document's **Create and Update** Zod schema:
```ts
selected_custom_fields: z.array(z.number().int().nonnegative()).max(200).optional(),
```
Files:
- `src/schemas/offers.schema.ts``CreateQuotationSchema` (Update derives via `.partial().omit()`).
- `src/schemas/issued-orders.schema.ts``CreateIssuedOrderSchema` (Update derives via `.partial().omit()`).
- `src/schemas/invoices.schema.ts` — both `CreateInvoiceSchema` and `UpdateInvoiceSchema`
(invoices define the two schemas separately).
### 3. Backend — services
In each create/update service, persist the field inside the **existing transaction**:
- On write: `selected_custom_fields: Array.isArray(body.selected_custom_fields) &&
body.selected_custom_fields.length > 0 ? JSON.stringify(body.selected_custom_fields) : null`
- No other logic changes; sits alongside the existing header column assignments.
Files: `src/services/offers.service.ts`, `src/services/issued-orders.service.ts`,
`src/services/invoices.service.ts`.
### 4. Backend — detail responses
The detail endpoints spread the document row. Decode the stored string back into a
`number[]` so the frontend receives a clean array (default `[]` when `NULL`/malformed —
malformed JSON degrades gracefully with a logged warning, per the error convention). Add
`selected_custom_fields: number[]` to the corresponding detail interfaces in
`src/admin/lib/queries/{offers,issued-orders,invoices}.ts`.
### 5. PDF rendering
In each of `offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts`, extend the
company-block `buildAddressLines()` with an optional parameter:
```ts
buildAddressLines(entity, isSupplier, t, selectedCustomFields?: number[] | null)
```
When building `fieldMap`, only emit a `custom_<i>` entry when `selectedCustomFields`
includes `i`. Behavior:
- `null` / `undefined` / empty array ⇒ **no** custom lines (the new default).
- Standard address lines (name, street, city/postal, country, IČO/`company_id`,
DIČ/`vat_id`) are **unaffected** — always built as today.
- For the custom fields that _are_ selected, the existing `showLabel` (`Name: Value` vs
`Value`) and `field_order` ordering logic is preserved unchanged.
Only the **company/sender** block is filtered (that's where company custom fields render).
The customer block (offers/invoices) and supplier block (issued orders) keep their own
custom-field behavior unchanged — those come from `customers`/`sklad_suppliers`, not company
settings, and are not in scope.
The PDF route reads the document's `selected_custom_fields` column, parses it to `number[]`,
and passes it into `buildAddressLines(settings, …, selected)`.
> Archived-PDF note: finalized/numbered documents serve their archived NAS copy and won't
> change retroactively. Drafts (and any forced re-render) reflect the current selection.
### 6. Frontend — shared picker component
New shared component under `src/admin/components/document/` (e.g.
`CustomFieldsPrintPicker.tsx`), reused by all three detail pages (extend the shared module,
don't fork three copies — per CLAUDE.md document-module conventions).
Props (shape): the parsed company custom fields (`{name, value}[]` from
`companySettings.custom_fields`), the current selected indices, an `onChange`, and a
`disabled` flag.
Behavior:
- Renders a checkbox per company custom field; label shows the field's name (and/or value)
so the user knows what each is.
- Bound to local state on the detail page, seeded from the document's
`selected_custom_fields`.
- Hidden/empty when the company has no custom fields defined.
- Respects edit-lock and editable-status rules: read-only (`disabled`) when the document
isn't editable or is locked by another user, matching how the rest of the header fields
behave on that page.
- Included in the page's save payload as `selected_custom_fields: number[]`.
Pages: `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx` —
each already loads `companySettingsOptions()`, so the field definitions are available with
no new query.
Placement: in the editable header/meta area of each detail page, near the other
document-level settings (currency/language/etc.).
---
## Data flow
```
Company settings: custom_fields = [{name:"Tel.",…}, {name:"Web",…}, {name:"Datová schránka",…}]
idx 0 idx 1 idx 2
Offer detail page → user ticks "Tel." and "Datová schránka"
→ save payload selected_custom_fields: [0, 2]
→ service stores quotations.selected_custom_fields = "[0,2]"
Offer PDF render
→ read column "[0,2]" → [0,2]
→ buildAddressLines(settings, …, [0,2])
→ fieldMap emits custom_0 ("Tel.: …") and custom_2 ("Datová schránka: …"), skips custom_1
→ sender block prints Tel. and Datová schránka only
```
## Testing
Server-side Vitest against `app_test` (no Prisma mocking; mock Puppeteer/NAS only):
- Create/update each document with `selected_custom_fields` → column persists the JSON
string; empty/omitted → `NULL`.
- Detail response decodes to `number[]` (and `[]` for `NULL`).
- Schema: non-integer / negative entries rejected; omitted is valid.
- PDF: with a stubbed `html-to-pdf`, assert the rendered company block includes only the
selected custom field lines and still includes standard address lines; empty selection ⇒
no custom lines; standard lines unaffected.
- Regression: customer/supplier custom fields still render regardless of company selection.
## Migration & rollout
- One migration adding the three columns (all default `NULL`), applied to dev `app` and
`app_test`.
- No data backfill (none-selected default is the intended post-ship state).
- No production PDF changes for already-archived documents.
## Affected files (reference)
- `prisma/schema.prisma` (+ new migration folder)
- `src/schemas/offers.schema.ts`, `issued-orders.schema.ts`, `invoices.schema.ts`
- `src/services/offers.service.ts`, `issued-orders.service.ts`, `invoices.service.ts`
- `src/routes/admin/offers-pdf.ts`, `issued-orders-pdf.ts`, `invoices-pdf.ts`
- (detail route handlers, if decoding is done there rather than in the service)
- `src/admin/lib/queries/offers.ts`, `issued-orders.ts`, `invoices.ts`
- `src/admin/components/document/CustomFieldsPrintPicker.tsx` (new)
- `src/admin/pages/OfferDetail.tsx`, `IssuedOrderDetail.tsx`, `InvoiceDetail.tsx`

View File

@@ -19,7 +19,7 @@
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" /> <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<title>Admin</title> <title>BOHA Admin</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE `invoices` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
-- AlterTable
ALTER TABLE `issued_orders` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;
-- AlterTable
ALTER TABLE `quotations` ADD COLUMN `selected_custom_fields` VARCHAR(255) NULL;

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)
@@ -205,6 +208,7 @@ model invoice_items {
quantity Decimal? @default(1.000) @db.Decimal(12, 3) quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20) unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2) unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
discount Decimal? @default(0.00) @db.Decimal(5, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2) vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
position Int? @default(0) position Int? @default(0)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1") invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
@@ -235,6 +239,7 @@ model invoices {
billing_text String? @db.VarChar(500) billing_text String? @db.VarChar(500)
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
internal_notes String? @db.Text internal_notes String? @db.Text
selected_custom_fields String? @db.VarChar(255)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
invoice_items invoice_items[] invoice_items invoice_items[]
@@ -389,6 +394,7 @@ model issued_orders {
language String? @default("cs") @db.VarChar(5) language String? @default("cs") @db.VarChar(5)
order_text String? @db.VarChar(500) order_text String? @db.VarChar(500)
internal_notes String? @db.Text internal_notes String? @db.Text
selected_custom_fields String? @db.VarChar(255)
locked_by Int? locked_by Int?
locked_at DateTime? @db.DateTime(0) locked_at DateTime? @db.DateTime(0)
created_at DateTime? @default(now()) @db.DateTime(0) created_at DateTime? @default(now()) @db.DateTime(0)
@@ -456,6 +462,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")
@@ -501,6 +508,7 @@ model quotation_items {
quantity Decimal? @default(1.000) @db.Decimal(12, 3) quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20) unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2) unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
discount Decimal? @default(0.00) @db.Decimal(5, 2)
is_included_in_total Boolean? @default(true) is_included_in_total Boolean? @default(true)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1") quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
@@ -521,6 +529,7 @@ model quotations {
status String @default("active") @db.VarChar(20) status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500) scope_title String? @db.VarChar(500)
scope_description String? @db.Text scope_description String? @db.Text
selected_custom_fields String? @db.VarChar(255)
locked_by Int? locked_by Int?
locked_at DateTime? @db.DateTime(0) locked_at DateTime? @db.DateTime(0)
modified_at DateTime? @db.DateTime(0) modified_at DateTime? @db.DateTime(0)

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

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
reportServerVersion,
subscribeNewVersion,
getNewVersion,
BUILD_VERSION,
__resetVersionStateForTest,
} from "../admin/utils/appVersion";
/**
* Client store backing the "new version available" banner. The server stamps
* X-App-Version on every response; apiFetch feeds it here. A version that
* differs from the one baked into THIS bundle (BUILD_VERSION) means a deploy
* happened → latch it and notify subscribers (the banner).
*/
describe("appVersion store", () => {
beforeEach(() => __resetVersionStateForTest());
it("ignores the same version, empty, or null", () => {
reportServerVersion(BUILD_VERSION);
reportServerVersion("");
reportServerVersion(null);
reportServerVersion(undefined);
expect(getNewVersion()).toBeNull();
});
it("latches + notifies once when the server reports a different version", () => {
let notified = 0;
const unsub = subscribeNewVersion(() => {
notified++;
});
reportServerVersion(`${BUILD_VERSION}-next`);
expect(getNewVersion()).toBe(`${BUILD_VERSION}-next`);
expect(notified).toBe(1);
// Idempotent: once a new version is known, further reports don't re-notify
// or overwrite (the banner is already showing).
reportServerVersion("99.0.0");
expect(getNewVersion()).toBe(`${BUILD_VERSION}-next`);
expect(notified).toBe(1);
unsub();
});
});

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

@@ -87,7 +87,9 @@ describe("issued-order drafts — deferred numbering", () => {
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => { it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
const expected = (await previewIssuedOrderNumber()).number; const expected = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({}); const draft = await createIssuedOrder({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
if ("error" in draft) if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`); throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id); issuedOrderIds.push(draft.id);
@@ -104,7 +106,9 @@ describe("issued-order drafts — deferred numbering", () => {
}); });
it("finalizing is idempotent — re-finalizing does not re-number", async () => { it("finalizing is idempotent — re-finalizing does not re-number", async () => {
const draft = await createIssuedOrder({}); const draft = await createIssuedOrder({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
if ("error" in draft) if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`); throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id); issuedOrderIds.push(draft.id);

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

@@ -169,7 +169,10 @@ describe("createIssuedOrder", () => {
it("numbers immediately when created already-finalized (status sent)", async () => { it("numbers immediately when created already-finalized (status sent)", async () => {
const before = (await previewIssuedOrderNumber()).number; const before = (await previewIssuedOrderNumber()).number;
const order = await mkIssued({ status: "sent" }); const order = await mkIssued({
status: "sent",
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
expect(order.po_number).toBe(before); expect(order.po_number).toBe(before);
expect(order.status).toBe("sent"); expect(order.status).toBe("sent");
}); });
@@ -199,7 +202,9 @@ describe("createIssuedOrder", () => {
describe("updateIssuedOrder status transitions", () => { describe("updateIssuedOrder status transitions", () => {
it("allows draft -> sent and rejects draft -> completed", async () => { it("allows draft -> sent and rejects draft -> completed", async () => {
const order = await mkIssued({}); const order = await mkIssued({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
const ok = await updateIssuedOrder(order.id, { status: "sent" }); const ok = await updateIssuedOrder(order.id, { status: "sent" });
expect("error" in ok).toBe(false); expect("error" in ok).toBe(false);
const bad = await updateIssuedOrder(order.id, { status: "completed" }); const bad = await updateIssuedOrder(order.id, { status: "completed" });
@@ -226,7 +231,9 @@ describe("updateIssuedOrder status transitions", () => {
}); });
it("status-only transition payloads still work when not editable", async () => { it("status-only transition payloads still work when not editable", async () => {
const order = await mkIssued({}); const order = await mkIssued({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" }); await updateIssuedOrder(order.id, { status: "confirmed" });
const res = await updateIssuedOrder(order.id, { status: "completed" }); const res = await updateIssuedOrder(order.id, { status: "completed" });
@@ -242,6 +249,44 @@ describe("updateIssuedOrder status transitions", () => {
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 }); const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
expect("error" in res && res.error).toBe("supplier_not_found"); expect("error" in res && res.error).toBe("supplier_not_found");
}); });
it("finalizes a sections-only order (no items) successfully", async () => {
const order = await mkIssued({});
const res = await updateIssuedOrder(order.id, {
status: "sent",
items: [],
sections: [
{ title: "Scope", title_cz: "Rozsah prací", content: "<p>Detail</p>" },
],
});
expect("error" in res).toBe(false);
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.status).toBe("sent");
expect(row!.po_number).toBeTruthy();
});
it("rejects finalizing a completely blank order (no items, no sections)", async () => {
const order = await mkIssued({});
const res = await updateIssuedOrder(order.id, {
status: "sent",
items: [],
sections: [],
});
expect("error" in res && res.error).toBe("empty_document");
// Stays a draft — the finalize was rejected before any write.
const row = await prisma.issued_orders.findUnique({
where: { id: order.id },
});
expect(row!.status).toBe("draft");
expect(row!.po_number).toBeNull();
});
it("rejects creating a non-draft order with no content", async () => {
const res = await createIssuedOrder({ status: "sent" });
expect("error" in res && res.error).toBe("empty_document");
});
}); });
describe("getIssuedOrder", () => { describe("getIssuedOrder", () => {
@@ -525,7 +570,9 @@ describe("POST /api/admin/issued-orders legacy dropped fields", () => {
describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => { describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
it("400s a field edit on a confirmed order with the explicit Czech message", async () => { it("400s a field edit on a confirmed order with the explicit Czech message", async () => {
const order = await mkIssued({}); const order = await mkIssued({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" }); await updateIssuedOrder(order.id, { status: "confirmed" });
@@ -539,8 +586,46 @@ 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({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
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({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
await updateIssuedOrder(order.id, { status: "sent" }); await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" }); await updateIssuedOrder(order.id, { status: "confirmed" });
@@ -699,6 +784,23 @@ describe("renderIssuedOrderHtml", () => {
expect(html).toContain("Odběratel"); expect(html).toContain("Odběratel");
}); });
it("omits the items table and total when there are no items (sections-only order)", () => {
const html = renderIssuedOrderHtml(
order,
[],
supplier,
{ company_name: "Naše firma" },
"cs",
issuer,
[{ title: "Scope", title_cz: "Rozsah prací", content: "<p>Detail</p>" }],
);
// The section renders…
expect(html).toContain("Rozsah prací");
// …but the items table, the billing heading and the total row do not.
expect(html).not.toContain('class="items"');
expect(html).not.toContain("Celkem bez DPH");
});
it("renders the structured supplier address plus IČO and DIČ", () => { it("renders the structured supplier address plus IČO and DIČ", () => {
const html = renderIssuedOrderHtml( const html = renderIssuedOrderHtml(
order, order,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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");
} }
}); });

View File

@@ -0,0 +1,129 @@
import { describe, it, expect } from "vitest";
import {
encodeSelectedCustomFields,
parseSelectedCustomFields,
} from "../utils/custom-fields";
import { afterAll } from "vitest";
import { createOffer, getOffer } from "../services/offers.service";
import { renderOfferHtml } from "../routes/admin/offers-pdf";
import type { OfferForPdf } from "../routes/admin/offers-pdf";
import type { company_settings } from "@prisma/client";
import prisma from "../config/database";
describe("selected custom fields encode/decode", () => {
it("encodes a non-empty index array to a JSON string", () => {
expect(encodeSelectedCustomFields([0, 2])).toBe("[0,2]");
});
it("encodes empty / non-array to null", () => {
expect(encodeSelectedCustomFields([])).toBeNull();
expect(encodeSelectedCustomFields(undefined)).toBeNull();
expect(encodeSelectedCustomFields("nope")).toBeNull();
});
it("dedupes, sorts, and drops invalid entries before encoding", () => {
expect(encodeSelectedCustomFields([2, 0, 2, -1, 1.5, 3])).toBe("[0,2,3]");
});
it("parses a stored string back to a number array", () => {
expect(parseSelectedCustomFields("[0,2]")).toEqual([0, 2]);
});
it("parses null / malformed to an empty array", () => {
expect(parseSelectedCustomFields(null)).toEqual([]);
expect(parseSelectedCustomFields("")).toEqual([]);
expect(parseSelectedCustomFields("{garbage")).toEqual([]);
expect(parseSelectedCustomFields('"x"')).toEqual([]);
});
it("parses already-array input (defensive) and filters invalid", () => {
expect(parseSelectedCustomFields([0, "1", 2, -3] as unknown)).toEqual([
0, 2,
]);
});
});
describe("offers selected_custom_fields round-trip", () => {
const created: number[] = [];
afterAll(async () => {
if (created.length)
await prisma.quotations.deleteMany({ where: { id: { in: created } } });
});
it("persists selection on create and decodes it on detail", async () => {
const res = (await createOffer({
status: "draft",
selected_custom_fields: [2, 0, 0],
})) as { id: number };
created.push(res.id);
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
expect(row?.selected_custom_fields).toBe("[0,2]");
const detail = await getOffer(res.id);
expect(detail?.selected_custom_fields).toEqual([0, 2]);
});
it("stores null when selection is empty", async () => {
const res = (await createOffer({
status: "draft",
selected_custom_fields: [],
})) as { id: number };
created.push(res.id);
const row = await prisma.quotations.findUnique({ where: { id: res.id } });
expect(row?.selected_custom_fields).toBeNull();
});
});
describe("offer PDF prints only selected company custom fields", () => {
// Two company custom fields; the document selects only index 0.
const settings = {
company_name: "Test s.r.o.",
custom_fields: JSON.stringify({
fields: [
{ name: "Email", value: "selected@example.com", showLabel: false },
{ name: "Web", value: "notselected.example.com", showLabel: false },
],
field_order: [],
}),
logo_data: null,
} as unknown as company_settings;
const baseQuotation = {
quotation_number: "TEST-2098-001",
language: "EN",
currency: "EUR",
valid_until: new Date("2098-01-01"),
project_code: null,
created_at: new Date("2098-01-01"),
customers: { name: "Cust" },
quotation_items: [],
scope_sections: [],
};
it("renders the selected custom field and omits the non-selected one", () => {
const html = renderOfferHtml(
{
...baseQuotation,
selected_custom_fields: "[0]",
} as unknown as OfferForPdf,
settings,
);
expect(html).toContain("selected@example.com");
expect(html).not.toContain("notselected.example.com");
});
it("prints NO company custom fields when the document selects none", () => {
// The company call site always passes a parsed array; a null column
// decodes to [] → empty selection → no custom fields on the document.
const html = renderOfferHtml(
{
...baseQuotation,
selected_custom_fields: null,
} as unknown as OfferForPdf,
settings,
);
expect(html).not.toContain("selected@example.com");
expect(html).not.toContain("notselected.example.com");
});
});

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

@@ -19,6 +19,9 @@ import tripsRoutes from "../routes/admin/trips";
// WHOLE filtered set (not one 25-row page), honors the month filter in // WHOLE filtered set (not one 25-row page), honors the month filter in
// both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes // both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes
// non-managers to their own trips exactly like the list does. // non-managers to their own trips exactly like the list does.
// 3. POST /trips rejects a non-manager filing a trip under someone else's
// user_id with an explicit Czech 403 (impersonation gap), while
// self-creates and manager on-behalf creates keep working.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Note-prefix for test-created rows so cleanup never touches real data. */ /** Note-prefix for test-created rows so cleanup never touches real data. */
@@ -66,6 +69,18 @@ async function authGet(path: string, token: string) {
}); });
} }
async function authPost(path: string, token: string, body: unknown) {
return app.inject({
method: "POST",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
async function authPut(path: string, token: string, body: unknown) { async function authPut(path: string, token: string, body: unknown) {
return app.inject({ return app.inject({
method: "PUT", method: "PUT",
@@ -539,3 +554,88 @@ describe("GET /api/admin/trips/stats", () => {
expect(filtered.json().data.count).toBe(2); expect(filtered.json().data.count).toBe(2);
}); });
}); });
describe("POST /api/admin/trips — on-behalf authorization", () => {
function postBody(params: { userId?: number; note: string }) {
return {
vehicle_id: vehicleAId,
...(params.userId !== undefined ? { user_id: params.userId } : {}),
trip_date: "2098-11-10",
start_km: 100,
end_km: 150,
route_from: "Praha",
route_to: "Brno",
is_business: true,
notes: `${N}${params.note}`,
};
}
it("rejects a non-manager filing a trip under another user's id with 403", async () => {
const res = await authPost(
"/api/admin/trips",
scopeToken,
postBody({ userId: adminUserId, note: "authz_spoof" }),
);
expect(res.statusCode).toBe(403);
expect(res.json()).toEqual({
success: false,
error: "Nemáte oprávnění zadat jízdu za jiného uživatele",
});
// …and the trip must NOT have been created.
const created = await prisma.trips.findFirst({
where: { notes: `${N}authz_spoof` },
});
expect(created).toBeNull();
});
it("lets a non-manager create a trip without user_id (defaults to self)", async () => {
const res = await authPost(
"/api/admin/trips",
scopeToken,
postBody({ note: "authz_self_implicit" }),
);
expect(res.statusCode).toBe(201);
expect(res.json().success).toBe(true);
const created = await prisma.trips.findFirst({
where: { notes: `${N}authz_self_implicit` },
});
expect(created).not.toBeNull();
expect(created!.user_id).toBe(scopeUserId);
});
it("lets a non-manager create a trip with their OWN user_id", async () => {
const res = await authPost(
"/api/admin/trips",
scopeToken,
postBody({ userId: scopeUserId, note: "authz_self_explicit" }),
);
expect(res.statusCode).toBe(201);
const created = await prisma.trips.findFirst({
where: { notes: `${N}authz_self_explicit` },
});
expect(created).not.toBeNull();
expect(created!.user_id).toBe(scopeUserId);
});
it("lets a manager/admin create a trip on behalf of another user", async () => {
const res = await authPost(
"/api/admin/trips",
adminToken,
postBody({ userId: scopeUserId, note: "authz_on_behalf" }),
);
expect(res.statusCode).toBe(201);
const created = await prisma.trips.findFirst({
where: { notes: `${N}authz_on_behalf` },
});
expect(created).not.toBeNull();
expect(created!.user_id).toBe(scopeUserId);
});
});

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,24 @@
import { describe, it, expect } from "vitest";
import Fastify from "fastify";
import { registerVersionHeader, readAppVersion } from "../middleware/version";
/**
* The server stamps every response with X-App-Version so the SPA can detect a
* deploy (the client compares it to the version baked into its bundle).
*/
describe("registerVersionHeader", () => {
it("adds X-App-Version to responses", async () => {
const app = Fastify({ logger: false });
registerVersionHeader(app, "9.9.9");
app.get("/ping", async () => ({ ok: true }));
const res = await app.inject({ method: "GET", url: "/ping" });
expect(res.headers["x-app-version"]).toBe("9.9.9");
await app.close();
});
it("defaults to the package.json version (semver string)", () => {
expect(readAppVersion()).toMatch(/^\d+\.\d+\.\d+/);
});
});

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

@@ -7,65 +7,97 @@ import { queryClient } from "./lib/queryClient";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import AppShell from "./ui/AppShell"; import AppShell from "./ui/AppShell";
import AlertContainer from "./components/AlertContainer"; import AlertContainer from "./components/AlertContainer";
import UpdateBanner from "./components/UpdateBanner";
import { lazyWithReload } from "./utils/lazyWithReload";
import MuiProvider from "./ui/MuiProvider"; import MuiProvider from "./ui/MuiProvider";
import { LoadingState } from "./ui"; import { LoadingState } from "./ui";
import Login from "./pages/Login"; import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
const Odin = lazy(() => import("./pages/Odin")); const Dashboard = lazyWithReload(() => import("./pages/Dashboard"));
const Users = lazy(() => import("./pages/Users")); const Odin = lazyWithReload(() => import("./pages/Odin"));
const Attendance = lazy(() => import("./pages/Attendance")); const Users = lazyWithReload(() => import("./pages/Users"));
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory")); const Attendance = lazyWithReload(() => import("./pages/Attendance"));
const AttendanceAdmin = lazy(() => import("./pages/AttendanceAdmin")); const AttendanceHistory = lazyWithReload(
const AttendanceBalances = lazy(() => import("./pages/AttendanceBalances")); () => import("./pages/AttendanceHistory"),
const AttendanceCreate = lazy(() => import("./pages/AttendanceCreate")); );
const LeaveRequests = lazy(() => import("./pages/LeaveRequests")); const AttendanceAdmin = lazyWithReload(() => import("./pages/AttendanceAdmin"));
const LeaveApproval = lazy(() => import("./pages/LeaveApproval")); const AttendanceBalances = lazyWithReload(
const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation")); () => import("./pages/AttendanceBalances"),
const PlanWork = lazy(() => import("./pages/PlanWork")); );
const Trips = lazy(() => import("./pages/Trips")); const AttendanceCreate = lazyWithReload(
const TripsHistory = lazy(() => import("./pages/TripsHistory")); () => import("./pages/AttendanceCreate"),
const TripsAdmin = lazy(() => import("./pages/TripsAdmin")); );
const Vehicles = lazy(() => import("./pages/Vehicles")); const LeaveRequests = lazyWithReload(() => import("./pages/LeaveRequests"));
const Offers = lazy(() => import("./pages/Offers")); const LeaveApproval = lazyWithReload(() => import("./pages/LeaveApproval"));
const OfferDetail = lazy(() => import("./pages/OfferDetail")); const AttendanceLocation = lazyWithReload(
const OffersCustomers = lazy(() => import("./pages/OffersCustomers")); () => import("./pages/AttendanceLocation"),
const OffersTemplates = lazy(() => import("./pages/OffersTemplates")); );
const Orders = lazy(() => import("./pages/Orders")); const PlanWork = lazyWithReload(() => import("./pages/PlanWork"));
const OrderDetail = lazy(() => import("./pages/OrderDetail")); const Trips = lazyWithReload(() => import("./pages/Trips"));
const IssuedOrderDetail = lazy(() => import("./pages/IssuedOrderDetail")); const TripsHistory = lazyWithReload(() => import("./pages/TripsHistory"));
const Projects = lazy(() => import("./pages/Projects")); const TripsAdmin = lazyWithReload(() => import("./pages/TripsAdmin"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail")); const Vehicles = lazyWithReload(() => import("./pages/Vehicles"));
const Invoices = lazy(() => import("./pages/Invoices")); const Offers = lazyWithReload(() => import("./pages/Offers"));
const InvoiceDetail = lazy(() => import("./pages/InvoiceDetail")); const OfferDetail = lazyWithReload(() => import("./pages/OfferDetail"));
const Settings = lazy(() => import("./pages/Settings")); const OffersCustomers = lazyWithReload(() => import("./pages/OffersCustomers"));
const AuditLog = lazy(() => import("./pages/AuditLog")); const OffersTemplates = lazyWithReload(() => import("./pages/OffersTemplates"));
const NotFound = lazy(() => import("./pages/NotFound")); const Orders = lazyWithReload(() => import("./pages/Orders"));
const Warehouse = lazy(() => import("./pages/Warehouse")); const OrderDetail = lazyWithReload(() => import("./pages/OrderDetail"));
const WarehouseItems = lazy(() => import("./pages/WarehouseItems")); const IssuedOrderDetail = lazyWithReload(
const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail")); () => import("./pages/IssuedOrderDetail"),
const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts")); );
const WarehouseReceiptDetail = lazy( const Projects = lazyWithReload(() => import("./pages/Projects"));
const ProjectDetail = lazyWithReload(() => import("./pages/ProjectDetail"));
const Invoices = lazyWithReload(() => import("./pages/Invoices"));
const InvoiceDetail = lazyWithReload(() => import("./pages/InvoiceDetail"));
const Settings = lazyWithReload(() => import("./pages/Settings"));
const AuditLog = lazyWithReload(() => import("./pages/AuditLog"));
const NotFound = lazyWithReload(() => import("./pages/NotFound"));
const Warehouse = lazyWithReload(() => import("./pages/Warehouse"));
const WarehouseItems = lazyWithReload(() => import("./pages/WarehouseItems"));
const WarehouseItemDetail = lazyWithReload(
() => import("./pages/WarehouseItemDetail"),
);
const WarehouseReceipts = lazyWithReload(
() => import("./pages/WarehouseReceipts"),
);
const WarehouseReceiptDetail = lazyWithReload(
() => import("./pages/WarehouseReceiptDetail"), () => import("./pages/WarehouseReceiptDetail"),
); );
const WarehouseReceiptForm = lazy(() => import("./pages/WarehouseReceiptForm")); const WarehouseReceiptForm = lazyWithReload(
const WarehouseIssues = lazy(() => import("./pages/WarehouseIssues")); () => import("./pages/WarehouseReceiptForm"),
const WarehouseIssueDetail = lazy(() => import("./pages/WarehouseIssueDetail")); );
const WarehouseIssueForm = lazy(() => import("./pages/WarehouseIssueForm")); const WarehouseIssues = lazyWithReload(() => import("./pages/WarehouseIssues"));
const WarehouseReservations = lazy( const WarehouseIssueDetail = lazyWithReload(
() => import("./pages/WarehouseIssueDetail"),
);
const WarehouseIssueForm = lazyWithReload(
() => import("./pages/WarehouseIssueForm"),
);
const WarehouseReservations = lazyWithReload(
() => import("./pages/WarehouseReservations"), () => import("./pages/WarehouseReservations"),
); );
const WarehouseInventory = lazy(() => import("./pages/WarehouseInventory")); const WarehouseInventory = lazyWithReload(
const WarehouseInventoryDetail = lazy( () => import("./pages/WarehouseInventory"),
);
const WarehouseInventoryDetail = lazyWithReload(
() => import("./pages/WarehouseInventoryDetail"), () => import("./pages/WarehouseInventoryDetail"),
); );
const WarehouseInventoryForm = lazy( const WarehouseInventoryForm = lazyWithReload(
() => import("./pages/WarehouseInventoryForm"), () => import("./pages/WarehouseInventoryForm"),
); );
const WarehouseReports = lazy(() => import("./pages/WarehouseReports")); const WarehouseReports = lazyWithReload(
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers")); () => import("./pages/WarehouseReports"),
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations")); );
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories")); const WarehouseSuppliers = lazyWithReload(
() => import("./pages/WarehouseSuppliers"),
);
const WarehouseLocations = lazyWithReload(
() => import("./pages/WarehouseLocations"),
);
const WarehouseCategories = lazyWithReload(
() => import("./pages/WarehouseCategories"),
);
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null; const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
export default function AdminApp() { export default function AdminApp() {
@@ -75,6 +107,7 @@ export default function AdminApp() {
<AlertProvider> <AlertProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AlertContainer /> <AlertContainer />
<UpdateBanner />
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<LoadingState />}> <Suspense fallback={<LoadingState />}>
<Routes> <Routes>

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

@@ -0,0 +1,42 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { menuSections, isItemActive } from "../ui/navData";
/**
* Null-rendering helper that keeps the browser tab title in sync with the
* active navigation item. Scans menuSections in order (first match wins) via
* the same isItemActive logic the sidebar uses, so detail routes covered by
* matchPrefix (e.g. /offers/123) inherit their section item's label.
* No cleanup on unmount — the last title simply persists.
*/
// Labels reused across sections ("Záznam", "Moje historie", "Správa",
// "Přehled") would produce identical tab titles — qualify those with their
// section label so /attendance and /trips tabs stay distinguishable.
const labelCounts = new Map<string, number>();
for (const section of menuSections) {
for (const item of section.items) {
labelCounts.set(item.label, (labelCounts.get(item.label) ?? 0) + 1);
}
}
export default function TitleSync() {
const { pathname } = useLocation();
useEffect(() => {
for (const section of menuSections) {
const item = section.items.find((candidate) =>
isItemActive(candidate, pathname),
);
if (item) {
const ambiguous = (labelCounts.get(item.label) ?? 0) > 1;
document.title = ambiguous
? `${item.label} ${section.label} · BOHA`
: `${item.label} · BOHA`;
return;
}
}
document.title = "BOHA Admin";
}, [pathname]);
return null;
}

View File

@@ -0,0 +1,35 @@
import { useSyncExternalStore } from "react";
import Snackbar from "@mui/material/Snackbar";
import Button from "@mui/material/Button";
import { subscribeNewVersion, getNewVersion } from "../utils/appVersion";
/**
* Shows a persistent banner when the server reports a newer build than the one
* this tab is running (detected via the X-App-Version header in apiFetch). The
* reload is user-initiated — we never force it, so an in-progress form is safe.
*/
export default function UpdateBanner() {
const newVersion = useSyncExternalStore(
subscribeNewVersion,
getNewVersion,
() => null,
);
return (
<Snackbar
open={Boolean(newVersion)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
message="Je k dispozici nová verze aplikace."
action={
<Button
color="primary"
size="small"
variant="contained"
onClick={() => window.location.reload()}
>
Obnovit
</Button>
}
/>
);
}

View File

@@ -12,6 +12,7 @@ import DialogActions from "@mui/material/DialogActions";
import { useAuth } from "../../context/AuthContext"; import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext"; import { useAlert } from "../../context/AlertContext";
import apiFetch from "../../utils/api"; import apiFetch from "../../utils/api";
import { USER_INVALIDATE } from "../../lib/queries/users";
import { iconBadgeSx } from "../../theme"; import { iconBadgeSx } from "../../theme";
import { Card, Button, Modal, Field, TextField } from "../../ui"; import { Card, Button, Modal, Field, TextField } from "../../ui";
import useDialogScrollLock from "../../ui/useDialogScrollLock"; import useDialogScrollLock from "../../ui/useDialogScrollLock";
@@ -142,6 +143,9 @@ export default function DashProfile({
const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({ const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({
queryKey: ["totp", "qr", totpQrUri], queryKey: ["totp", "qr", totpQrUri],
enabled: !!totpQrUri, enabled: !!totpQrUri,
// Purely local QR generation — the global "server data" error toast would
// misattribute a failure here; the totpQrFailed inline message covers it.
meta: { suppressGlobalErrorToast: true },
staleTime: Infinity, staleTime: Infinity,
// The URI embeds the TOTP secret — drop it from the cache as soon as the // The URI embeds the TOTP secret — drop it from the cache as soon as the
// enrollment UI unmounts instead of keeping it for the default 5-min GC. // enrollment UI unmounts instead of keeping it for the default 5-min GC.
@@ -221,9 +225,13 @@ export default function DashProfile({
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(), fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
}); });
// Refresh anything keyed on the current user's data so stale views // Refresh anything keyed on the current user's data so stale views
// (dashboard widgets, user lists) pick up the edited profile. // pick up the edited profile — a self-rename must also refresh the
// domains embedding the display name (USER_INVALIDATE), not just the
// dashboard and user list.
for (const key of USER_INVALIDATE) {
queryClient.invalidateQueries({ queryKey: [key] });
}
queryClient.invalidateQueries({ queryKey: ["dashboard"] }); queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
setShowModal(false); setShowModal(false);
// The 300ms wait is load-bearing: it lets the modal's close fade finish // The 300ms wait is load-bearing: it lets the modal's close fade finish
// before the success toast appears, so the toast doesn't flash over the // before the success toast appears, so the toast doesn't flash over the
@@ -262,8 +270,9 @@ export default function DashProfile({
initial={reduce ? false : { opacity: 0, y: 12 }} initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }} animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }} transition={{ duration: 0.25, delay: 0.15 }}
style={{ height: "100%" }}
> >
<Card> <Card sx={{ height: "100%" }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",

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

@@ -132,8 +132,9 @@ export default function DashSessions() {
initial={reduce ? false : { opacity: 0, y: 12 }} initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }} animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }} transition={{ duration: 0.25, delay: 0.15 }}
style={{ height: "100%" }}
> >
<Card sx={{ display: "flex", flexDirection: "column" }}> <Card sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",

View File

@@ -0,0 +1,60 @@
import { FormControlLabel, Checkbox, Box, Typography } from "@mui/material";
import type { CompanySettingsCustomField } from "../../lib/queries/settings";
interface Props {
/** Company custom field definitions, in positional order (index = print key). */
fields: CompanySettingsCustomField[];
/** Currently selected positional indices. */
selected: number[];
/** Read-only (document not editable / locked by another user). */
disabled?: boolean;
onChange: (next: number[]) => void;
}
/**
* Per-document picker: choose which COMPANY custom fields print on this
* document's PDF. Selection is positional (matches the PDF `custom_<i>` keys).
* Renders nothing when the company has defined no printable custom fields.
*/
export default function CustomFieldsPrintPicker({
fields,
selected,
disabled = false,
onChange,
}: Props) {
const printable = fields.filter((f) => (f.value || "").trim());
if (printable.length === 0) return null;
const toggle = (idx: number, checked: boolean) => {
const set = new Set(selected);
if (checked) set.add(idx);
else set.delete(idx);
onChange([...set].sort((a, b) => a - b));
};
return (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Vlastní pole na PDF
</Typography>
{fields.map((f, idx) => {
if (!(f.value || "").trim()) return null;
const label = (f.name || "").trim() ? `${f.name}: ${f.value}` : f.value;
return (
<FormControlLabel
key={idx}
control={
<Checkbox
size="small"
checked={selected.includes(idx)}
disabled={disabled}
onChange={(e) => toggle(idx, e.target.checked)}
/>
}
label={<Typography variant="body2">{label}</Typography>}
/>
);
})}
</Box>
);
}

View File

@@ -49,10 +49,31 @@ export interface DocumentItem {
quantity: string | number; quantity: string | number;
unit: string; unit: string;
unit_price: string | number; unit_price: string | number;
/** Per-item discount as a percentage (0100). Offers only; 0 elsewhere. */
discount: string | number;
/** Offers only — undefined (issued orders) counts as included. */ /** Offers only — undefined (issued orders) counts as included. */
is_included_in_total?: boolean; is_included_in_total?: boolean;
} }
/**
* Discount input styling: centered, and with the native number spinners hidden
* so a 3-digit value ("100") isn't clipped under the up/down arrows.
*/
const discountInputSx = {
"& input": { textAlign: "center" },
"& input[type=number]": { MozAppearance: "textfield" as const },
"& input::-webkit-outer-spin-button, & input::-webkit-inner-spin-button": {
WebkitAppearance: "none",
margin: 0,
},
};
/** NET of one line after its per-item percentage discount. */
const discountedLineNet = (item: DocumentItem): number =>
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0) *
(1 - (Number(item.discount) || 0) / 100);
let documentItemKeyCounter = 0; let documentItemKeyCounter = 0;
/** Unique client-side key for a (new or hydrated) document item row. */ /** Unique client-side key for a (new or hydrated) document item row. */
@@ -66,6 +87,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
quantity: 1, quantity: 1,
unit: "ks", unit: "ks",
unit_price: 0, unit_price: 0,
discount: 0,
is_included_in_total: true, is_included_in_total: true,
}); });
@@ -73,9 +95,7 @@ export const emptyDocumentItem = (): DocumentItem => ({
export const documentItemsTotal = (items: DocumentItem[]): number => export const documentItemsTotal = (items: DocumentItem[]): number =>
items.reduce( items.reduce(
(sum, it) => (sum, it) =>
it.is_included_in_total === false it.is_included_in_total === false ? sum : sum + discountedLineNet(it),
? sum
: sum + (Number(it.quantity) || 0) * (Number(it.unit_price) || 0),
0, 0,
); );
@@ -111,6 +131,7 @@ function SortableItemRow({
readOnly, readOnly,
canDelete, canDelete,
showIncludedInTotal, showIncludedInTotal,
showDiscount,
itemDescriptionMaxLength, itemDescriptionMaxLength,
onUpdate, onUpdate,
onRemove, onRemove,
@@ -121,6 +142,7 @@ function SortableItemRow({
readOnly: boolean; readOnly: boolean;
canDelete: boolean; canDelete: boolean;
showIncludedInTotal: boolean; showIncludedInTotal: boolean;
showDiscount: boolean;
itemDescriptionMaxLength: number; itemDescriptionMaxLength: number;
onUpdate: (field: keyof DocumentItem, value: string | boolean) => void; onUpdate: (field: keyof DocumentItem, value: string | boolean) => void;
onRemove: () => void; onRemove: () => void;
@@ -143,8 +165,7 @@ function SortableItemRow({
position: "relative" as const, position: "relative" as const,
zIndex: isDragging ? 10 : undefined, zIndex: isDragging ? 10 : undefined,
}; };
const lineTotal = const lineTotal = discountedLineNet(item);
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
if (isMobile) { if (isMobile) {
return ( return (
@@ -214,7 +235,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" } }}
/> />
@@ -252,6 +274,17 @@ function SortableItemRow({
InputProps={{ readOnly }} InputProps={{ readOnly }}
sx={{ "& input": { textAlign: "right" } }} sx={{ "& input": { textAlign: "right" } }}
/> />
{showDiscount && (
<TextField
label="Sleva %"
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate("discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
InputProps={{ readOnly }}
sx={discountInputSx}
/>
)}
</Box> </Box>
<Box <Box
sx={{ sx={{
@@ -352,7 +385,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": {
@@ -394,6 +428,18 @@ function SortableItemRow({
sx={{ "& input": { textAlign: "right" } }} sx={{ "& input": { textAlign: "right" } }}
/> />
</TableCell> </TableCell>
{showDiscount && (
<TableCell>
<TextField
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate("discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
InputProps={{ readOnly }}
sx={discountInputSx}
/>
</TableCell>
)}
{showIncludedInTotal && ( {showIncludedInTotal && (
<TableCell align="center"> <TableCell align="center">
<Checkbox <Checkbox
@@ -440,10 +486,18 @@ interface DocumentItemsEditorProps {
error?: string; error?: string;
/** Render the offers-only "V ceně" (is_included_in_total) column. */ /** Render the offers-only "V ceně" (is_included_in_total) column. */
showIncludedInTotal?: boolean; showIncludedInTotal?: boolean;
/** Render the per-item "Sleva %" discount column (offers only). */
showDiscount?: boolean;
/** Schema cap for the detailed description (offers 8000, issued orders 5000). */ /** Schema cap for the detailed description (offers 8000, issued orders 5000). */
itemDescriptionMaxLength?: number; itemDescriptionMaxLength?: number;
/** Optional page-specific control (e.g. item-template Select) next to the add button. */ /** Optional page-specific control (e.g. item-template Select) next to the add button. */
templatesSlot?: ReactNode; templatesSlot?: ReactNode;
/**
* Allow removing every row so the list can be empty (issued orders may be
* issued by sections alone). Default false keeps the offers/invoices rule of
* at least one item.
*/
allowEmpty?: boolean;
} }
/** /**
@@ -458,8 +512,10 @@ export default function DocumentItemsEditor({
readOnly, readOnly,
error, error,
showIncludedInTotal = false, showIncludedInTotal = false,
showDiscount = false,
itemDescriptionMaxLength = 5000, itemDescriptionMaxLength = 5000,
templatesSlot, templatesSlot,
allowEmpty = false,
}: DocumentItemsEditorProps) { }: DocumentItemsEditorProps) {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
@@ -489,10 +545,13 @@ export default function DocumentItemsEditor({
const addItem = () => onChange([...items, emptyDocumentItem()]); const addItem = () => onChange([...items, emptyDocumentItem()]);
const removeItem = (index: number) => { const removeItem = (index: number) => {
if (items.length <= 1) return; if (!allowEmpty && items.length <= 1) return;
onChange(items.filter((_, i) => i !== index)); onChange(items.filter((_, i) => i !== index));
}; };
// When allowEmpty, even the last row is deletable (list may go to zero).
const canDeleteRow = allowEmpty || items.length > 1;
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return;
@@ -559,8 +618,9 @@ export default function DocumentItemsEditor({
index={index} index={index}
currency={currency} currency={currency}
readOnly={readOnly} readOnly={readOnly}
canDelete={items.length > 1} canDelete={canDeleteRow}
showIncludedInTotal={showIncludedInTotal} showIncludedInTotal={showIncludedInTotal}
showDiscount={showDiscount}
itemDescriptionMaxLength={itemDescriptionMaxLength} itemDescriptionMaxLength={itemDescriptionMaxLength}
onUpdate={(field, value) => updateItem(index, field, value)} onUpdate={(field, value) => updateItem(index, field, value)}
onRemove={() => removeItem(index)} onRemove={() => removeItem(index)}
@@ -592,6 +652,11 @@ export default function DocumentItemsEditor({
<TableCell sx={{ width: "8rem" }} align="center"> <TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena Jedn. cena
</TableCell> </TableCell>
{showDiscount && (
<TableCell sx={{ width: "7rem" }} align="center">
Sleva %
</TableCell>
)}
{showIncludedInTotal && ( {showIncludedInTotal && (
<TableCell sx={{ width: "4rem" }} align="center"> <TableCell sx={{ width: "4rem" }} align="center">
V ceně V ceně
@@ -611,8 +676,9 @@ export default function DocumentItemsEditor({
index={index} index={index}
currency={currency} currency={currency}
readOnly={readOnly} readOnly={readOnly}
canDelete={items.length > 1} canDelete={canDeleteRow}
showIncludedInTotal={showIncludedInTotal} showIncludedInTotal={showIncludedInTotal}
showDiscount={showDiscount}
itemDescriptionMaxLength={itemDescriptionMaxLength} itemDescriptionMaxLength={itemDescriptionMaxLength}
onUpdate={(field, value) => onUpdate={(field, value) =>
updateItem(index, field, value) updateItem(index, field, value)

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";
@@ -102,6 +103,10 @@ export default function OdinChat() {
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const threadRef = useRef<HTMLDivElement>(null); const threadRef = useRef<HTMLDivElement>(null);
const seededId = useRef<number | null>(null); const seededId = useRef<number | null>(null);
// Drag & drop over the chat column. The depth counter pairs enter/leave
// events bubbling from children so the overlay doesn't flicker mid-drag.
const [dragOver, setDragOver] = useState(false);
const dragDepth = useRef(0);
// Below md the conversation list collapses into a slide-in drawer. // Below md the conversation list collapses into a slide-in drawer.
const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), { const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), {
@@ -109,6 +114,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 +138,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 +165,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 } }
: {}),
})), })),
}), }),
}) })
@@ -161,17 +181,63 @@ export default function OdinChat() {
}; };
// Attaching only stages files; nothing is sent until Odeslat. // Attaching only stages files; nothing is sent until Odeslat.
const onFiles = (files: FileList | null) => { const stageFiles = (files: File[]) => {
if (!files || files.length === 0) return; if (files.length === 0) return;
setAttachments((a) => [ setAttachments((a) => [
...a, ...a,
...Array.from(files).map((file) => ({ id: nextUid(), file })), ...files.map((file) => ({ id: nextUid(), file })),
]); ]);
};
const onFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
stageFiles(Array.from(files));
if (fileRef.current) fileRef.current.value = ""; if (fileRef.current) fileRef.current.value = "";
}; };
const removeAttachment = (id: string) => const removeAttachment = (id: string) =>
setAttachments((a) => a.filter((s) => s.id !== id)); setAttachments((a) => a.filter((s) => s.id !== id));
// Drag & drop anywhere over the chat column stages files exactly like the
// paperclip (same accept filter as the file input). Only file drags react —
// text/link drags pass through untouched — and nothing stages while busy.
const isAccepted = (f: File) =>
f.type === "application/pdf" || f.type.startsWith("image/");
const dragHasFiles = (e: React.DragEvent) =>
Array.from(e.dataTransfer.types).includes("Files");
const onDragEnter = (e: React.DragEvent) => {
if (busy || !dragHasFiles(e)) return;
e.preventDefault();
dragDepth.current += 1;
setDragOver(true);
};
const onDragOver = (e: React.DragEvent) => {
if (!dragHasFiles(e)) return;
// preventDefault permits the drop (and keeps the browser from replacing
// the app with the dropped file); while busy the drop itself is refused.
e.preventDefault();
if (busy) e.dataTransfer.dropEffect = "none";
};
const onDragLeave = () => {
if (dragDepth.current === 0) return;
dragDepth.current -= 1;
if (dragDepth.current === 0) setDragOver(false);
};
const onDrop = (e: React.DragEvent) => {
if (!dragHasFiles(e)) return;
e.preventDefault();
dragDepth.current = 0;
setDragOver(false);
if (busy) return;
const dropped = Array.from(e.dataTransfer.files);
const accepted = dropped.filter(isAccepted);
if (accepted.length === 0) {
// Nothing usable — hint instead of silently doing nothing.
if (dropped.length > 0) alert.info("Podporované soubory: PDF a obrázky");
return;
}
stageFiles(accepted);
};
const onSelect = (id: number) => { const onSelect = (id: number) => {
setSidebarOpen(false); setSidebarOpen(false);
if (id === activeId || busy) return; if (id === activeId || busy) return;
@@ -208,26 +274,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 +292,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 +336,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 +355,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 +481,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",
}} }}
@@ -443,24 +517,55 @@ export default function OdinChat() {
sidebar sidebar
)} )}
{/* Chat column */} {/* Chat column — also the drop target for invoice files (overlay below). */}
<Box <Box
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
sx={{ sx={{
position: "relative",
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
display: "flex", display: "flex",
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 +595,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>
@@ -508,11 +612,14 @@ export default function OdinChat() {
{review.length > 0 && ( {review.length > 0 && (
<Box <Box
sx={{ sx={{
maxHeight: "35vh", maxHeight: "40vh",
overflowY: "auto", overflowY: "auto",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 1, gap: 1,
// Flex children shrink by default — with 2+ cards they'd get
// compressed to fit instead of overflowing into the scrollbar.
"& > *": { flexShrink: 0 },
}} }}
> >
{review.map((inv) => ( {review.map((inv) => (
@@ -542,6 +649,63 @@ export default function OdinChat() {
onRemoveAttachment={removeAttachment} onRemoveAttachment={removeAttachment}
onSubmit={submit} onSubmit={submit}
/> />
{/* Drop-target overlay — pointer-events pass through so the drag
events keep firing on the column beneath it. */}
{dragOver && (
<Box
sx={(t) => ({
position: "absolute",
inset: 0,
zIndex: 10,
pointerEvents: "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
// Longhand on purpose — see the container border note above.
borderStyle: "dashed",
borderWidth: 2,
borderColor: "primary.main",
borderRadius: { xs: 0, md: 3 },
bgcolor: `rgba(${t.vars!.palette.primary.mainChannel} / 0.08)`,
})}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 0.5,
px: 3,
py: 2,
borderRadius: 2,
bgcolor: "background.paper",
boxShadow: 3,
}}
>
<Box
component="svg"
viewBox="0 0 24 24"
sx={{ width: 32, height: 32, color: "primary.main", mb: 0.5 }}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Přetáhněte soubory sem
</Typography>
<Typography variant="caption" color="text.secondary">
PDF nebo obrázky faktur
</Typography>
</Box>
</Box>
)}
</Box> </Box>
</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

@@ -8,6 +8,7 @@ import {
useEffect, useEffect,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { registerQueryErrorNotifier } from "../lib/queryClient";
interface Alert { interface Alert {
id: string; id: string;
@@ -43,9 +44,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();
}; };
}, []); }, []);
@@ -80,6 +82,13 @@ export function AlertProvider({ children }: { children: ReactNode }) {
[addAlert, removeAlert], [addAlert, removeAlert],
); );
// Global React Query error toasts (queryClient.ts). `addAlert` is a stable
// useCallback, so this registers once on mount; re-registering the same
// handler is idempotent either way.
useEffect(() => {
registerQueryErrorNotifier((msg) => addAlert(msg, "error"));
}, [addAlert]);
return ( return (
<AlertContext.Provider value={methods}> <AlertContext.Provider value={methods}>
<AlertStateContext.Provider value={{ alerts, removeAlert }}> <AlertStateContext.Provider value={{ alerts, removeAlert }}>

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
[], [],
); );
@@ -248,7 +253,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return { success: false, error: data.error }; return { success: false, error: data.error };
} catch { } catch {
const errorMsg = const errorMsg =
"Chyba pripojeni. Zkontrolujte prosim pripojeni k internetu a zkuste to znovu."; "Chyba připojení. Zkontrolujte prosím připojení k internetu a zkuste to znovu.";
setError(errorMsg); setError(errorMsg);
return { success: false, error: errorMsg }; return { success: false, error: errorMsg };
} }
@@ -306,7 +311,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setError(data.error); setError(data.error);
return { success: false, error: data.error }; return { success: false, error: data.error };
} catch { } catch {
const errorMsg = "Chyba pripojeni."; const errorMsg = "Chyba připojení.";
setError(errorMsg); setError(errorMsg);
return { success: false, error: errorMsg }; return { success: false, error: errorMsg };
} }

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

@@ -1,6 +1,6 @@
/** /**
* Single source of truth for document status labels (Czech) + chip colors * Single source of truth for document status labels (Czech) + chip colors
* (MUI semantic) across the Offers / Invoices / Orders modules. * (MUI semantic) across the Offers / Invoices / Orders / Projects modules.
* *
* Previously every list AND detail page defined its own `STATUS_LABELS` + * Previously every list AND detail page defined its own `STATUS_LABELS` +
* `STATUS_COLORS` maps. They drifted — most notably the issued-invoice status * `STATUS_COLORS` maps. They drifted — most notably the issued-invoice status
@@ -46,7 +46,7 @@ export const ORDER_STATUS: Record<string, StatusMeta> = {
prijata: { label: "Přijatá", color: "info" }, prijata: { label: "Přijatá", color: "info" },
v_realizaci: { label: "V realizaci", color: "warning" }, v_realizaci: { label: "V realizaci", color: "warning" },
dokoncena: { label: "Dokončená", color: "success" }, dokoncena: { label: "Dokončená", color: "success" },
stornovana: { label: "Stornována", color: "error" }, stornovana: { label: "Stornovaná", color: "error" },
}; };
/** ISSUED ORDER / objednávka vydaná (issued_orders). */ /** ISSUED ORDER / objednávka vydaná (issued_orders). */
@@ -75,6 +75,17 @@ export const RECEIVED_INVOICE_STATUS: Record<string, StatusMeta> = {
paid: { label: "Uhrazena", color: "success" }, paid: { label: "Uhrazena", color: "success" },
}; };
/**
* PROJECT (projects). Czech-keyed like customer orders. Colors follow the
* app-wide convention: open/in-progress = info, done = success,
* cancelled = error.
*/
export const PROJECT_STATUS: Record<string, StatusMeta> = {
aktivni: { label: "Aktivní", color: "info" },
dokonceny: { label: "Dokončený", color: "success" },
zruseny: { label: "Zrušený", color: "error" },
};
/** /**
* Display label for a document's official number. Drafts (deferred numbering) * Display label for a document's official number. Drafts (deferred numbering)
* have a NULL/empty number until they are finalized — show "Koncept" instead * have a NULL/empty number until they are finalized — show "Koncept" instead

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

@@ -1,28 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery } from "../apiAdapter";
export const auditLogOptions = (filters: {
search?: string;
action?: string;
entityType?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
}) =>
queryOptions({
queryKey: ["audit-log", filters],
queryFn: () => {
const params = new URLSearchParams();
if (filters.search) params.set("search", filters.search);
if (filters.action) params.set("action", filters.action);
if (filters.entityType) params.set("entity_type", filters.entityType);
if (filters.dateFrom) params.set("date_from", filters.dateFrom);
if (filters.dateTo) params.set("date_to", filters.dateTo);
if (filters.page) params.set("page", String(filters.page));
const qs = params.toString();
return jsonQuery<{
data: Record<string, unknown>[];
pagination: Record<string, unknown>;
}>(`/api/admin/audit-log${qs ? `?${qs}` : ""}`);
},
});

View File

@@ -38,6 +38,7 @@ export interface InvoiceItem {
quantity: number; quantity: number;
unit: string; unit: string;
unit_price: number; unit_price: number;
discount?: number;
vat_rate?: number; vat_rate?: number;
is_included_in_total: boolean; is_included_in_total: boolean;
position?: number; position?: number;
@@ -84,6 +85,7 @@ export interface InvoiceDetail {
bank_account_id?: number | null; bank_account_id?: number | null;
items?: InvoiceItem[]; items?: InvoiceItem[];
sections?: InvoiceSection[]; sections?: InvoiceSection[];
selected_custom_fields?: number[];
subtotal: number; subtotal: number;
vat_amount: number; vat_amount: number;
total: number; total: number;

View File

@@ -60,6 +60,7 @@ export interface IssuedOrderDetail extends IssuedOrder {
sections: IssuedOrderSection[]; sections: IssuedOrderSection[];
supplier: Record<string, unknown> | null; supplier: Record<string, unknown> | null;
valid_transitions: string[]; valid_transitions: string[];
selected_custom_fields: number[];
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */ /** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
locked_by: { locked_by: {
user_id: number; user_id: number;
@@ -143,6 +144,9 @@ export const issuedOrderDetailOptions = (id: string | undefined) =>
// 404s (deleted/missing orders) are not transient. Retrying just spams // 404s (deleted/missing orders) are not transient. Retrying just spams
// GETs and keeps the detail page on an infinite spinner. // GETs and keeps the detail page on an infinite spinner.
retry: false, retry: false,
// IssuedOrderDetail toasts its own Czech error + navigates away (and
// suppresses it during delete) — the global QueryCache toast would double up.
meta: { suppressGlobalErrorToast: true },
}); });
export const issuedOrderNextNumberOptions = () => export const issuedOrderNextNumberOptions = () =>

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