Compare commits

..

46 Commits

Author SHA1 Message Date
BOHA
875ef4d79e chore(release): v2.4.47 — KPI cards + Bilance aligned with the print recap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:48:56 +02:00
BOHA
a679f6e8ac fix(attendance): KPI cards + Bilance now use the print-recap formulas
Verification found three divergences from the binding print recap:

- getWorkfund (Bilance page): overtime included nemoc (print Přesčas is
  worked + dovolená only) and missing ignored neplacené volno (print
  Chybějící hodiny counts every approved absence). Both now match; the
  response carries covered (manko base) + covered_prescas (Přesčas base)
  and the page's year aggregate sums them separately.
- AttendanceAdmin fond bar: used = worked + dovolená + worked_holiday +
  svatek — after v2.4.45 the last two fields hold the SAME value, so
  holiday work counted twice (regression). Coverage is now worked + all
  approved absences, and the ± badges show the exact print values
  (ut.prescas / ut.manko) instead of a derived delta; bar color follows
  Přesčas/Chybějící hodiny.
- Fond itself was already aligned everywhere (non-holiday weekdays × 8,
  prorated for the running month).

+1 route-parity test (real DB): worked 80 + dov 24 + nem 16 + nepl 8 vs
fond 176 → covered 128, covered_prescas 104, missing 48, overtime 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:47:26 +02:00
BOHA
3530dd247c chore(release): v2.4.46 — Chybějící hodiny (manko) in the payroll recap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:22:36 +02:00
BOHA
996012e3d3 feat(attendance): 'Chybějící hodiny' (manko) row — real hours below the fond
manko = max(0, fond − (worked_total + dovolená + nemoc + neplacené volno))
using REAL clock time, not the paušál. Supplement next to Přesčas — the
verified binding formulas are untouched.

Why: the paušál Odpracováno can nominally cover the fond while dozens of
real hours are missing (Dominik 6/2026: 21 days × 8 + 8h dovolená = 176 =
fond exactly, thanks to weekend shifts compensating 3 recordless weekdays —
yet real worked time was 136,5h). Přesčas = 0 correctly, and the 31,5h gap
vanished from every total. Now it's a permanent summary line, red+bold when
positive, plus a 'Chybí: Xh' chip on the admin screen cards.

+4 tests incl. the real June dataset as a pinned acceptance case (also
pins noc 6,75 — early shifts starting 05:30/05:45 legitimately count
night minutes before 06:00).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:12:20 +02:00
BOHA
3cd7cfcc34 chore(release): v2.4.45 — binding payroll-recap formulas (payslip-verified)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:03:28 +02:00
BOHA
68d9c224bc fix(attendance): binding payroll-recap formulas, back-computed against a real payslip
Formulas per the owner's verified spec (Dominik Vesecký 5/2026, exact to
a tenth of an hour — built-in acceptance dataset pins every line):

- fond        = (Po-Pá dny mimo svátek) × 8         [was: incl. holidays]
- odpracováno = (dny se záznamem Práce mimo svátek) × 8, paušálně
                [was: actual worked hours minus holiday work]
- přesčas     = max(0, (worked_total + dovolená) − fond)
                [was: worked only, holiday-including fond]
- vč. svátků  = odpracováno + přesčas  [was: worked_total]
- svátek/dovolená/so-ne/noc unchanged (already verified correct)

Expected 5/2026: fond 152 · odpracováno 144 · svátek 8 · dovolená 24 ·
přesčas 17,25→17,3 · vč. 161,25→161,3 · So/Ne 57,25 · noc 36,75 (the
payslip's 31 was the accountant's own error — she missed the 31.5.→1.6.
overnight shift; the spec mandates 36,75).

Two-shift days count once toward odpracováno; 'holiday' placeholder
records stay evidence-only; holiday table remains year-parametric
(fixed dates + computed Easter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:00:54 +02:00
BOHA
200aa0e1a8 chore(release): v2.4.44 — payroll recap per accountant spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:00:37 +02:00
BOHA
e3b414d22c fix(attendance): payroll recap per accountant spec; multi-shift day rows; shared computation
Rekapitulace (accountant notes, confirmed by owner 2026-07):
- Odpracováno = all worked hours EXCEPT hours worked on holidays
  (was floor(worked/8)×8 — a 3h day printed as 0h)
- Vč. svátků a přesčasů = ALL worked hours; line2 − line1 = Svátek
  (was worked + vacation − 8h×worked-holidays — could print less than
  actually worked, and vacation inflated it)
- Přesčas = max(0, worked − fond), month-level, never negative
  (was the sub-8h remainder + vacation — printed 3h overtime for a
  3h month, 8h overtime for pure vacation, and −6h when a holiday
  was worked)
- Svátek = hours actually WORKED on holiday dates
  (was 8h per UNworked holiday — opposite meaning)
- So/Ne, Práce v noci, Dovolená, Fond unchanged (verified correct)

Single shared computeMzdaSummary now feeds BOTH the print and the
admin screen (they had drifted: capped vs uncapped holiday adjustment).

Print day table: one row PER RECORD — days with two shifts (real in
prod data) silently dropped all but the last record from the table
while the totals counted them all.

+7 spec tests (accountant's own examples); print snapshot updated to
the new spec values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:00:17 +02:00
BOHA
89656ac2c6 chore(release): v2.4.43 — timezone-proof attendance print
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:26:44 +02:00
BOHA
94030230e7 fix(attendance): timezone-proof print dates; overnight-only time prefixes
Two bugs in the attendance admin print (Tisk on Správa docházky):

1. Viewer-timezone date shift: day rows are built from date-only strings
   ('2026-06-01'), which new Date() parses as UTC midnight per spec; local
   formatting then rendered the PREVIOUS day for any viewer west of UTC —
   a June print opened with '31. 5. Neděle' (a May row holding June 1's
   punches) and every weekday name + weekend tint shifted by one. Czech
   viewers were unaffected (UTC+1/+2), US viewers always hit it.
   formatDate / getCzechWeekday / isWeekendDate now parse the calendar day
   from the string parts — identical output in every timezone.

2. Every time cell carried a bogus date prefix ('1.6. 08:00'): the
   overnight-only guard compared the extracted date part against the raw
   wire shift_date ('2026-06-01T02:00:00') — never equal. Now compares
   normalized date parts; the prefix fires only for true overnight punches.

Verified end-to-end against the production June payload; +8 regression
tests (timezone-proof by construction — parts-based, no UTC parse).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:26:23 +02:00
BOHA
d859b5bbf7 chore(release): v2.4.42 — status quick actions + bidirectional project-order sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 04:34:57 +02:00
BOHA
5683912b76 feat(ui): quick status-action chip menus on offers/orders/projects; ProjectDetail transition buttons
- NEW shared StatusChipMenu: clickable status chip opens a dense menu of valid
  next states; picks confirm via the shared ConfirmDialog (danger variant,
  loading, stays open on failure); plain chip without edit permission;
  row-click-safe (stopPropagation both directions).
- Offers list: draft -> Aktivovat (number assignment noted, PDF archived after
  finalize like the detail); active -> 'Vytvořit objednávku…' (opens the
  existing create-order modal — 'ordered' stays owned by that flow) +
  Zneplatnit; ordered -> Zneplatnit.
- ReceivedOrders list: Zahájit realizaci / Dokončit / Stornovat / Obnovit with
  cascade notes; Czech quote pairs fixed („…“); OrderDetail transition button
  says 'Obnovit' when reopening.
- Projects list + ProjectDetail: status combobox replaced by transition
  buttons (Dokončit/Zrušit/Obnovit projekt) rendered from valid_transitions;
  cascade notes only when a linked order will actually change; save no longer
  carries status; busy states symmetric.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 04:33:58 +02:00
BOHA
3ec512faf1 feat(projects/orders): project status machine + bidirectional project-order sync; order reopen
- projects.service: VALID_TRANSITIONS (aktivni->dokonceny/zruseny,
  terminal->aktivni reopen), tolerant of legacy statuses; getProject returns
  valid_transitions; updateProject validates transitions (invalid_transition
  token -> Czech 400 in the route).
- NEW project->order cascade, transactional with the project update: finishing/
  cancelling a project completes/cancels its linked received order — only while
  the order is still open (never resurrects storno, never cancels completed);
  reopen never cascades. Direct tx write, no service recursion; cascaded order
  change gets its own audit row.
- orders.service: reopen edges (dokoncena/stornovana -> v_realizaci);
  syncProjectStatus is reopen-aware (reopen skips project sync) and returns
  the cascaded projects for per-project audit rows in the route.
- orders.schema: fix phantom 'zrusena' enum token -> canonical 'stornovana'
  (every storno PUT through the route 400ed with a raw English Zod message;
  latent until now because no UI sent it) + route-level regression test.
- NEW project-order-status-sync.test.ts: 15 tests — both cascade directions,
  guards, reopen-no-cascade, legacy tolerance, forward-sync regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 04:33:58 +02:00
BOHA
9f9f359acb docs(spec): status quick actions + bidirectional project-order sync
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 03:49:46 +02:00
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
147 changed files with 7376 additions and 1283 deletions

9
.gitignore vendored
View File

@@ -21,5 +21,14 @@ dist-client/
.claude/worktrees/
.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/

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.

View File

@@ -46,6 +46,17 @@ without explicit user confirmation.**
errors from prior pids).
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
read-only pre-flight first: `prisma migrate status` on prod, verify FK
constraint names the migration DROPs, check no data violates new

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

@@ -0,0 +1,96 @@
# Status quick actions + project⇄order sync
**Date:** 2026-07-04 · **Status:** Approved (interactive design w/ owner) · **Scope:** offers, received orders, projects
## Problem
Status changes are inconsistent: the Invoices list has a quick chip action (click → confirm → paid),
but Offers/ReceivedOrders/Projects tables have inert chips — every change needs the detail page.
ProjectDetail edits status via a free combobox in the form (no transitions, no confirm), unlike
OfferDetail/OrderDetail's transition buttons. Projects have **no status machine at all** (free-text
column). Order→project completion sync exists one-way (`syncProjectStatus`), but finishing a
**project** leaves its linked order untouched.
## Decisions (owner)
1. **Chip opens a menu** of valid next states (not single-click-advance) → ConfirmDialog per pick,
danger styling + cascade notes on destructive/irreversible ones.
2. **Reopen allowed**: `dokonceny/zruseny → aktivni` (projects), `dokoncena/stornovana → v_realizaci`
(received orders). **Reopening never cascades** to the linked record.
3. **Sync scope: finish + cancel, both directions.** Project `dokonceny` ⇄ order `dokoncena`;
project `zruseny` ⇄ order `stornovana`.
4. Offers quick menu: `Aktivovat` (draft — same number-assignment + PDF-archive semantics as the
detail), `Zneplatnit` (danger), and **"Vytvořit objednávku…" which opens the existing
create-order modal** (never a raw label flip to `ordered` — that state is owned by the
create-order flow).
## Design
### Backend
**Projects gain a status machine** (`src/services/projects.service.ts`):
`VALID_TRANSITIONS = { aktivni: [dokonceny, zruseny], dokonceny: [aktivni], zruseny: [aktivni] }`.
`updateProject` validates status changes (token `invalid_transition` → Czech 400 in the route);
a legacy/unknown current status may transition to any canonical value (tolerant). `getProject`
returns `valid_transitions` (same contract as offers/orders).
**Project → order cascade** (new), inside one transaction with the project update:
- project → `dokonceny` ⇒ linked order → `dokoncena` **iff** order status ∈ {prijata, v_realizaci}
- project → `zruseny` ⇒ linked order → `stornovana` **iff** order status ∈ {prijata, v_realizaci}
- reopen ⇒ no order change; completed/cancelled orders are never resurrected by a cascade
- direct `tx` write (no service recursion); sibling projects of the same order are NOT touched
(multi-project orders are a rare schema possibility, not a flow; avoid surprise mass updates)
- the service returns what cascaded so the route writes an audit row for the order change too
**Received orders** (`src/services/orders.service.ts`):
- `VALID_TRANSITIONS` gains reopen: `dokoncena: [v_realizaci]`, `stornovana: [v_realizaci]`
- `syncProjectStatus` becomes reopen-aware: given the previous status, a transition OUT of a
terminal state (reopen) skips project sync entirely (decision 2). Forward transitions keep the
existing mapping (v_realizaci→aktivni no-op in practice, dokoncena→dokonceny,
stornovana→zruseny), and the cascaded project change now gets an audit row.
- item/section edit guards stay status-based, so a reopened order is editable again (intended).
No DB migration (status columns are strings already).
### Frontend
**Shared `StatusChipMenu`** (`src/admin/components/StatusChipMenu.tsx`): renders a `StatusChip`;
click opens a small MUI `Menu` of actions; each action either opens a `ConfirmDialog`
(danger variant + `loading` during the request; cascade note in the message) or runs a plain
`onClick` (the modal-opening offer item). Hidden affordance fixed via `title` tooltip. Chip is
plain (non-clickable) when the user lacks the edit permission or no actions exist.
**Offers list**: draft → `Aktivovat` (confirm notes the number assignment; after success the same
fire-and-forget PDF-archive call the detail does); active → `Vytvořit objednávku…` (opens the
existing modal) + `Zneplatnit` (danger); ordered → `Zneplatnit` (danger).
**ReceivedOrders list**: prijata → `Zahájit realizaci`, `Stornovat` (danger, note: linked project
will be cancelled); v_realizaci → `Dokončit` (note: linked project will be completed),
`Stornovat` (danger); dokoncena/stornovana → `Obnovit` (reopen, no cascade note).
**Projects list**: aktivni → `Dokončit` (note when `order_id` present: linked order will be
completed), `Zrušit` (danger, order-storno note); dokonceny/zruseny → `Obnovit`.
**ProjectDetail**: status `Select` removed from the form (and status removed from the save
payload); header gains transition buttons rendered from `valid_transitions`
(`Dokončit projekt` / `Zrušit projekt` danger / `Obnovit projekt`), each via ConfirmDialog with
cascade notes, as a status-only PUT with its own mutation
(invalidate: projects, orders, offers, invoices, warehouse). OrderDetail keeps its pattern;
its transition label shows `Obnovit` when reopening from a terminal state.
Invalidation for all new status mutations: `["orders","offers","projects","invoices"]`
(+`["warehouse"]` on project mutations, matching the page's existing set).
### Tests
Service-level (real `app_test` DB): project transition validation (all legal/illegal edges incl.
legacy-status tolerance), project→order cascade both mappings + the guard (no cascade onto
dokoncena/stornovana orders), reopen-no-cascade both directions, order reopen transitions, and
regression: order→project sync still fires on forward transitions.
## Out of scope
Offer machine changes (unchanged: draft→active→ordered/invalidated), issued orders, sibling-project
propagation, ReceivedOrders detail-page button changes beyond the automatic reopen button.

View File

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

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.4.31",
"version": "2.4.47",
"description": "",
"main": "dist/server.js",
"scripts": {

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
user_agent String? @db.Text
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([created_at], map: "idx_audit_logs_created")
@@ -206,6 +208,7 @@ model invoice_items {
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
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)
position Int? @default(0)
invoices invoices @relation(fields: [invoice_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "invoice_items_ibfk_1")
@@ -236,6 +239,7 @@ model invoices {
billing_text String? @db.VarChar(500)
language String? @default("cs") @db.VarChar(5)
internal_notes String? @db.Text
selected_custom_fields String? @db.VarChar(255)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
invoice_items invoice_items[]
@@ -390,6 +394,7 @@ model issued_orders {
language String? @default("cs") @db.VarChar(5)
order_text String? @db.VarChar(500)
internal_notes String? @db.Text
selected_custom_fields String? @db.VarChar(255)
locked_by Int?
locked_at DateTime? @db.DateTime(0)
created_at DateTime? @default(now()) @db.DateTime(0)
@@ -503,6 +508,7 @@ model quotation_items {
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
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)
modified_at DateTime? @db.DateTime(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
@@ -523,6 +529,7 @@ model quotations {
status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
selected_custom_fields String? @db.VarChar(255)
locked_by Int?
locked_at DateTime? @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)

View File

@@ -391,12 +391,12 @@ async function main() {
console.log(` Admin role now has all ${allPerms.length} permissions`);
// 4. Ensure admin user exists
let adminUser = await prisma.users.findFirst({
const adminUser = await prisma.users.findFirst({
where: { username: "admin" },
});
if (!adminUser) {
const hash = bcrypt.hashSync("admin", 10);
adminUser = await prisma.users.create({
await prisma.users.create({
data: {
username: "admin",
email: "admin@boha.local",

View File

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

View File

@@ -0,0 +1,378 @@
// 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">8,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">0,00h</span>
</div>
<div class="summary-row summary-alert">
<span class="summary-label">Chybějící hodiny:</span>
<span class="summary-value">152,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,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,284 @@
import { describe, it, expect } from "vitest";
import {
computeMzdaSummary,
formatHoursDecimal1,
} from "../admin/utils/attendanceHelpers";
import { buildUserSectionHtml } from "../admin/hooks/useAttendanceAdmin";
/**
* BINDING payroll-recap formulas (owner spec 2026-07, back-computed against
* the real payslip of Dominik Vesecký 5/2026 — the dataset below is the
* built-in acceptance test and MUST match exactly, not approximately):
*
* fond = (PoPá dny, které NEJSOU svátek) × 8
* odpracováno = (kalendářní dny se záznamem Práce mimo svátek) × 8, paušálně
* svátek = čisté odpracované hodiny ve dnech svátku
* dovolená = Σ leave_hours (evidence)
* přesčas = max(0, (worked_total + dovolená) fond) [měsíční]
* vč. svátků = odpracováno + přesčas
* so/ne = čisté hodiny směn začínajících v So/Ne (celá směna)
* noc = průnik s 22:0006:00 minus pauzy v pásmu, ke dni začátku
*
* Expected 5/2026 (holidays 1.5. + 8.5.): fond 152 · odpracováno 144 ·
* svátek 8 · dovolená 24 · přesčas 17,25 (=17,3 na desetiny) · vč. 161,25
* (=161,3) · So/Ne 57,25 · noc 36,75 (payslip said 31 — accountant's own
* error, she missed the 31.5.→1.6. overnight shift; 36,75 is CORRECT).
*/
const W = (d: string, from: string, to: string, brk?: [string, string]) => ({
shift_date: `${d}T02:00:00`,
arrival_time: from.includes(" ") ? from.replace(" ", "T") : `${d}T${from}:00`,
departure_time: to.includes(" ") ? to.replace(" ", "T") : `${d}T${to}:00`,
break_start: brk
? brk[0].includes(" ")
? brk[0].replace(" ", "T")
: `${d}T${brk[0]}:00`
: null,
break_end: brk
? brk[1].includes(" ")
? brk[1].replace(" ", "T")
: `${d}T${brk[1]}:00`
: null,
leave_type: "work",
});
const V = (d: string, h: number) => ({
shift_date: `${d}T02:00:00`,
leave_type: "vacation",
leave_hours: h,
arrival_time: null,
departure_time: null,
break_start: null,
break_end: null,
});
/** The exact dataset from the spec (times with a space carry a full date). */
const DOMINIK_MAY_2026 = [
W("2026-05-01", "08:15", "17:15", ["12:30", "13:30"]),
W("2026-05-03", "21:30", "2026-05-04 06:15:00", [
"2026-05-04 01:52:00",
"2026-05-04 02:22:00",
]),
V("2026-05-05", 8),
V("2026-05-06", 8),
V("2026-05-07", 8),
W("2026-05-10", "21:15", "2026-05-11 06:30:00", [
"2026-05-11 01:52:00",
"2026-05-11 02:22:00",
]),
W("2026-05-12", "07:45", "16:00", ["11:52", "12:22"]),
W("2026-05-13", "08:00", "16:15", ["12:07", "12:37"]),
W("2026-05-14", "08:00", "16:15", ["12:07", "12:37"]),
W("2026-05-15", "08:00", "16:00", ["12:00", "12:30"]),
W("2026-05-16", "10:00", "23:30", ["16:45", "17:45"]),
W("2026-05-17", "21:30", "2026-05-18 06:30:00", [
"2026-05-18 02:00:00",
"2026-05-18 02:30:00",
]),
W("2026-05-19", "09:30", "17:00", ["13:15", "13:45"]),
W("2026-05-20", "08:30", "16:30", ["12:30", "13:00"]),
W("2026-05-21", "09:45", "15:15"),
W("2026-05-22", "10:45", "15:30"),
W("2026-05-24", "17:45", "2026-05-25 06:00:00", [
"23:45",
"2026-05-25 00:45:00",
]),
W("2026-05-26", "09:00", "14:30"),
W("2026-05-27", "10:00", "15:30"),
W("2026-05-28", "08:00", "15:45", ["11:45", "12:15"]),
W("2026-05-29", "08:15", "15:00", ["11:30", "12:00"]),
W("2026-05-31", "19:45", "2026-06-01 04:15:00", [
"2026-06-01 00:00:00",
"2026-06-01 00:30:00",
]),
];
describe("computeMzdaSummary — built-in acceptance dataset (Dominik 5/2026)", () => {
const s = computeMzdaSummary(DOMINIK_MAY_2026 as never, "2026-05");
it("Fond měsíce = 152 (19 PoPá dnů mimo svátky 1.5. a 8.5.)", () => {
expect(s.businessDays).toBe(19);
expect(s.fund).toBe(152);
});
it("Odpracováno = 144 (18 pracovních dnů mimo svátek × 8, paušálně)", () => {
expect(s.odpracovano).toBe(144);
});
it("Svátek = 8 (skutečné hodiny odpracované 1. 5.)", () => {
expect(s.svatekHours).toBe(8);
});
it("Dovolená = 24", () => {
expect(s.vacationHours).toBe(24);
});
it("worked_total = 145,25 (145:15 čistého času)", () => {
expect(s.workedHoursRaw).toBe(145.25);
});
it("Přesčas = 17,25 → zobrazeno 17,3 ((145,25 + 24) 152)", () => {
expect(s.prescas).toBe(17.25);
expect(formatHoursDecimal1(s.prescas * 60)).toBe("17,3");
});
it("Odpracováno včetně svátků a přesčasů = 161,25 → zobrazeno 161,3 (144 + 17,25)", () => {
expect(s.vcetneSvatku).toBe(161.25);
expect(formatHoursDecimal1(s.vcetneSvatku * 60)).toBe("161,3");
});
it("So/Ne = 57,25 (6 směn začínajících v So/Ne, celé směny)", () => {
expect(s.weekendHours).toBe(57.25);
});
it("Práce v noci = 36,75 — VČETNĚ směny 31.5.→1.6. (výplatnice s 31 h se mýlí)", () => {
expect(s.nightMinutes / 60).toBe(36.75);
});
it("Chybějící hodiny = 0 (145,25 + 24 pokrývá fond 152)", () => {
expect(s.manko).toBe(0);
});
});
describe("computeMzdaSummary — Chybějící hodiny (Dominik 6/2026, reálná data)", () => {
// Real production month exposing the gap the paušál hides: 21 attended days
// × 8 = 168 + 8h dovolená = 176 = fond exactly (weekend shifts nominally
// compensate 3 missing weekdays 1.6./11.6./15.6.), yet REAL worked time is
// only 136,5h → 31,5h below the fond. Přesčas must stay 0 (formulas are
// binding), manko must surface the 31,5.
const JUNE = [
W("2026-06-02", "06:15", "14:00", ["10:00", "10:30"]),
W("2026-06-03", "06:30", "13:30", ["10:00", "10:30"]),
W("2026-06-04", "06:00", "13:45", ["09:45", "10:15"]),
W("2026-06-05", "06:15", "13:45", ["10:00", "10:30"]),
W("2026-06-07", "07:15", "13:00", ["09:30", "10:00"]), // Sunday
W("2026-06-08", "07:15", "13:30", ["10:15", "10:45"]),
W("2026-06-09", "06:00", "11:15", ["09:30", "10:00"]),
W("2026-06-10", "06:00", "11:45", ["09:30", "10:00"]),
W("2026-06-12", "06:00", "13:45", ["09:45", "10:15"]),
W("2026-06-14", "17:30", "2026-06-15 04:15:00", ["22:45", "23:15"]), // Sun→Mon
W("2026-06-16", "07:30", "13:30", ["10:00", "10:30"]),
W("2026-06-17", "10:45", "13:15"),
W("2026-06-18", "06:00", "13:15", ["09:30", "10:00"]),
W("2026-06-19", "07:00", "13:15", ["10:00", "10:30"]),
W("2026-06-22", "06:00", "13:30", ["09:45", "10:15"]),
W("2026-06-23", "06:00", "13:45", ["09:45", "10:15"]),
W("2026-06-24", "05:30", "13:45", ["09:30", "10:00"]),
V("2026-06-25", 8),
W("2026-06-26", "05:45", "14:45", ["10:15", "10:45"]),
W("2026-06-27", "05:45", "17:00", ["11:15", "11:45"]), // Saturday
W("2026-06-29", "11:45", "14:30"),
W("2026-06-30", "06:30", "09:15"),
W("2026-06-30", "10:45", "13:30"),
];
const s = computeMzdaSummary(JUNE as never, "2026-06");
it("verified rows stay exactly as before (fond 176, odpracováno 168, přesčas 0)", () => {
expect(s.fund).toBe(176);
expect(s.businessDays).toBe(22);
expect(s.odpracovano).toBe(168); // 21 days × 8 flat
expect(s.vacationHours).toBe(8);
expect(s.workedHoursRaw).toBe(136.5);
expect(s.prescas).toBe(0); // (136,5 + 8) 176 < 0 — unchanged formula
expect(s.vcetneSvatku).toBe(168);
expect(s.svatekHours).toBe(0);
expect(s.weekendHours).toBe(26.25); // shifts starting 7.6., 14.6., 27.6.
// 14.6. shift (22:0004:15 minus break = 5:45) + early starts before
// 06:00: 24.6. 05:30 (0:30), 26.6. 05:45 (0:15), 27.6. 05:45 (0:15).
expect(s.nightMinutes / 60).toBe(6.75);
});
it("Chybějící hodiny = 31,5 — the gap the paušál nominally hides", () => {
expect(s.manko).toBe(31.5); // 176 (136,5 + 8)
});
it("nemoc a neplacené volno kryjí fond stejně jako dovolená", () => {
const withSick = [...JUNE, { ...V("2026-06-01", 8), leave_type: "sick" }];
const s2 = computeMzdaSummary(withSick as never, "2026-06");
expect(s2.sickHours).toBe(8);
expect(s2.manko).toBe(23.5); // 31,5 8
});
});
describe("computeMzdaSummary — formula edge cases", () => {
it("Odpracováno is a flat 8h per day: a lone 4:45 day counts as 8", () => {
const s = computeMzdaSummary(
[W("2026-06-02", "10:45", "15:30")] as never,
"2026-06",
);
expect(s.odpracovano).toBe(8);
expect(s.workedHoursRaw).toBe(4.75);
expect(s.prescas).toBe(0); // (4,75 + 0) 176 < 0
expect(s.vcetneSvatku).toBe(8); // odpracováno + přesčas
});
it("two shifts on one day count that day ONCE (8h, not 16h)", () => {
const s = computeMzdaSummary(
[
W("2026-06-02", "06:00", "10:00"),
W("2026-06-02", "14:00", "18:00"),
] as never,
"2026-06",
);
expect(s.odpracovano).toBe(8);
expect(s.workedHoursRaw).toBe(8);
});
it("dovolená covers the fond in Přesčas", () => {
// 176h fond (June 2026): 152h worked + 30h vacation = 182 → +6 přesčas.
const recs: unknown[] = [];
const days = [
"01",
"02",
"03",
"04",
"05",
"08",
"09",
"10",
"11",
"12",
"15",
"16",
"17",
"18",
"19",
"22",
"23",
"24",
"25",
].map((d) => `2026-06-${d}`);
for (const d of days) recs.push(W(d, "08:00", "16:30", ["12:00", "12:30"])); // 19×8=152
recs.push(V("2026-06-29", 8), V("2026-06-30", 8), V("2026-06-26", 14));
const s = computeMzdaSummary(recs as never, "2026-06");
expect(s.vacationHours).toBe(30);
expect(s.prescas).toBe(6);
});
it("a shift STARTING on a holiday goes to Svátek, not to Odpracováno days", () => {
const s = computeMzdaSummary(
[W("2026-07-06", "08:00", "18:30", ["12:00", "12:30"])] as never, // 6.7. holiday, 10h
"2026-07",
);
expect(s.svatekHours).toBe(10);
expect(s.odpracovano).toBe(0);
expect(s.fund).toBe(176); // 23 MonFri 6.7. holiday = 22 × 8
});
});
describe("print day table — multiple records on one day", () => {
it("renders one row per record (two shifts are both visible)", () => {
const records = [
W("2026-06-02", "06:00", "10:00"),
W("2026-06-02", "14:00", "18:00"),
];
const html = buildUserSectionHtml(
"1",
{ name: "Test", minutes: 480, vacation_hours: 0, records } as never,
{ month: "2026-06", month_name: "Červen 2026", user_totals: {} } as never,
);
expect(html).toContain("06:00");
expect(html).toContain("14:00");
const rows = html.match(/<td>2\. 6\. 2026<\/td>/g) || [];
expect(rows.length).toBe(2);
});
});

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,77 @@
import { describe, it, expect } from "vitest";
import {
getCzechWeekday,
isWeekendDate,
formatTimeOrDatetimePrint,
} from "../admin/utils/attendanceHelpers";
import { formatDate } from "../admin/utils/formatters";
/**
* Regression: the attendance admin print builds its day rows from date-ONLY
* strings ("2026-06-01"). `new Date("YYYY-MM-DD")` is UTC midnight per the
* ECMAScript spec, so formatting it with local getters rendered the PREVIOUS
* day for viewers west of UTC — a June print opened with "31. 5. 2026 Neděle"
* (a May row) and every weekday name was shifted by one. The helpers now
* parse the calendar day from the string parts, which is timezone-proof by
* construction: these assertions hold in EVERY zone (on a machine west of
* UTC the old implementation fails them).
*/
describe("attendance print — timezone-proof date rendering", () => {
it("formatDate renders a date-only string as that exact calendar day", () => {
expect(formatDate("2026-06-01")).toBe("1. 6. 2026");
expect(formatDate("2026-06-30")).toBe("30. 6. 2026");
});
it("formatDate keeps naive-datetime (wire format) behavior", () => {
expect(formatDate("2026-06-01T02:00:00")).toBe("1. 6. 2026");
});
it("getCzechWeekday names the string's own calendar day", () => {
expect(getCzechWeekday("2026-06-01")).toBe("Pondělí");
expect(getCzechWeekday("2026-06-06")).toBe("Sobota");
expect(getCzechWeekday("2026-06-07")).toBe("Neděle");
// naive-datetime wire format resolves to the same day
expect(getCzechWeekday("2026-06-01T02:00:00")).toBe("Pondělí");
});
it("isWeekendDate flags the string's own calendar day", () => {
expect(isWeekendDate("2026-06-06")).toBe(true); // Saturday
expect(isWeekendDate("2026-06-07")).toBe(true); // Sunday
expect(isWeekendDate("2026-06-01")).toBe(false); // Monday
expect(isWeekendDate("2026-06-06T02:00:00")).toBe(true);
});
it("agrees with a locally-constructed date in the current zone (guards a regression to UTC parsing)", () => {
// Local construction is the ground truth for "calendar day" in any zone.
const local = new Date(2026, 5, 1, 12).toLocaleDateString("cs-CZ", {
weekday: "long",
});
const expected = local.charAt(0).toUpperCase() + local.slice(1);
expect(getCzechWeekday("2026-06-01")).toBe(expected);
});
});
describe("attendance print — overnight date prefix on time cells", () => {
// shift_date arrives as a naive datetime on the wire ("2026-06-01T02:00:00");
// the guard used to compare it raw against the extracted date part, so the
// overnight-only "d.m." prefix fired on EVERY cell ("1.6. 08:00").
const wireShiftDate = "2026-06-01T02:00:00";
it("same-day punch renders time only", () => {
expect(
formatTimeOrDatetimePrint("2026-06-01T08:00:00", wireShiftDate),
).toBe("08:00");
});
it("overnight punch keeps the date prefix", () => {
expect(
formatTimeOrDatetimePrint("2026-06-02T01:30:00", wireShiftDate),
).toBe("2.6. 01:30");
});
it("date-only shiftDate also matches", () => {
expect(formatTimeOrDatetimePrint("2026-06-01T08:00:00", "2026-06-01")).toBe(
"08:00",
);
});
});

View File

@@ -0,0 +1,124 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import prisma from "../config/database";
import { getWorkfund } from "../services/attendance.service";
/**
* Print-recap parity for the Bilance page's monthly fund overview
* (getWorkfund), per the binding formulas (computeMzdaSummary):
* overtime (Přesčas) = max(0, worked + dovolená fond)
* missing (Chybějící hodiny) = max(0, fond (worked + dovolená + nemoc
* + neplacené volno))
* The two coverage bases differ deliberately: only dovolená earns toward
* overtime, every approved absence covers the fond.
*/
const USERNAME = "wf_parity_test_user";
let userId: number;
const june = (day: string) => new Date(`2026-06-${day}T00:00:00Z`);
const dt = (day: string, hm: string) => new Date(`2026-06-${day}T${hm}:00`);
beforeAll(async () => {
await prisma.attendance.deleteMany({
where: { users: { username: USERNAME } },
});
await prisma.users.deleteMany({ where: { username: USERNAME } });
// getAttendanceUsers includes only users whose role carries
// attendance.record — the seeded admin role has every permission.
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
if (!adminRole) throw new Error("Admin role not found — seed the DB first");
const user = await prisma.users.create({
data: {
username: USERNAME,
email: `${USERNAME}@test.local`,
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "Workfund",
last_name: "Parity",
is_active: true,
role_id: adminRole.id,
},
});
userId = user.id;
// June 2026 (fond 176 = 22 non-holiday weekdays × 8):
// 10 work days of 8h net (08:0016:30, 30min break) → worked 80
const workDays = ["01", "02", "03", "04", "05", "08", "09", "10", "11", "12"];
for (const d of workDays) {
await prisma.attendance.create({
data: {
user_id: userId,
shift_date: june(d),
arrival_time: dt(d, "08:00"),
departure_time: dt(d, "16:30"),
break_start: dt(d, "12:00"),
break_end: dt(d, "12:30"),
leave_type: "work",
},
});
}
// dovolená 24, nemoc 16, neplacené volno 8
const leaves: Array<[string, string, number]> = [
["15", "vacation", 8],
["16", "vacation", 8],
["17", "vacation", 8],
["18", "sick", 8],
["19", "sick", 8],
["22", "unpaid", 8],
];
for (const [d, type, hours] of leaves) {
await prisma.attendance.create({
data: {
user_id: userId,
shift_date: june(d),
leave_type: type as never,
leave_hours: hours,
},
});
}
});
afterAll(async () => {
await prisma.attendance.deleteMany({ where: { user_id: userId } });
await prisma.users.deleteMany({ where: { id: userId } });
});
describe("getWorkfund — print-recap parity", () => {
it("computes overtime from worked+dovolená and missing from all approved absences", async () => {
const data = await getWorkfund(2026);
// getWorkfund's future-year early return types months as a bare {} —
// narrow to the populated shape (2026 is never a future year here).
const months = data.months as Record<
string,
{
fund: number;
users: Record<
string,
{
worked: number;
covered: number;
covered_prescas: number;
overtime: number;
missing: number;
}
>;
}
>;
const juneData = months["6"];
expect(juneData).toBeTruthy();
expect(juneData.fund).toBe(176);
const us = juneData.users[String(userId)];
expect(us).toBeTruthy();
expect(us.worked).toBe(80);
// covered (manko base): 80 + 24 + 16 + 8
expect(us.covered).toBe(128);
// covered_prescas (Přesčas base): 80 + 24 — sick/unpaid excluded
expect(us.covered_prescas).toBe(104);
// Chybějící hodiny: 176 128
expect(us.missing).toBe(48);
// Přesčas: max(0, 104 176)
expect(us.overtime).toBe(0);
});
});

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 () => {
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)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
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 () => {
const draft = await createIssuedOrder({});
const draft = await createIssuedOrder({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);

View File

@@ -169,7 +169,10 @@ describe("createIssuedOrder", () => {
it("numbers immediately when created already-finalized (status sent)", async () => {
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.status).toBe("sent");
});
@@ -199,7 +202,9 @@ describe("createIssuedOrder", () => {
describe("updateIssuedOrder status transitions", () => {
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" });
expect("error" in ok).toBe(false);
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 () => {
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: "confirmed" });
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 });
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", () => {
@@ -525,7 +570,9 @@ describe("POST /api/admin/issued-orders legacy dropped fields", () => {
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 () => {
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: "confirmed" });
@@ -543,7 +590,9 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
// 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({});
const order = await mkIssued({
items: [{ description: "Položka", quantity: 1, unit_price: 1 }],
});
await updateIssuedOrder(order.id, { status: "sent" });
const res = await app!.inject({
@@ -574,7 +623,9 @@ describe("PUT /api/admin/issued-orders/:id editable-state guard (HTTP)", () => {
});
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: "confirmed" });
@@ -733,6 +784,23 @@ describe("renderIssuedOrderHtml", () => {
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Č", () => {
const html = renderIssuedOrderHtml(
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,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

@@ -196,6 +196,31 @@ describe("order attachment payload hygiene", () => {
expect(withoutDetail!.attachment_name).toBeNull();
});
it("PUT /:id accepts the canonical storno token through the Zod layer", async () => {
// Regression: UpdateOrderSchema carried a phantom "zrusena" enum member
// until 2026-07, so { status: "stornovana" } 400ed at parseBody with a
// raw English Zod message. Service-level tests bypass the schema — this
// must stay a route-level test.
const customer = await makeCustomer();
const res = await createOrder({
...baseOrder,
customer_id: customer.id,
create_project: false,
});
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
const put = await app.inject({
method: "PUT",
url: `/api/admin/orders/${res.id}`,
headers: { authorization: `Bearer ${adminToken}` },
payload: { status: "stornovana" },
});
expect(put.statusCode).toBe(200);
const row = await prisma.orders.findUnique({ where: { id: res.id! } });
expect(row!.status).toBe("stornovana");
});
it("HTTP list/detail JSON carries no attachment_data; /attachment still serves the binary", async () => {
const { withId, withoutId } = await makeOrderPair();
const headers = { authorization: `Bearer ${adminToken}` };

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 () => {
app = await buildApp();

View File

@@ -0,0 +1,301 @@
import { describe, it, expect, afterEach, afterAll } from "vitest";
import prisma from "../config/database";
import {
updateProject,
getProject,
VALID_TRANSITIONS as PROJECT_VALID_TRANSITIONS,
} from "../services/projects.service";
import { updateOrder } from "../services/orders.service";
/**
* Bidirectional project⇄order status sync (spec 2026-07-04):
* - projects gain a status machine (aktivni ⇄ dokonceny/zruseny) with
* legacy-status tolerance,
* - finishing/cancelling a project cascades onto its linked OPEN order
* (prijata/v_realizaci) — terminal orders are never touched,
* - reopen (project → aktivni, order → v_realizaci) is now a legal
* transition and NEVER cascades,
* - regression: the existing order→project forward sync stays intact.
*
* Real app_test DB via the service layer (suite convention). Fixtures use a
* unique prefix; projects are created without project_number so getProject's
* NAS folder probe is skipped.
*/
const N = "posync_";
const createdProjectIds: number[] = [];
const createdOrderIds: number[] = [];
let seq = 0;
async function mkOrder(status: string) {
const order = await prisma.orders.create({
data: { order_number: `${N}${Date.now()}_${seq++}`, status },
});
createdOrderIds.push(order.id);
return order;
}
async function mkProject(status: string, orderId?: number) {
const project = await prisma.projects.create({
data: { name: `${N}project_${seq++}`, status, order_id: orderId ?? null },
});
createdProjectIds.push(project.id);
return project;
}
// FK-safe order: projects reference orders (Restrict), so projects go first.
afterEach(async () => {
await prisma.projects.deleteMany({
where: { id: { in: createdProjectIds } },
});
await prisma.orders.deleteMany({ where: { id: { in: createdOrderIds } } });
createdProjectIds.length = 0;
createdOrderIds.length = 0;
});
afterAll(async () => {
await prisma.$disconnect();
});
function assertOk<T>(
res: T,
): asserts res is Exclude<T, null | { error: unknown }> {
if (!res || (typeof res === "object" && "error" in res)) {
throw new Error(`expected success, got ${JSON.stringify(res)}`);
}
}
describe("project → order cascade (updateProject)", () => {
it("aktivni→dokonceny completes a linked v_realizaci order and returns synced_order", async () => {
const order = await mkOrder("v_realizaci");
const project = await mkProject("aktivni", order.id);
const res = await updateProject(project.id, { status: "dokonceny" });
assertOk(res);
expect(res.synced_order).toEqual({
id: order.id,
order_number: order.order_number,
from: "v_realizaci",
to: "dokoncena",
});
expect(res.old_status).toBe("aktivni");
const freshOrder = await prisma.orders.findUnique({
where: { id: order.id },
});
expect(freshOrder?.status).toBe("dokoncena");
const freshProject = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(freshProject?.status).toBe("dokonceny");
});
it("aktivni→zruseny cancels a linked prijata order", async () => {
const order = await mkOrder("prijata");
const project = await mkProject("aktivni", order.id);
const res = await updateProject(project.id, { status: "zruseny" });
assertOk(res);
expect(res.synced_order).toEqual({
id: order.id,
order_number: order.order_number,
from: "prijata",
to: "stornovana",
});
const freshOrder = await prisma.orders.findUnique({
where: { id: order.id },
});
expect(freshOrder?.status).toBe("stornovana");
});
it("guard: never resurrects a terminal order (stornovana stays stornovana)", async () => {
const order = await mkOrder("stornovana");
const project = await mkProject("aktivni", order.id);
const res = await updateProject(project.id, { status: "dokonceny" });
assertOk(res);
expect(res.synced_order).toBeNull();
const freshOrder = await prisma.orders.findUnique({
where: { id: order.id },
});
expect(freshOrder?.status).toBe("stornovana");
const freshProject = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(freshProject?.status).toBe("dokonceny");
});
it("reopen (dokonceny→aktivni) never touches the linked order", async () => {
const order = await mkOrder("dokoncena");
const project = await mkProject("dokonceny", order.id);
const res = await updateProject(project.id, { status: "aktivni" });
assertOk(res);
expect(res.synced_order).toBeNull();
const freshOrder = await prisma.orders.findUnique({
where: { id: order.id },
});
expect(freshOrder?.status).toBe("dokoncena");
const freshProject = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(freshProject?.status).toBe("aktivni");
});
it("no linked order → no cascade, synced_order null", async () => {
const project = await mkProject("aktivni");
const res = await updateProject(project.id, { status: "dokonceny" });
assertOk(res);
expect(res.synced_order).toBeNull();
});
});
describe("project status machine (updateProject validation)", () => {
it("rejects dokonceny→zruseny with the invalid_transition token", async () => {
const project = await mkProject("dokonceny");
const res = await updateProject(project.id, { status: "zruseny" });
expect(res && "error" in res && res.error).toBe("invalid_transition");
expect(res).toMatchObject({
currentStatus: "dokonceny",
newStatus: "zruseny",
});
// Nothing was written.
const fresh = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(fresh?.status).toBe("dokonceny");
});
it("legacy tolerance: an unknown current status may move to any canonical target", async () => {
const project = await mkProject("stary");
const res = await updateProject(project.id, { status: "dokonceny" });
assertOk(res);
const fresh = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(fresh?.status).toBe("dokonceny");
});
it("legacy tolerance still rejects a non-canonical target", async () => {
const project = await mkProject("stary");
const res = await updateProject(project.id, { status: "nesmysl" });
expect(res && "error" in res && res.error).toBe("invalid_transition");
});
it("status-unchanged payloads pass without transition validation", async () => {
const project = await mkProject("dokonceny");
const res = await updateProject(project.id, {
status: "dokonceny",
notes: `${N}note`,
});
assertOk(res);
});
});
describe("order reopen + order → project sync (updateOrder)", () => {
it("dokoncena→v_realizaci is now valid and skips project sync (reopen)", async () => {
const order = await mkOrder("dokoncena");
const project = await mkProject("dokonceny", order.id);
const res = await updateOrder(order.id, { status: "v_realizaci" });
expect("error" in res).toBe(false);
if ("error" in res) throw new Error(res.error);
expect(res.synced_projects).toEqual([]);
const freshOrder = await prisma.orders.findUnique({
where: { id: order.id },
});
expect(freshOrder?.status).toBe("v_realizaci");
// Reopen never cascades — the project keeps its terminal status.
const freshProject = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(freshProject?.status).toBe("dokonceny");
});
it("stornovana→v_realizaci is valid too; other exits from terminal stay rejected", async () => {
const order = await mkOrder("stornovana");
const res = await updateOrder(order.id, { status: "v_realizaci" });
expect("error" in res).toBe(false);
const back = await mkOrder("dokoncena");
const rejected = await updateOrder(back.id, { status: "prijata" });
expect("error" in rejected).toBe(true);
if ("error" in rejected) {
expect(rejected.status).toBe(400);
expect(rejected.error).toContain("Neplatný přechod stavu");
}
});
it("regression: v_realizaci→dokoncena still completes the linked project and reports it", async () => {
const order = await mkOrder("v_realizaci");
const project = await mkProject("aktivni", order.id);
const res = await updateOrder(order.id, { status: "dokoncena" });
expect("error" in res).toBe(false);
if ("error" in res) throw new Error(res.error);
expect(res.synced_projects).toEqual([
{
id: project.id,
project_number: null,
from: "aktivni",
to: "dokonceny",
},
]);
const freshProject = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(freshProject?.status).toBe("dokonceny");
});
it("regression: prijata→stornovana still cancels the linked project", async () => {
const order = await mkOrder("prijata");
const project = await mkProject("aktivni", order.id);
const res = await updateOrder(order.id, { status: "stornovana" });
expect("error" in res).toBe(false);
if ("error" in res) throw new Error(res.error);
expect(res.synced_projects).toHaveLength(1);
const freshProject = await prisma.projects.findUnique({
where: { id: project.id },
});
expect(freshProject?.status).toBe("zruseny");
});
});
describe("getProject valid_transitions", () => {
it("matches the machine for canonical statuses", async () => {
const active = await mkProject("aktivni");
expect((await getProject(active.id))?.valid_transitions).toEqual(
PROJECT_VALID_TRANSITIONS["aktivni"],
);
expect((await getProject(active.id))?.valid_transitions).toEqual([
"dokonceny",
"zruseny",
]);
const done = await mkProject("dokonceny");
expect((await getProject(done.id))?.valid_transitions).toEqual(["aktivni"]);
const cancelled = await mkProject("zruseny");
expect((await getProject(cancelled.id))?.valid_transitions).toEqual([
"aktivni",
]);
});
it("offers all canonical targets for a legacy/unknown status", async () => {
const legacy = await mkProject("stary");
expect((await getProject(legacy.id))?.valid_transitions).toEqual([
"aktivni",
"dokonceny",
"zruseny",
]);
});
});

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

@@ -19,6 +19,9 @@ import tripsRoutes from "../routes/admin/trips";
// WHOLE filtered set (not one 25-row page), honors the month filter in
// both wire formats ("month=5&year=2098" and "month=2098-05"), and scopes
// non-managers to their own trips exactly like the list does.
// 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. */
@@ -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) {
return app.inject({
method: "PUT",
@@ -539,3 +554,88 @@ describe("GET /api/admin/trips/stats", () => {
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,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

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

View File

@@ -148,7 +148,7 @@ const TrashIcon = (
);
export default function PlanCellModal(props: Props) {
const { open, mode, onClose } = props;
const { open, mode } = props;
if (!open || mode.kind === "closed") return null;
if (mode.kind === "view") return <ViewModal {...props} />;
if (mode.kind === "day-in-range")
@@ -488,6 +488,7 @@ function DayInRangeModal(
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít — ponechat rozsah beze změny"
hideCancel
maxWidth="md"
>
<Typography variant="body2" sx={{ mb: 2 }}>

View File

@@ -491,9 +491,11 @@ export default function PlanGrid({
}, [projects]);
// Memoize the day list so it isn't rebuilt on every render (only when the
// visible range changes). Stable identity also keeps the row map cheap.
const dateFrom = data?.date_from;
const dateTo = data?.date_to;
const days = useMemo(
() => (data ? eachDay(data.date_from, data.date_to) : []),
[data?.date_from, data?.date_to],
() => (dateFrom && dateTo ? eachDay(dateFrom, dateTo) : []),
[dateFrom, dateTo],
);
if (!data) return <LoadingState />;

View File

@@ -19,46 +19,22 @@ import {
} from "../utils/attendanceHelpers";
// ---------- Shared types ----------
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;
}
// Definitions live in the dependency-free ./shiftFormTypes module; re-exported
// here so existing importers of ShiftFormModal keep working.
export type {
ShiftFormData,
ProjectLog,
Project,
User,
EditingRecord,
} from "./shiftFormTypes";
import type {
ShiftFormData,
ProjectLog,
Project,
User,
EditingRecord,
} from "./shiftFormTypes";
// ---------- Sub-component props ----------

View File

@@ -0,0 +1,148 @@
import { useState, type ComponentProps } from "react";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { StatusChip, ConfirmDialog } from "../ui";
/** One entry in a {@link StatusChipMenu}. */
export interface StatusChipAction {
/** Stable identifier, e.g. the target status. */
key: string;
/** Menu item text, e.g. "Dokončit". */
label: string;
/** Red menu-item text + danger ConfirmDialog variant. */
danger?: boolean;
/**
* Confirmation dialog content. When omitted the action fires directly from
* the menu without confirmation (used by "Vytvořit objednávku…", which
* opens its own modal).
*/
confirm?: { title: string; message: string; confirmText: string };
/**
* Executed on pick (after confirmation when `confirm` is set). Awaited:
* while pending the ConfirmDialog shows its loading state and cannot be
* closed; on resolve the dialog closes; on rejection it stays open (the
* caller is responsible for toasting the error).
*/
onAction: () => void | Promise<void>;
}
interface StatusChipMenuProps {
/** Chip text (current status label). */
label: string;
/** Chip color, same palette as {@link StatusChip}. */
color: ComponentProps<typeof StatusChip>["color"];
/** Quick actions. Empty/undefined renders a plain non-clickable chip. */
actions?: StatusChipAction[];
/** Force a plain chip (e.g. the user lacks the edit permission). */
disabled?: boolean;
/** Tooltip on the clickable chip. */
title?: string;
}
/**
* Status chip with a quick-action menu, shared by the list pages
* (offers / received orders / projects).
*
* Clicking the chip (stopPropagation, so row navigation never fires) opens a
* dense MUI Menu of the valid next actions. Picking one closes the menu and
* either fires `onAction` directly (no `confirm` given) or opens the single
* shared ConfirmDialog instance — danger variant for destructive actions,
* `loading` while the awaited `onAction` is pending, staying open when it
* rejects per app convention (the caller toasts errors).
*
* With no actions — or with `disabled` — it renders a plain non-clickable
* StatusChip without a tooltip.
*/
export default function StatusChipMenu({
label,
color,
actions,
disabled = false,
title = "Změnit stav",
}: StatusChipMenuProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [pending, setPending] = useState<StatusChipAction | null>(null);
const [loading, setLoading] = useState(false);
if (disabled || !actions || actions.length === 0) {
return <StatusChip label={label} color={color} />;
}
const handlePick = (action: StatusChipAction) => {
setAnchorEl(null);
if (action.confirm) {
setPending(action);
} else {
void (async () => {
try {
await action.onAction();
} catch {
// Expected: the caller toasts its own errors; nothing to close here.
}
})();
}
};
const handleConfirm = async () => {
if (!pending) return;
setLoading(true);
try {
await pending.onAction();
// Success → close; ConfirmDialog freezes its content through the fade.
setPending(null);
} catch {
// Expected: rejection keeps the dialog open; the caller toasts the error.
} finally {
setLoading(false);
}
};
return (
<>
<StatusChip
label={label}
color={color}
title={title}
aria-haspopup="menu"
aria-expanded={Boolean(anchorEl)}
onClick={(e) => {
e.stopPropagation();
setAnchorEl(e.currentTarget);
}}
/>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
transformOrigin={{ vertical: "top", horizontal: "left" }}
slotProps={{ list: { dense: true } }}
>
{actions.map((action) => (
<MenuItem
key={action.key}
// stopPropagation: MUI portals re-bubble synthetic events to
// React-tree ancestors — a future onRowClick row must not fire.
onClick={(e) => {
e.stopPropagation();
handlePick(action);
}}
sx={action.danger ? { color: "error.main" } : undefined}
>
{action.label}
</MenuItem>
))}
</Menu>
<ConfirmDialog
isOpen={pending !== null}
onClose={() => setPending(null)}
onConfirm={() => void handleConfirm()}
title={pending?.confirm?.title ?? ""}
message={pending?.confirm?.message ?? ""}
confirmText={pending?.confirm?.confirmText}
confirmVariant={pending?.danger ? "danger" : "primary"}
loading={loading}
/>
</>
);
}

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 { useAlert } from "../../context/AlertContext";
import apiFetch from "../../utils/api";
import { USER_INVALIDATE } from "../../lib/queries/users";
import { iconBadgeSx } from "../../theme";
import { Card, Button, Modal, Field, TextField } from "../../ui";
import useDialogScrollLock from "../../ui/useDialogScrollLock";
@@ -142,6 +143,9 @@ export default function DashProfile({
const { data: totpQrDataUrl, isError: totpQrFailed } = useQuery({
queryKey: ["totp", "qr", 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,
// 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.
@@ -221,9 +225,13 @@ export default function DashProfile({
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
});
// Refresh anything keyed on the current user's data so stale views
// (dashboard widgets, user lists) pick up the edited profile.
// 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: ["users"] });
setShowModal(false);
// The 300ms wait is load-bearing: it lets the modal's close fade finish
// before the success toast appears, so the toast doesn't flash over the
@@ -262,8 +270,9 @@ export default function DashProfile({
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
style={{ height: "100%" }}
>
<Card>
<Card sx={{ height: "100%" }}>
<Box
sx={{
display: "flex",

View File

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

View File

@@ -103,6 +103,10 @@ export default function OdinChat() {
const fileRef = useRef<HTMLInputElement>(null);
const threadRef = useRef<HTMLDivElement>(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.
const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), {
@@ -177,17 +181,63 @@ export default function OdinChat() {
};
// Attaching only stages files; nothing is sent until Odeslat.
const onFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
const stageFiles = (files: File[]) => {
if (files.length === 0) return;
setAttachments((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 = "";
};
const removeAttachment = (id: string) =>
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) => {
setSidebarOpen(false);
if (id === activeId || busy) return;
@@ -467,9 +517,14 @@ export default function OdinChat() {
sidebar
)}
{/* Chat column */}
{/* Chat column — also the drop target for invoice files (overlay below). */}
<Box
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
sx={{
position: "relative",
flex: 1,
minWidth: 0,
display: "flex",
@@ -557,11 +612,14 @@ export default function OdinChat() {
{review.length > 0 && (
<Box
sx={{
maxHeight: "35vh",
maxHeight: "40vh",
overflowY: "auto",
display: "flex",
flexDirection: "column",
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) => (
@@ -591,6 +649,63 @@ export default function OdinChat() {
onRemoveAttachment={removeAttachment}
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>
);

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 MenuItem from "@mui/material/MenuItem";
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
// currently-selected reservation is no longer available, clear it and notify

View File

@@ -8,6 +8,7 @@ import {
useEffect,
type ReactNode,
} from "react";
import { registerQueryErrorNotifier } from "../lib/queryClient";
interface Alert {
id: string;
@@ -43,9 +44,10 @@ export function AlertProvider({ children }: { children: ReactNode }) {
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
useEffect(() => {
const timeouts = timeoutsRef.current;
return () => {
timeoutsRef.current.forEach(clearTimeout);
timeoutsRef.current.clear();
timeouts.forEach(clearTimeout);
timeouts.clear();
};
}, []);
@@ -80,6 +82,13 @@ export function AlertProvider({ children }: { children: ReactNode }) {
[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 (
<AlertContext.Provider value={methods}>
<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 };
} catch {
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);
return { success: false, error: errorMsg };
}
@@ -306,7 +311,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setError(data.error);
return { success: false, error: data.error };
} catch {
const errorMsg = "Chyba pripojeni.";
const errorMsg = "Chyba připojení.";
setError(errorMsg);
return { success: false, error: errorMsg };
}

View File

@@ -18,10 +18,8 @@ import {
getDaysInMonth,
shiftDateForMonth,
formatHoursDecimal,
calculateNightMinutes,
normalizeDateStr,
holidaysInMonth,
calculateFreeHolidayHours,
computeMzdaSummary,
getCzechWeekday,
} from "../utils/attendanceHelpers";
import type {
@@ -29,7 +27,7 @@ import type {
ProjectLog,
Project,
User,
} from "../components/ShiftFormModal";
} from "../components/shiftFormTypes";
// ---------------------------------------------------------------------------
// Types
@@ -99,6 +97,7 @@ interface UserTotal {
weekend_hours?: number;
night_minutes?: number;
worked_holiday_hours?: number; // sum of hours worked ON a holiday
manko?: number; // Chybějící hodiny: real clock time below the fond
records?: AttendanceRecord[];
}
@@ -216,117 +215,36 @@ function computeUserTotals(
}
}
// Add fund data per user (matching PHP addFundDataToUserTotals)
const [yearStr, monthStr] = month.split("-");
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1;
// Count Mon-Fri business days in month (INCLUDING holidays).
// The fund is rawBizDays × 8 — holidays stay in the count so the
// user is paid for them via the fund. Svátek (free holiday hours) is
// computed separately as "8h per holiday the user did not work".
let rawBizDays = 0;
const cur = new Date(yr, mo, 1);
while (cur.getMonth() === mo) {
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) rawBizDays++;
cur.setDate(cur.getDate() + 1);
}
const holidayDates = holidaysInMonth(yr, mo + 1);
const holidayCount = holidayDates.length;
// Rekapitulace per user — the SAME shared computation the print uses
// (computeMzdaSummary), so the screen and the printed sheet can never
// disagree. Accountant spec 2026-07: Odpracováno = worked minus
// holiday-worked; Vč. svátků = ALL worked; Přesčas = max(0, worked fond);
// Svátek = hours actually worked on holidays.
for (const uid of Object.keys(totals)) {
const t = totals[uid];
const userRecords = recordsByUser.get(Number(uid)) || [];
// Odpracováno: floor(worked / 8) × 8 — round to whole days, drop remainder.
const workedHoursRaw = t.minutes / 60;
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
// So/Ne: sum of worked hours on Sat/Sun (raw, not rounded).
const weekendHours = userRecords
.filter(
(r) =>
(r.leave_type || "work") === "work" && isWeekendDate(r.shift_date),
)
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
// Práce v noci: minutes in 22:00-06:00 across all work records.
const nightMinutes = userRecords
.filter((r) => (r.leave_type || "work") === "work")
.reduce((sum, r) => sum + calculateNightMinutes(r), 0);
// Svátek (free): 8h per holiday the user did not work.
const freeHolidayHours = calculateFreeHolidayHours(
userRecords,
holidayDates,
);
// Worked holidays: holidays the user DID work (counted toward
// Odpracováno, but their 8h must not flow into Přesčas).
const workedDatesSet = new Set(
userRecords
.filter((r) => (r.leave_type || "work") === "work")
.map((r) => normalizeDateStr(r.shift_date))
.filter(Boolean),
);
let workedHolidayCount = 0;
let workedHolidayHours = 0;
for (const hd of holidayDates) {
if (workedDatesSet.has(hd)) workedHolidayCount++;
}
// Sum the actual hours worked on holiday dates (for the KPI card's
// "fond used" total = real_work + vacation + worked_holiday + free_holiday).
for (const r of userRecords) {
if ((r.leave_type || "work") !== "work") continue;
const d = normalizeDateStr(r.shift_date);
if (d && holidayDates.includes(d)) {
workedHolidayHours += calculateWorkMinutesPrint(r) / 60;
}
}
// Fund = Mon-Fri × 8h. Holidays count as work days (already in rawBizDays).
const fund = rawBizDays * 8;
// Včetně svátků a přesčasů (mzda formula):
// odpracovano + vacation + remainder min(freeHols, workedHols × 8)
//
// Two-way logic matching the print:
// • If the user worked at least one holiday, that worked holiday's
// 8h is in Odpracováno and shouldn't also count as Přesčas. We
// subtract one 8h per worked holiday (capped at the total free
// holiday hours — can't go negative).
// • If the user did NOT work any holiday, no adjustment is needed:
// all unworked holidays are already paid via the Svátek line and
// don't flow into Přesčas, so vcetne_svatku just equals the
// work + vacation + remainder.
const remainder = workedHoursRaw - odpracovano;
const holidayAdj = Math.min(freeHolidayHours, workedHolidayCount * 8);
const vcetneSv = odpracovano - holidayAdj + t.vacation_hours + remainder;
// Přesčas = vcetneSv odpracovano (derived).
const prescas = vcetneSv - odpracovano;
const s = computeMzdaSummary(userRecords, month);
// Legacy fields (kept so existing UI doesn't break).
const workedHours = Math.round(workedHoursRaw * 10) / 10;
const workedHours = Math.round(s.workedHoursRaw * 10) / 10;
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
t.fund = fund;
t.business_days = rawBizDays;
t.fund = s.fund;
t.business_days = s.businessDays;
t.worked_hours = workedHours;
t.covered = covered;
t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10);
t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10);
t.missing = Math.max(0, Math.round((s.fund - covered) * 10) / 10);
t.overtime = Math.max(0, Math.round((covered - s.fund) * 10) / 10);
t.worked_hours_raw = workedHoursRaw;
t.odpracovano = odpracovano;
t.vcetne_svatku = vcetneSv;
t.prescas = prescas;
t.svatek = freeHolidayHours;
t.weekend_hours = weekendHours;
t.night_minutes = nightMinutes;
t.worked_holiday_hours = workedHolidayHours;
t.worked_hours_raw = s.workedHoursRaw;
t.odpracovano = s.odpracovano;
t.vcetne_svatku = s.vcetneSvatku;
t.prescas = s.prescas;
t.svatek = s.svatekHours;
t.weekend_hours = s.weekendHours;
t.night_minutes = s.nightMinutes;
t.worked_holiday_hours = s.svatekHours;
t.manko = s.manko;
}
return totals;
@@ -345,14 +263,25 @@ function escapeHtml(str: string): string {
.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) {
return record.project_logs
.map((log: any) => {
.map((log) => {
let h: number, m: number;
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(log.hours) || 0;
m = parseInt(log.minutes) || 0;
// hours/minutes arrive as string or number — String() before parseInt
// (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) {
const mins = Math.max(
0,
@@ -375,15 +304,28 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
return escapeHtml(record.project_name || "—");
}
function buildUserSectionHtml(
export function buildUserSectionHtml(
userId: string,
userData: Record<string, any>,
printData: Record<string, any>,
userData: UserTotal,
printData: PrintData,
): string {
// Build a date-keyed lookup of the user's records.
const recordsByDate = new Map<string, Record<string, any>>();
// Build a date-keyed lookup of the user's records. A day may hold MULTIPLE
// records (two shifts, or work + partial-day vacation) — keep them ALL; a
// single-record map silently dropped every shift but the last one.
const recordsByDate = new Map<string, AttendanceRecord[]>();
for (const r of userData.records || []) {
recordsByDate.set(normalizeDateStr(r.shift_date), r);
const key = normalizeDateStr(r.shift_date);
const list = recordsByDate.get(key);
if (list) list.push(r);
else recordsByDate.set(key, [r]);
}
// Stable in-day order: by arrival time (leave records without times last).
for (const list of recordsByDate.values()) {
list.sort((a, b) =>
String(a.arrival_time || "9999").localeCompare(
String(b.arrival_time || "9999"),
),
);
}
// Iterate one row per day of the month.
@@ -392,24 +334,19 @@ function buildUserSectionHtml(
const mo = parseInt(monthStr, 10);
const daysInMonth = getDaysInMonth(yr, mo);
const userRecords = (userData.records || []) as Record<string, any>[];
const userRecords = userData.records ?? [];
const rows: string[] = [];
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = shiftDateForMonth(yr, mo, d);
const record = recordsByDate.get(dateStr);
const dayRecords = recordsByDate.get(dateStr);
const weekend = isWeekendDate(dateStr);
const holiday = isHoliday(dateStr);
const leaveType = (record?.leave_type as string) || "work";
const rowClasses = [
weekend && "weekend",
holiday && "holiday",
leaveType === "vacation" && "vacation",
]
.filter(Boolean)
.join(" ");
if (!record) {
if (!dayRecords || dayRecords.length === 0) {
const rowClasses = [weekend && "weekend", holiday && "holiday"]
.filter(Boolean)
.join(" ");
rows.push(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
@@ -424,25 +361,37 @@ function buildUserSectionHtml(
continue;
}
const isLeave = leaveType !== "work";
const workMinutes = calculateWorkMinutesPrint(record);
const hours = Math.floor(workMinutes / 60);
const mins = workMinutes % 60;
const breakCell =
isLeave || !record.break_start || !record.break_end
? "—"
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
// One row PER RECORD — a day can hold two shifts, or work + partial-day
// vacation. (A single row per day silently dropped the extra shifts.)
for (const record of dayRecords) {
const leaveType = (record.leave_type as string) || "work";
const rowClasses = [
weekend && "weekend",
holiday && "holiday",
leaveType === "vacation" && "vacation",
]
.filter(Boolean)
.join(" ");
let hoursCell: string;
if (workMinutes > 0 && !isLeave) {
hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
} else if (isLeave) {
hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
} else {
hoursCell = "—";
}
const isLeave = leaveType !== "work";
const workMinutes = calculateWorkMinutesPrint(record);
const hours = Math.floor(workMinutes / 60);
const mins = workMinutes % 60;
const breakCell =
isLeave || !record.break_start || !record.break_end
? "—"
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
rows.push(`<tr class="${rowClasses}">
let hoursCell: string;
if (workMinutes > 0 && !isLeave) {
hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
} else if (isLeave) {
hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
} else {
hoursCell = "—";
}
rows.push(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
<td>${escapeHtml(getLeaveTypeName(leaveType))}</td>
@@ -453,113 +402,52 @@ function buildUserSectionHtml(
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${escapeHtml(record.notes || "")}</td>
</tr>`);
}
// ----- mzda-style summary numbers (computed here, not from userData,
// because the backend's getPrintData only returns the legacy fields). -----
// Worked minutes: sum of calculateWorkMinutesPrint across work records.
let workedMinutes = 0;
let weekendHoursRaw = 0;
let nightMinutes = 0;
for (const r of userRecords) {
const leaveType = r.leave_type || "work";
if (leaveType !== "work") continue;
const m = calculateWorkMinutesPrint(r);
workedMinutes += m;
if (isWeekendDate(r.shift_date)) {
weekendHoursRaw += m / 60;
}
nightMinutes += calculateNightMinutes(r);
}
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
// "holiday" leave_type is auto-computed from Czech holidays further down
// and no longer selectable in the UI, so it's deliberately omitted here.
let vacationHours = 0,
sickHours = 0;
for (const r of userRecords) {
const lt = r.leave_type || "work";
if (lt === "work") continue;
const h = Number(r.leave_hours) || 8;
if (lt === "vacation") vacationHours += h;
else if (lt === "sick") sickHours += h;
}
// Odpracováno: floor(worked / 8) × 8.
const workedHoursRaw = workedMinutes / 60;
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
// Free Svátek: 8h per holiday date the user did not work.
const holidayDates = holidaysInMonth(yr, mo);
const holidayCount = holidayDates.length;
const workedDates = new Set(
userRecords
.filter((r) => (r.leave_type || "work") === "work")
.map((r) => normalizeDateStr(r.shift_date))
.filter(Boolean),
);
let freeHolidayHours = 0;
let workedHolidayCount = 0;
for (const hd of holidayDates) {
if (workedDates.has(hd)) workedHolidayCount++;
else freeHolidayHours += 8;
}
// Fund: count Mon-Fri (incl. holidays) × 8h.
let rawBizDays = 0;
const cur = new Date(yr, mo - 1, 1);
while (cur.getMonth() === mo - 1) {
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) rawBizDays++;
cur.setDate(cur.getDate() + 1);
}
const businessDays = rawBizDays;
const fund = businessDays * 8;
// Včetně svátků a přesčasů: floor(worked/8)*8 worked_holiday*8 + vacation + remainder.
// (A worked holiday counts toward Odpracováno, but its 8h should not flow
// into Přesčas — so we subtract it here. Unworked holidays are paid via
// the fund and don't affect this line.)
const remainder = workedHoursRaw - odpracovano;
const vcetneSv =
odpracovano - workedHolidayCount * 8 + vacationHours + remainder;
// Přesčas = #2 - #1.
const prescas = vcetneSv - odpracovano;
// Rekapitulace — single shared implementation (accountant spec 2026-07):
// Odpracováno = all worked hours EXCEPT hours worked on holidays
// Vč. svátků a přesč. = ALL worked hours (line2 line1 = Svátek)
// Přesčas = max(0, worked fond)
// Svátek = hours actually worked on holiday dates
const s = computeMzdaSummary(userRecords, printData.month);
const summaryHtml = `<div class="user-summary">
<div class="summary-row">
<span class="summary-label">Odpracováno:</span>
<span class="summary-value">${formatHoursDecimal(odpracovano * 60)}h</span>
<span class="summary-value">${formatHoursDecimal(s.odpracovano * 60)}h</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">${formatHoursDecimal(vcetneSv * 60)}h</span>
<span class="summary-value">${formatHoursDecimal(s.vcetneSvatku * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Dovolená:</span>
<span class="summary-value">${formatHoursDecimal(vacationHours * 60)}h</span>
<span class="summary-value">${formatHoursDecimal(s.vacationHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Přesčas:</span>
<span class="summary-value">${formatHoursDecimal(prescas * 60)}h</span>
<span class="summary-value">${formatHoursDecimal(s.prescas * 60)}h</span>
</div>
<div class="summary-row${s.manko > 0 ? " summary-alert" : ""}">
<span class="summary-label">Chybějící hodiny:</span>
<span class="summary-value">${formatHoursDecimal(s.manko * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Svátek:</span>
<span class="summary-value">${formatHoursDecimal(freeHolidayHours * 60)}h</span>
<span class="summary-value">${formatHoursDecimal(s.svatekHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">So/Ne:</span>
<span class="summary-value">${formatHoursDecimal(weekendHoursRaw * 60)}h</span>
<span class="summary-value">${formatHoursDecimal(s.weekendHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Práce v noci:</span>
<span class="summary-value">${formatHoursDecimal(nightMinutes)}h</span>
<span class="summary-value">${formatHoursDecimal(s.nightMinutes)}h</span>
</div>
<div class="summary-row summary-fund">
<span class="summary-label">Fond měsíce:</span>
<span class="summary-value">${formatHoursDecimal(fund * 60)}h (${businessDays} dnů)</span>
<span class="summary-value">${formatHoursDecimal(s.fund * 60)}h (${s.businessDays} dnů)</span>
</div>
<div class="legend">
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
@@ -592,8 +480,8 @@ function buildUserSectionHtml(
</div>`;
}
function buildPrintHtml(
pData: Record<string, any>,
export function buildPrintHtml(
pData: PrintData,
userSections: string,
emptyMsg: string,
filterNote: string,
@@ -665,6 +553,9 @@ function buildPrintHtml(
}
.user-summary .summary-label { color: #555; }
.user-summary .summary-value { font-weight: 600; }
/* Chybějící hodiny > 0 — must jump out of the sheet, not hide in it. */
.user-summary .summary-alert .summary-label,
.user-summary .summary-alert .summary-value { color: #b00020; font-weight: 700; }
.user-summary .summary-fund {
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
}
@@ -1334,7 +1225,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
const pData = result.data;
const userSections = Object.entries(pData.user_totals)
.map(([uid, uData]) =>
buildUserSectionHtml(uid, uData as Record<string, any>, pData),
buildUserSectionHtml(uid, uData as UserTotal, pData),
)
.join("");
const emptyMsg =

View File

@@ -200,7 +200,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// Snapshot the currently-cached grid for a given key. Returns
// `undefined` if there's nothing cached (we don't patch empty data).
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
@@ -312,7 +312,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
function rollbackCells(ctx: CellRollback | undefined) {
if (!ctx || !ctx.rolled) return;
const prev = qc.getQueryData<GridData>(ctx.key as any);
const prev = qc.getQueryData<GridData>(ctx.key);
if (!prev) return;
const userPrev = prev.cells[ctx.userId] ?? {};
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
// looking for any cell with entryId === id (we already know
// 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;
if (grid) {
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.
// Look it up from the current grid: find the user that has a 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;
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
let entryRange: { from: string; to: string } | null = null;
@@ -511,7 +511,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
onMutate: (vars): CellRollback => {
// Find the user/date for this overrideId in the current grid, then
// 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;
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cells] of Object.entries(byDate)) {
@@ -559,7 +559,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
method: "DELETE",
}),
onMutate: (vars): CellRollback => {
const grid = qc.getQueryData<GridData>(currentGridKey as any);
const grid = qc.getQueryData<GridData>(currentGridKey);
if (!grid) return null;
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
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
// `mutateAsync` promise rejects, the mutation's own `onError` has already
// restored the patched cells, so there is nothing left to do here.
function rollbackMutation(_mutation: unknown, _userId: number) {
/* no-op — see onMutate/onError above */
}

View File

@@ -1,6 +1,6 @@
/**
* 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` +
* `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" },
v_realizaci: { label: "V realizaci", color: "warning" },
dokoncena: { label: "Dokončená", color: "success" },
stornovana: { label: "Stornována", color: "error" },
stornovana: { label: "Stornovaná", color: "error" },
};
/** ISSUED ORDER / objednávka vydaná (issued_orders). */
@@ -75,6 +75,17 @@ export const RECEIVED_INVOICE_STATUS: Record<string, StatusMeta> = {
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)
* have a NULL/empty number until they are finalized — show "Koncept" instead

View File

@@ -77,7 +77,10 @@ export interface BalancesData {
export interface FundUserData {
name: string;
worked: number;
/** worked + dovolená + nemoc + neplacené volno — covers the fond (manko base). */
covered: number;
/** worked + dovolená only — the Přesčas base (print-recap parity). */
covered_prescas: number;
overtime: number;
missing: number;
}

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

View File

@@ -60,6 +60,7 @@ export interface IssuedOrderDetail extends IssuedOrder {
sections: IssuedOrderSection[];
supplier: Record<string, unknown> | null;
valid_transitions: string[];
selected_custom_fields: number[];
/** Fresh edit lock held by ANOTHER user (same shape as offers), else null. */
locked_by: {
user_id: number;
@@ -143,6 +144,9 @@ export const issuedOrderDetailOptions = (id: string | undefined) =>
// 404s (deleted/missing orders) are not transient. Retrying just spams
// GETs and keeps the detail page on an infinite spinner.
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 = () =>

View File

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

View File

@@ -143,6 +143,7 @@ export interface OfferItemData {
quantity: number;
unit: string;
unit_price: number;
discount?: number;
is_included_in_total: boolean;
position?: number;
}
@@ -183,6 +184,7 @@ export interface OfferDetailData {
locked_by: OfferLockInfo | null;
/** Legal next statuses, computed server-side (same contract as issued orders). */
valid_transitions: string[];
selected_custom_fields: number[];
}
export const offerDetailOptions = (id: string | undefined) =>
@@ -193,6 +195,9 @@ export const offerDetailOptions = (id: string | undefined) =>
// 404s (deleted/missing offers) are not transient. Retrying just spams
// GETs and fires the detail-page redirect toast repeatedly.
retry: false,
// OfferDetail toasts its own Czech error + navigates away (and suppresses
// it during delete) — the global QueryCache toast would double up.
meta: { suppressGlobalErrorToast: true },
});
export const offerNextNumberOptions = () =>

View File

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

View File

@@ -28,6 +28,8 @@ export interface ProjectData {
quotation_number?: string;
has_nas_folder?: boolean;
project_notes?: ProjectNote[];
/** Valid status-machine targets from the current status (server-computed). */
valid_transitions?: string[];
}
export interface Project {

View File

@@ -18,6 +18,20 @@ export interface Role {
display_name: string;
}
/**
* Query-key domains to invalidate on any user create/update/delete: a user's
* display name is embedded in these domains (CLAUDE.md: "user CRUD →
* trips+attendance").
*/
export const USER_INVALIDATE: readonly string[] = [
"users",
"trips",
"attendance",
"leave-requests",
"leave",
"projects",
];
export const userListOptions = (permission?: string) =>
queryOptions({
queryKey: ["users", { permission }],

View File

@@ -1,6 +1,39 @@
import { QueryClient } from "@tanstack/react-query";
import { QueryCache, QueryClient } from "@tanstack/react-query";
/**
* Global query-error notifier. AlertContext registers its `error()` toast
* here on mount; until then failures fall back to console.error.
*/
let queryErrorNotifier: ((msg: string) => void) | null = null;
export function registerQueryErrorNotifier(fn: (msg: string) => void) {
queryErrorNotifier = fn;
}
/** Rate limit: at most one query-error toast per window (a page can have many failing queries). */
const ERROR_TOAST_WINDOW_MS = 4000;
let lastErrorToastAt = 0;
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
// Pages with bespoke error handling (detail pages that toast + navigate,
// purely-local queries like QR generation) opt out via query meta.
if (query.meta?.suppressGlobalErrorToast) return;
// Background refetch failure with stale data still on screen — stay silent.
if (query.state.data !== undefined) return;
// 401s are handled by apiFetch/AuthContext (token refresh / logout).
if (error instanceof Error && error.message === "Unauthorized") return;
const now = Date.now();
if (now - lastErrorToastAt < ERROR_TOAST_WINDOW_MS) return;
lastErrorToastAt = now;
if (queryErrorNotifier) {
queryErrorNotifier("Nepodařilo se načíst data ze serveru");
} else {
console.error("Query failed before alert notifier registered:", error);
}
},
}),
defaultOptions: {
queries: {
staleTime: 30_000,

View File

@@ -46,28 +46,29 @@ interface UserTotalData {
prescas?: number;
svatek?: number;
worked_holiday_hours?: number;
/** Chybějící hodiny: real clock time below the fond (approved absences count). */
manko?: number;
}
/** Fond "used" total mirrors the Fond footer line below:
* real_worked + vacation + worked_holiday + free_holiday. */
/** Fond coverage — print-recap parity (computeMzdaSummary): real worked
* hours + every approved absence (dovolená + nemoc + neplacené volno).
* This is the base the Chybějící hodiny line measures against. */
function getFondUsed(data: UserTotalData): number {
const realWorked = data.worked_hours_raw ?? data.worked_hours;
return (
realWorked +
(data.vacation_hours ?? 0) +
(data.worked_holiday_hours ?? 0) +
(data.svatek ?? 0)
(data.sick_hours ?? 0) +
(data.unpaid_hours ?? 0)
);
}
/** Maps the Fond delta (used fund) to a ProgressBar color token, mirroring
* the legacy getFundBarBackground conditional: over fund → warning, exactly
* at fund → success, under fund → primary (was the accent gradient). */
/** Bar color follows the print recap: Přesčas > 0 → warning, Chybějící
* hodiny > 0 → error, exactly covered → success. */
function getFundBarColor(data: UserTotalData): ProgressColor {
const delta = getFondUsed(data) - (data.fund ?? 0);
if (delta > 0) return "warning";
if (delta >= 0) return "success";
return "primary";
if ((data.prescas ?? 0) > 0) return "warning";
if ((data.manko ?? 0) > 0) return "error";
return "success";
}
const PrintIcon = (
@@ -293,17 +294,24 @@ export default function AttendanceAdmin() {
label={`Nep: ${ut.unpaid_hours}h`}
/>
)}
{ut.manko !== undefined && ut.manko > 0 && (
<StatusChip
color="error"
label={`Chybí: ${String(Math.round(ut.manko * 100) / 100).replace(".", ",")}h`}
title="Skutečně odpracované hodiny + schválené absence nedosahují fondu měsíce"
/>
)}
</Box>
{/* Fond usage */}
{/* Fond usage — the ± badges are the PRINT recap values
(Přesčas / Chybějící hodiny), so the card and the
printed sheet can never disagree. */}
{ut.fund !== null &&
(() => {
// Fond "used" = real_worked + vacation +
// worked_holiday + free_holiday (everything the user
// actually got paid for this month).
const fondUsed = getFondUsed(ut);
const fundVal = ut.fund ?? 0;
const delta = fondUsed - fundVal;
const prescas = ut.prescas ?? 0;
const manko = ut.manko ?? 0;
const pct = Math.min(
100,
(fondUsed / (ut.fund || 1)) * 100,
@@ -325,26 +333,28 @@ export default function AttendanceAdmin() {
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h
</Typography>
{delta > 0 && (
{prescas > 0 && (
<Typography
variant="caption"
title="Přesčas (worked + dovolená nad fond)"
sx={{
fontWeight: 600,
color: "warning.main",
}}
>
+{formatHoursDecimal(delta * 60)}h
+{formatHoursDecimal(prescas * 60)}h
</Typography>
)}
{delta < 0 && (
{manko > 0 && (
<Typography
variant="caption"
title="Chybějící hodiny do fondu"
sx={{
fontWeight: 600,
color: "error.main",
}}
>
-{formatHoursDecimal(Math.abs(delta) * 60)}h
-{formatHoursDecimal(manko * 60)}h
</Typography>
)}
</Box>

View File

@@ -251,6 +251,7 @@ export default function AttendanceBalances() {
let totalFund = 0;
let totalWorked = 0;
let totalCovered = 0;
let totalCoveredPrescas = 0;
for (const monthData of Object.values(fundData.months)) {
// Use prorated fund (fund_to_date) for current month, full fund for past
totalFund += monthData.fund_to_date ?? monthData.fund;
@@ -258,15 +259,20 @@ export default function AttendanceBalances() {
if (us) {
totalWorked += us.worked;
totalCovered += us.covered;
// Older cached payloads may lack covered_prescas — fall back to
// covered (the pre-parity behavior) rather than dropping to 0.
totalCoveredPrescas += us.covered_prescas ?? us.covered;
}
}
// Print-recap parity: missing (Chybějící hodiny) counts every approved
// absence; overtime (Přesčas) counts only worked + dovolená.
const missing = Math.max(
0,
Math.round((totalFund - totalCovered) * 10) / 10,
);
const overtime = Math.max(
0,
Math.round((totalCovered - totalFund) * 10) / 10,
Math.round((totalCoveredPrescas - totalFund) * 10) / 10,
);
return {
fund: totalFund,

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import Forbidden from "../components/Forbidden";
import { czechPlural } from "../utils/formatters";
import useDebounce from "../hooks/useDebounce";
import apiFetch from "../utils/api";
import { useApiMutation } from "../lib/queries/mutations";
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
@@ -122,6 +123,9 @@ export default function AuditLog() {
date_from: "",
date_to: "",
});
// Raw value stays in the TextField; only the debounced value hits the
// queryKey/request, so typing doesn't refire the query on every keystroke.
const debouncedSearch = useDebounce(filters.search, 300);
const [page, setPage] = useState(1);
const [perPage] = useState(50);
const [showCleanup, setShowCleanup] = useState(false);
@@ -136,7 +140,7 @@ export default function AuditLog() {
queryKey: [
"audit-log",
{
search: filters.search,
search: debouncedSearch,
action: filters.action,
entityType: filters.entity_type,
dateFrom: filters.date_from,
@@ -150,7 +154,7 @@ export default function AuditLog() {
page: String(page),
per_page: String(perPage),
});
if (filters.search) params.set("search", filters.search);
if (debouncedSearch) params.set("search", debouncedSearch);
if (filters.action) params.set("action", filters.action);
if (filters.entity_type) params.set("entity_type", filters.entity_type);
if (filters.date_from) params.set("date_from", filters.date_from);
@@ -177,6 +181,12 @@ export default function AuditLog() {
// doesn't drop to <LoadingState/> (which unmounts PageEnter and replays the
// whole entrance animation on every filter change).
placeholderData: keepPreviousData,
// logAudit() writes on every mutation app-wide and none of those
// mutations invalidate ["audit-log"], so cached data goes stale the
// moment anything happens elsewhere — always refetch on mount (same
// rationale as dashboardOptions).
staleTime: 0,
refetchOnMount: "always",
});
const logs: AuditLogEntry[] = logsData?.data ?? [];

View File

@@ -3,10 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import { companySettingsOptions } from "../lib/queries/settings";
import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
@@ -23,7 +20,6 @@ import {
TextField,
Select,
CheckboxField,
StatusChip,
FileUpload,
LoadingState,
type DataColumn,

View File

@@ -124,7 +124,7 @@ export default function Dashboard() {
});
alert.success(result?.message || "Docházka zaznamenána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba pripojeni");
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setPunching(false);
}
@@ -348,7 +348,9 @@ export default function Dashboard() {
display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
gap: 3,
alignItems: "start",
// stretch (not start) so the cards in a row share one height —
// even bottoms instead of ragged content-height cards.
alignItems: "stretch",
mb: 3,
}}
>
@@ -360,10 +362,18 @@ export default function Dashboard() {
<DashAttendanceToday attendance={dashData?.attendance ?? null} />
)}
{/* Pravy sloupec: projekty + nabidky */}
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{/* Pravy sloupec: projekty + nabidky — both grow so the stacked
cards fill the column to match the neighbouring cards' height. */}
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 3,
height: "100%",
}}
>
{dashData?.projects && (
<Card sx={{ display: "flex", flexDirection: "column" }}>
<Card sx={{ display: "flex", flexDirection: "column", flex: 1 }}>
<Box
sx={{
display: "flex",
@@ -421,7 +431,7 @@ export default function Dashboard() {
)}
{dashData?.offers && (
<Card sx={{ display: "flex", flexDirection: "column" }}>
<Card sx={{ display: "flex", flexDirection: "column", flex: 1 }}>
<Box
sx={{
display: "flex",
@@ -498,7 +508,7 @@ export default function Dashboard() {
display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" },
gap: 3,
alignItems: "start",
alignItems: "stretch",
}}
>
<DashProfile

View File

@@ -27,6 +27,7 @@ import RichEditor from "../components/RichEditor";
import SectionsEditor, {
type DocumentSection,
} from "../components/document/SectionsEditor";
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import {
DndContext,
closestCenter,
@@ -185,9 +186,34 @@ interface InvoiceItem {
quantity: string | number;
unit: string;
unit_price: string | number;
/** Per-item discount as a percentage (0100). */
discount: string | number;
vat_rate: number;
}
/**
* Discount input styling: centered, 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 invoice line after its per-item percentage discount. */
const invoiceLineNet = (item: {
quantity: string | number;
unit_price: string | number;
discount?: string | number | null;
}): number =>
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0) *
(1 - (Number(item.discount) || 0) / 100);
interface InvoiceForm {
customer_id: number | null;
customer_name: string;
@@ -253,8 +279,7 @@ function SortableInvoiceRow({
position: "relative" as const,
zIndex: isDragging ? 10 : undefined,
};
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const lineTotal = invoiceLineNet(item);
if (isMobile) {
return (
@@ -349,6 +374,14 @@ function SortableInvoiceRow({
onChange={(e) => onUpdate(index, "unit_price", e.target.value)}
slotProps={{ htmlInput: { step: "any" } }}
/>
<TextField
label="Sleva %"
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate(index, "discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
sx={discountInputSx}
/>
{apply_vat && (
<Select
label="DPH"
@@ -458,6 +491,15 @@ function SortableInvoiceRow({
sx={{ "& input": { textAlign: "right" } }}
/>
</TableCell>
<TableCell>
<TextField
type="number"
value={item.discount ?? ""}
onChange={(e) => onUpdate(index, "discount", e.target.value)}
slotProps={{ htmlInput: { min: "0", max: "100", step: "any" } }}
sx={discountInputSx}
/>
</TableCell>
{apply_vat ? (
<TableCell>
<Select
@@ -513,6 +555,7 @@ export default function InvoiceDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
discount: 0,
vat_rate: 21,
}),
[],
@@ -569,6 +612,9 @@ export default function InvoiceDetail() {
// Rich-text CZ/EN "Obsah" sections (printed after the items on the PDF —
// shared editor with offers/issued orders).
const [sections, setSections] = useState<DocumentSection[]>([]);
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [items, setItems] = useState<InvoiceItem[]>(() => [
{
_key: "inv-1",
@@ -577,6 +623,7 @@ export default function InvoiceDetail() {
quantity: 1,
unit: "ks",
unit_price: 0,
discount: 0,
vat_rate: 21,
},
]);
@@ -621,10 +668,17 @@ export default function InvoiceDetail() {
const customers = customersQuery.data ?? [];
const bankAccountsQuery = useQuery(bankAccountsOptions());
const bankAccounts = bankAccountsQuery.data ?? [];
const bankAccounts = useMemo(
() => bankAccountsQuery.data ?? [],
[bankAccountsQuery.data],
);
const invoiceQuery = useQuery(invoiceDetailOptions(id));
const invoice = invoiceQuery.data ?? null;
// Read-only view: show the Sleva column only when some line carries a discount.
const hasItemDiscount = (invoice?.items ?? []).some(
(it) => Number(it.discount) > 0,
);
const nextNumberQuery = useQuery({
queryKey: ["invoices", "next-number"],
@@ -656,8 +710,9 @@ export default function InvoiceDetail() {
const [deleting, setDeleting] = useState(false);
useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
blobTimeouts.forEach(clearTimeout);
};
}, []);
@@ -747,6 +802,7 @@ export default function InvoiceDetail() {
quantity: Number(item.quantity) || 1,
unit: item.unit || "",
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
// Per-line VAT: hydrate from the ITEM's own stored rate (a real DB
// column returned by the detail endpoint) — falling back to the
// invoice-level rate only when the line carries none. Hydrating
@@ -769,11 +825,15 @@ export default function InvoiceDetail() {
: [];
setSections(mappedSections);
const mappedCustomFields = inv.selected_custom_fields ?? [];
setSelectedCustomFields(mappedCustomFields);
// Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
sections: mappedSections,
selectedCustomFields: mappedCustomFields,
});
setDataReady(true);
@@ -844,6 +904,7 @@ export default function InvoiceDetail() {
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
vat_rate: vatRate,
})),
);
@@ -870,15 +931,21 @@ export default function InvoiceDetail() {
// Edit mode: captured inside the sync effect from raw query data.
// Create mode: captured on the first render after sync effects populate the form.
if (dataReady && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
selectedCustomFields,
});
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
return (
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
JSON.stringify({ form, items, sections, selectedCustomFields }) !==
initialSnapshotRef.current
);
}, [form, items, sections]);
}, [form, items, sections, selectedCustomFields]);
useEffect(() => {
if (!isDirty) return;
@@ -963,8 +1030,7 @@ export default function InvoiceDetail() {
const vatByRate: Record<number, number> = {};
items.forEach((item) => {
const lineTotal =
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0);
const lineTotal = invoiceLineNet(item);
subtotal += lineTotal;
if (form.apply_vat) {
@@ -985,7 +1051,7 @@ export default function InvoiceDetail() {
>({
url: () => (isEdit ? `${API_BASE}/invoices/${id}` : `${API_BASE}/invoices`),
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["invoices", "orders"],
invalidate: ["invoices", "orders", "projects"],
onSuccess: (data) => {
const invoiceId = isEdit ? Number(id) : data.invoice_id;
// PDF binary generation — KEEP as raw apiFetch
@@ -998,13 +1064,13 @@ export default function InvoiceDetail() {
const statusMutation = useApiMutation<{ status: string }, unknown>({
url: () => `${API_BASE}/invoices/${id}`,
method: () => "PUT",
invalidate: ["invoices", "orders"],
invalidate: ["invoices", "orders", "projects"],
});
const invoiceDeleteMutation = useApiMutation<void, unknown>({
url: () => `${API_BASE}/invoices/${id}`,
method: () => "DELETE",
invalidate: ["invoices", "orders"],
invalidate: ["invoices", "orders", "projects"],
});
const handleCreateSubmit = async (targetStatus?: string) => {
@@ -1034,6 +1100,7 @@ export default function InvoiceDetail() {
// field never reaches the server as NaN (mirrors createTotals).
quantity: Number(item.quantity) || 0,
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
position: i,
})),
sections: sections.map((s, i) => ({
@@ -1042,6 +1109,7 @@ export default function InvoiceDetail() {
content: s.content,
position: i,
})),
selected_custom_fields: selectedCustomFields,
};
// Only set status when a target is given (create-as-draft / create-as-live
// / finalize). Editing a live invoice sends no status — the backend keeps
@@ -1054,7 +1122,12 @@ export default function InvoiceDetail() {
const data = await saveMutation.mutateAsync(payload);
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
selectedCustomFields,
});
if (!isEdit) {
navigate(`/invoices/${data.invoice_id}`);
}
@@ -1357,9 +1430,7 @@ export default function InvoiceDetail() {
isMobile ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{invoice.items.map((item, index) => {
const lineSubtotal =
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0);
const lineSubtotal = invoiceLineNet(item);
const lineVat = Number(invoice.apply_vat)
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
: 0;
@@ -1490,6 +1561,11 @@ export default function InvoiceDetail() {
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
{hasItemDiscount && (
<TableCell sx={{ width: "4rem" }} align="center">
Sleva %
</TableCell>
)}
<TableCell sx={{ width: "4rem" }} align="center">
DPH
</TableCell>
@@ -1500,9 +1576,7 @@ export default function InvoiceDetail() {
</TableHead>
<TableBody>
{invoice.items.map((item, index) => {
const lineSubtotal =
(Number(item.quantity) || 0) *
(Number(item.unit_price) || 0);
const lineSubtotal = invoiceLineNet(item);
const lineVat = Number(invoice.apply_vat)
? (lineSubtotal * (Number(item.vat_rate) || 0)) / 100
: 0;
@@ -1551,6 +1625,13 @@ export default function InvoiceDetail() {
>
{formatCurrency(item.unit_price, invoice.currency)}
</TableCell>
{hasItemDiscount && (
<TableCell align="center">
{Number(item.discount) > 0
? `${Number(item.discount)} %`
: "—"}
</TableCell>
)}
<TableCell align="center">
{Number(invoice.apply_vat)
? Number(item.vat_rate)
@@ -2148,6 +2229,15 @@ export default function InvoiceDetail() {
]}
/>
</Field>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={false}
onChange={setSelectedCustomFields}
/>
</Box>
</Card>
{/* Items */}
@@ -2234,6 +2324,9 @@ export default function InvoiceDetail() {
<TableCell sx={{ width: "8rem" }} align="center">
Jedn. cena
</TableCell>
<TableCell sx={{ width: "7rem" }} align="center">
Sleva %
</TableCell>
{form.apply_vat ? (
<TableCell sx={{ width: "5rem" }} align="center">
DPH

View File

@@ -20,6 +20,7 @@ import {
import { normalizeDateStr } from "../utils/attendanceHelpers";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import {
invoiceListOptions,
invoiceStatsOptions,
@@ -214,6 +215,7 @@ export default function Invoices() {
const [receivedUploadOpen, setReceivedUploadOpen] = useState(false);
const { sort, order, handleSort } = useTableSort("invoice_number");
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState("");
@@ -269,6 +271,11 @@ export default function Invoices() {
invoice: Invoice | null;
}>({ show: false, invoice: null });
const [deleting, setDeleting] = useState(false);
const [paidConfirm, setPaidConfirm] = useState<{
show: boolean;
invoice: Invoice | null;
}>({ show: false, invoice: null });
const [markingPaid, setMarkingPaid] = useState(false);
const [pdfLoading, setPdfLoading] = useState<number | null>(null);
const queryClient = useQueryClient();
@@ -279,7 +286,7 @@ export default function Invoices() {
isFetching: loading,
} = usePaginatedQuery<Invoice>(
invoiceListOptions({
search,
search: debouncedSearch,
sort,
order,
page,
@@ -294,7 +301,7 @@ export default function Invoices() {
// matches what's shown. Separate from the KPI `statsQuery` above.
const listTotalsQuery = useQuery(
invoiceTotalsOptions({
search,
search: debouncedSearch,
status: statusFilter || undefined,
month: statsMonth,
year: statsYear,
@@ -336,16 +343,21 @@ export default function Invoices() {
}
};
const toggleStatus = async (inv: Invoice) => {
if (inv.status === "paid") return;
const handleMarkPaid = async () => {
if (!paidConfirm.invoice) return;
setMarkingPaid(true);
try {
const res = await apiFetch(`${API_BASE}/invoices/${inv.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "paid" }),
});
const res = await apiFetch(
`${API_BASE}/invoices/${paidConfirm.invoice.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "paid" }),
},
);
const data = await res.json();
if (data.success) {
setPaidConfirm({ show: false, invoice: null });
alert.success("Faktura označena jako zaplacená");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
@@ -355,6 +367,8 @@ export default function Invoices() {
}
} catch {
alert.error("Chyba připojení");
} finally {
setMarkingPaid(false);
}
};
@@ -557,7 +571,8 @@ export default function Invoices() {
<StatusChip
label={statusLabel(INVOICE_STATUS, inv.status)}
color={statusColor(INVOICE_STATUS, inv.status)}
onClick={() => toggleStatus(inv)}
title="Označit jako zaplacenou"
onClick={() => setPaidConfirm({ show: true, invoice: inv })}
/>
),
},
@@ -613,7 +628,8 @@ export default function Invoices() {
>
{inv.status === "paid" ? ViewIcon : EditIcon}
</IconButton>
{hasPermission("invoices.view") && (
{/* Drafts have no number yet — /file 404s for them. */}
{hasPermission("invoices.view") && inv.invoice_number && (
<IconButton
size="small"
onClick={() => handlePdf(inv)}
@@ -760,7 +776,7 @@ export default function Invoices() {
sortDir={order}
onSort={handleSort}
empty={
search || statusFilter ? (
debouncedSearch || statusFilter ? (
<EmptyState title="Žádné faktury neodpovídají filtru." />
) : (
<EmptyState
@@ -807,12 +823,23 @@ export default function Invoices() {
onClose={() => setDeleteConfirm({ show: false, invoice: null })}
onConfirm={handleDelete}
title="Smazat fakturu"
message={`Opravdu chcete smazat fakturu "${deleteConfirm.invoice?.invoice_number}"? Tato akce je nevratná.`}
message={`Opravdu chcete smazat fakturu "${documentNumberLabel(deleteConfirm.invoice?.invoice_number)}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
confirmVariant="danger"
loading={deleting}
/>
<ConfirmDialog
isOpen={paidConfirm.show}
onClose={() => setPaidConfirm({ show: false, invoice: null })}
onConfirm={handleMarkPaid}
title="Označit fakturu jako zaplacenou"
message={`Označit fakturu "${documentNumberLabel(paidConfirm.invoice?.invoice_number)}" jako zaplacenou? Tato akce je nevratná.`}
confirmText="Označit jako zaplacenou"
cancelText="Zrušit"
loading={markingPaid}
/>
</>
</TabPanel>
</PageEnter>

View File

@@ -23,6 +23,7 @@ import SectionsEditor, {
type DocumentSection,
} from "../components/document/SectionsEditor";
import LockBanner from "../components/document/LockBanner";
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import { useDocumentLock } from "../hooks/useDocumentLock";
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
import { useDocumentPdf } from "../hooks/useDocumentPdf";
@@ -137,6 +138,7 @@ interface IssuedOrderSavePayload {
internal_notes: string;
items: IssuedOrderItemPayload[];
sections: IssuedOrderSectionPayload[];
selected_custom_fields: number[];
status?: string;
}
@@ -164,6 +166,9 @@ export default function IssuedOrderDetail() {
emptyDocumentItem(),
]);
const [sections, setSections] = useState<DocumentSection[]>([]);
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
// Which save action is in flight ("draft" | the live status | "save"), so
@@ -255,7 +260,7 @@ export default function IssuedOrderDetail() {
const canExport = hasPermission("orders.view");
const { isDirty, markClean } = useUnsavedChangesGuard(
{ form, items, sections },
{ form, items, sections, selectedCustomFields },
!isEdit || dataReady,
);
@@ -293,8 +298,12 @@ export default function IssuedOrderDetail() {
quantity: numberOr(it.quantity, 1),
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
// Issued orders have no Sleva column; keep the shared item shape happy.
discount: 0,
}))
: [emptyDocumentItem()];
: // Issued orders may be issued by sections alone — a saved order with
// no items reopens with an empty list (not a blank starter row).
[];
setItems(mappedItems);
const mappedSections =
@@ -306,6 +315,7 @@ export default function IssuedOrderDetail() {
}))
: [];
setSections(mappedSections);
setSelectedCustomFields(d.selected_custom_fields ?? []);
setLockedBy(d.locked_by ?? null);
// Acquire the edit lock when nobody holds it, the order is still
@@ -393,8 +403,18 @@ export default function IssuedOrderDetail() {
const newErrors: Record<string, string> = {};
if (!form.supplier_id) newErrors.supplier_id = "Vyberte dodavatele";
if (!form.order_date) newErrors.order_date = "Zadejte datum";
if (items.length === 0 || items.every((i) => !i.description.trim())) {
newErrors.items = "Přidejte alespoň jednu položku";
// Items are optional (an order may be issued by sections alone), but a
// completely blank order must not be finalizable: require at least one
// item with a description OR one non-empty section (title or content).
const hasItem = items.some((i) => i.description.trim());
const hasSection = sections.some(
(s) =>
(s.title_cz || "").trim() ||
(s.title || "").trim() ||
(s.content || "").replace(/<[^>]*>/g, "").trim(),
);
if (!hasItem && !hasSection) {
newErrors.items = "Přidejte alespoň jednu položku nebo obsah";
}
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
@@ -428,6 +448,7 @@ export default function IssuedOrderDetail() {
content: s.content,
position: i,
})),
selected_custom_fields: selectedCustomFields,
};
// Only set status when a target is given (create-as-draft / create-as-live
// / finalize). Editing a live order sends no status — the backend keeps
@@ -872,6 +893,15 @@ export default function IssuedOrderDetail() {
placeholder="Objednáváme si u Vás: (ponechte prázdné pro výchozí)"
/>
</Field>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={!editable}
onChange={setSelectedCustomFields}
/>
</Box>
</Card>
{/* Items */}
@@ -881,6 +911,7 @@ export default function IssuedOrderDetail() {
currency={form.currency}
readOnly={!editable}
error={errors.items}
allowEmpty
/>
{/* Rich-text PDF sections — the free-form PDF content lives here */}

View File

@@ -436,7 +436,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
action={
hasPermission("orders.create") ? (
<Button component={RouterLink} to="/orders/issued/new">
Vytvořit objednávku
Nová vydaná objednávka
</Button>
) : undefined
}

View File

@@ -87,7 +87,7 @@ export default function LeaveRequests() {
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "DELETE",
invalidate: ["leave-requests", "leave", "attendance"],
invalidate: ["leave-requests", "leave", "attendance", "users", "dashboard"],
onSuccess: (data) => {
setCancelModal({ open: false, id: null });
alert.success(data?.message || "Žádost byla zrušena");

View File

@@ -33,6 +33,7 @@ import SectionsEditor, {
type DocumentSection,
} from "../components/document/SectionsEditor";
import LockBanner from "../components/document/LockBanner";
import CustomFieldsPrintPicker from "../components/document/CustomFieldsPrintPicker";
import { useDocumentLock } from "../hooks/useDocumentLock";
import { useUnsavedChangesGuard } from "../hooks/useUnsavedChangesGuard";
import { useDocumentPdf } from "../hooks/useDocumentPdf";
@@ -93,6 +94,7 @@ interface OfferItemPayload {
quantity: number;
unit: string;
unit_price: number;
discount: number;
is_included_in_total: boolean;
position: number;
}
@@ -113,6 +115,7 @@ interface OfferSavePayload {
language: string;
items: OfferItemPayload[];
sections: OfferSectionPayload[];
selected_custom_fields: number[];
status?: string;
}
@@ -177,6 +180,9 @@ export default function OfferDetail() {
emptyDocumentItem(),
]);
const [sections, setSections] = useState<DocumentSection[]>([]);
const [selectedCustomFields, setSelectedCustomFields] = useState<number[]>(
[],
);
const [orderInfo, setOrderInfo] = useState<OfferOrderInfo | null>(null);
const [offerStatus, setOfferStatus] = useState<string>("");
const [quotationNumber, setQuotationNumber] = useState("");
@@ -269,7 +275,7 @@ export default function OfferDetail() {
const canDelete = isEdit && !orderInfo && !isInvalidated && !isLockedByOther;
const { markClean } = useUnsavedChangesGuard(
{ form, items, sections },
{ form, items, sections, selectedCustomFields },
!loading,
);
@@ -303,6 +309,7 @@ export default function OfferDetail() {
quantity: numberOr(it.quantity, 1),
unit: it.unit || "",
unit_price: Number(it.unit_price) || 0,
discount: Number(it.discount) || 0,
is_included_in_total: it.is_included_in_total !== false,
}))
: [emptyDocumentItem()];
@@ -316,6 +323,7 @@ export default function OfferDetail() {
}))
: [];
setSections(mappedSections);
setSelectedCustomFields(d.selected_custom_fields ?? []);
markClean({ form: formData, items: mappedItems, sections: mappedSections });
setOfferStatus(d.status || "");
setOrderInfo(d.order ?? null);
@@ -433,6 +441,7 @@ export default function OfferDetail() {
quantity: Number(item.quantity) || 0,
unit: item.unit,
unit_price: Number(item.unit_price) || 0,
discount: Number(item.discount) || 0,
is_included_in_total: item.is_included_in_total !== false,
position: i,
})),
@@ -442,6 +451,7 @@ export default function OfferDetail() {
content: s.content,
position: i,
})),
selected_custom_fields: selectedCustomFields,
};
// Only set status when a target is given (create-as-draft / create-as-live
// / finalize). Editing a live offer sends no status — the backend keeps
@@ -926,6 +936,15 @@ export default function OfferDetail() {
/>
</Field>
</Box>
<Box sx={{ mt: 2 }}>
<CustomFieldsPrintPicker
fields={companySettings?.custom_fields ?? []}
selected={selectedCustomFields}
disabled={readOnly}
onChange={setSelectedCustomFields}
/>
</Box>
</Card>
{/* Items (drag-and-drop, offers carry the "V ceně" column) */}
@@ -936,6 +955,7 @@ export default function OfferDetail() {
readOnly={readOnly}
error={errors.items}
showIncludedInTotal
showDiscount
itemDescriptionMaxLength={8000}
templatesSlot={
itemTemplates && itemTemplates.length > 0 ? (
@@ -957,6 +977,7 @@ export default function OfferDetail() {
quantity: 1,
unit: "ks",
unit_price: template.default_price || 0,
discount: 0,
is_included_in_total: true,
},
]);

View File

@@ -11,6 +11,9 @@ import ListItemText from "@mui/material/ListItemText";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import StatusChipMenu, {
type StatusChipAction,
} from "../components/StatusChipMenu";
import apiFetch from "../utils/api";
import {
@@ -41,7 +44,6 @@ import {
Field,
TextField,
Select,
StatusChip,
FileUpload,
EmptyState,
FilterBar,
@@ -413,6 +415,21 @@ export default function Offers() {
},
});
// Quick-action finalize (draft → active) from the status chip menu. A
// status-only PUT deliberately passes the editable-state guard; the number
// is assigned in-transaction server-side (same semantics as the detail).
const activateMutation = useApiMutation<
{ id: number; status: "active" },
{ id: number }
>({
url: (input) => `${API_BASE}/offers/${input.id}`,
method: () => "PUT",
invalidate: ["offers", "orders", "projects", "invoices"],
onSuccess: () => {
alert.success("Nabídka byla aktivována");
},
});
if (!hasPermission("offers.view")) return <Forbidden />;
const handleDuplicate = async (quotation: Quotation) => {
@@ -489,6 +506,89 @@ export default function Offers() {
}
};
const handleActivate = async (q: Quotation) => {
try {
await activateMutation.mutateAsync({ id: q.id, status: "active" });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
// Re-throw so the chip's ConfirmDialog stays open (app convention).
throw e;
}
// A finalized offer now has its official number — archive the PDF on the
// NAS, exactly like OfferDetail after finalize. Fire-and-forget: never
// block the toast/refresh — but a failure is surfaced, not swallowed.
apiFetch(`${API_BASE}/offers-pdf/${q.id}?save=1`)
.then((res) => {
if (!res.ok) alert.error("Nepodařilo se archivovat PDF nabídky na NAS");
})
.catch(() => alert.error("Nepodařilo se archivovat PDF nabídky na NAS"));
};
// "Zneplatnit" chip-menu entry — same wording as the page's existing
// invalidate ConfirmDialog and the same row mutation underneath.
const invalidateChipAction = (q: Quotation): StatusChipAction => ({
key: "invalidated",
label: "Zneplatnit",
danger: true,
confirm: {
title: "Zneplatnit nabídku",
message: `Opravdu chcete zneplatnit nabídku „${documentNumberLabel(q.quotation_number)}“? Nabídka bude pouze pro čtení a nepůjde upravovat.`,
confirmText: "Zneplatnit",
},
onAction: async () => {
try {
await invalidateMutation.mutateAsync(q.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
// Re-throw so the chip's ConfirmDialog stays open (app convention).
throw e;
}
},
});
// Quick actions for the status chip menu, by offer status. Only rendered
// when the user holds offers.edit (the page's mutating-action permission);
// "Vytvořit objednávku…" additionally mirrors the create-order icon's gates
// (active + no existing order + orders.create) and opens the same modal.
const statusQuickActions = (q: Quotation): StatusChipAction[] => {
switch (q.status) {
case "draft":
return [
{
key: "active",
label: "Aktivovat",
confirm: {
title: "Aktivovat nabídku",
message: `Nabídce „${documentNumberLabel(q.quotation_number)}“ bude přiděleno oficiální číslo. Aktivovat?`,
confirmText: "Aktivovat",
},
onAction: () => handleActivate(q),
},
];
case "active": {
const actions: StatusChipAction[] = [];
if (!q.order_id && hasPermission("orders.create")) {
actions.push({
key: "create-order",
label: "Vytvořit objednávku…",
onAction: () => {
setCustomerOrderNumber("");
setOrderAttachment(null);
setOrderModal({ show: true, quotation: q });
},
});
}
actions.push(invalidateChipAction(q));
return actions;
}
case "ordered":
return [invalidateChipAction(q)];
default:
// `invalidated` (and any unknown status): plain chip, no actions.
return [];
}
};
// Only show the full-page skeleton on the very first load; on subsequent
// refetches (filter/customer/tab/page change) keep the table visible (the
// Card dims via isFetching) so it doesn't flash.
@@ -572,14 +672,19 @@ export default function Offers() {
// The offer's own status is `active`/`ordered`/`invalidated`. When its
// linked order is completed, surface that as "Dokončená" (success) —
// matching the OfferDetail header chip and the completed row tint.
// Completed rows are read-only everywhere on this page, so the chip
// stays plain (no quick actions) in that derived state.
const completed =
q.status !== "invalidated" && q.order_status === "dokoncena";
return completed ? (
<StatusChip label="Dokončená" color="success" />
<StatusChipMenu label="Dokončená" color="success" />
) : (
<StatusChip
<StatusChipMenu
label={statusLabel(OFFER_STATUS, q.status)}
color={statusColor(OFFER_STATUS, q.status)}
actions={
hasPermission("offers.edit") ? statusQuickActions(q) : undefined
}
/>
);
},

View File

@@ -1,14 +1,7 @@
import { useState, useEffect, useMemo, useRef } from "react";
import DOMPurify from "dompurify";
import { useQuery } from "@tanstack/react-query";
import {
orderDetailOptions,
type OrderData,
type OrderItem,
type OrderSection,
type OrderInvoice,
type OrderProject,
} from "../lib/queries/orders";
import { orderDetailOptions, type OrderItem } from "../lib/queries/orders";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
@@ -123,13 +116,14 @@ export default function OrderDetail() {
useEffect(() => {
if (orderQuery.error) {
alert.error("Nepodařilo se načíst objednávku");
navigate("/orders");
navigate("/orders?tab=prijate");
}
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
blobTimeouts.forEach(clearTimeout);
};
}, []);
@@ -165,13 +159,13 @@ export default function OrderDetail() {
const statusMutation = useApiMutation<{ status: string }, unknown>({
url: () => `${API_BASE}/orders/${id}`,
method: () => "PUT",
invalidate: ["orders", "invoices"],
invalidate: ["orders", "offers", "projects", "invoices"],
});
const notesMutation = useApiMutation<{ notes: string }, unknown>({
url: () => `${API_BASE}/orders/${id}`,
method: () => "PUT",
invalidate: ["orders", "invoices"],
invalidate: ["orders", "offers", "projects", "invoices"],
});
const orderDeleteMutation = useApiMutation<
@@ -180,7 +174,7 @@ export default function OrderDetail() {
>({
url: () => `${API_BASE}/orders/${id}`,
method: () => "DELETE",
invalidate: ["orders", "invoices"],
invalidate: ["orders", "offers", "projects", "invoices"],
});
if (!hasPermission("orders.view")) return <Forbidden />;
@@ -283,7 +277,7 @@ export default function OrderDetail() {
try {
await orderDeleteMutation.mutateAsync({ delete_files: deleteFiles });
alert.success("Objednávka byla smazána");
navigate("/orders");
navigate("/orders?tab=prijate");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
@@ -298,6 +292,11 @@ export default function OrderDetail() {
if (!order) return null;
// From a terminal state the backend offers v_realizaci as a REOPEN — label
// it "Obnovit" (not "Zahájit realizaci") and note the no-cascade semantics.
const isReopen =
order.status === "dokoncena" || order.status === "stornovana";
const itemRows: ItemRow[] = (order.items ?? []).map((item, index) => ({
...item,
_index: index,
@@ -389,19 +388,42 @@ export default function OrderDetail() {
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{/* flexWrap: the title must drop below the Zpět button on phones
instead of overflowing the viewport (same as Offer/Invoice
detail headers). */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/orders"
to="/orders?tab=prijate"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět
</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4">
Objednávka {order.order_number}
Objednávka
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{order.order_number}
</Box>
</Typography>
<StatusChip
label={statusLabel(ORDER_STATUS, order.status)}
@@ -458,7 +480,9 @@ export default function OrderDetail() {
>
{statusChanging === status
? "Zpracovávám…"
: TRANSITION_LABELS[status] || status}
: isReopen && status === "v_realizaci"
? "Obnovit"
: TRANSITION_LABELS[status] || status}
</Button>
))}
{hasPermission("orders.delete") && (
@@ -765,9 +789,15 @@ export default function OrderDetail() {
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav objednávky"
message={`Opravdu chcete změnit stav objednávky "${order.order_number}" na "${statusLabel(ORDER_STATUS, statusConfirm.status)}"?${statusConfirm.status === "dokoncena" ? " Projekt bude automaticky dokončen." : ""}`}
message={
isReopen && statusConfirm.status === "v_realizaci"
? `Opravdu chcete obnovit objednávku "${order.order_number}"? Objednávka se vrátí do stavu "V realizaci". Propojený projekt zůstane beze změny.`
: `Opravdu chcete změnit stav objednávky "${order.order_number}" na "${statusLabel(ORDER_STATUS, statusConfirm.status)}"?${statusConfirm.status === "dokoncena" ? " Projekt bude automaticky dokončen." : ""}`
}
confirmText={
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
isReopen && statusConfirm.status === "v_realizaci"
? "Obnovit"
: TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
}
cancelText="Zrušit"
/>

View File

@@ -122,11 +122,11 @@ export default function Orders() {
startIcon={PlusIcon}
onClick={() => navigate("/orders/issued/new")}
>
Vytvořit objednávku
Nová vydaná objednávka
</Button>
) : (
<Button startIcon={PlusIcon} onClick={() => setCreateOpen(true)}>
Vytvořit objednávku
Nová přijatá objednávka
</Button>
)
) : undefined

View File

@@ -10,6 +10,10 @@ import {
getCell,
getCells,
type ModalMode as HookModalMode,
type PlanEntryBody,
type PlanEntryPatchBody,
type PlanOverrideBody,
type PlanOverridePatchBody,
} from "../hooks/usePlanWork";
import type { PlanCellModalMode } from "../components/PlanCellModal";
import PlanGrid from "../components/PlanGrid";
@@ -182,7 +186,13 @@ export default function PlanWork() {
if (bulkForm.user_ids.length === 0) return;
setBulkSubmitting(true);
try {
const data: any = await bulkCreate.mutateAsync({
// Counts can arrive either at the envelope root or nested under .data.
type BulkSummary = {
created_days?: number;
skipped_days?: number;
users?: number;
};
const data = (await bulkCreate.mutateAsync({
user_ids: bulkForm.user_ids.map(Number),
date_from: bulkForm.date_from,
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
@@ -190,12 +200,12 @@ export default function PlanWork() {
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
category: bulkForm.category,
note: bulkForm.note,
});
})) as BulkSummary & { data?: BulkSummary };
// apiCall returns the full envelope { success, data, message };
// the summary counts are under .data
const summary = data?.data ?? data;
const summary: BulkSummary = data?.data ?? data;
const skipped =
summary?.skipped_days > 0
(summary?.skipped_days ?? 0) > 0
? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).`
: "";
alert.success(
@@ -392,7 +402,7 @@ export default function PlanWork() {
// optimistic cache patch the hook already applied.
const saveEntry = useCallback(
async (body: any) => {
async (body: PlanEntryBody) => {
try {
await createEntry.mutateAsync(body);
// Pulse the first day of the new entry — for multi-day entries
@@ -409,7 +419,7 @@ export default function PlanWork() {
);
const updateEntryFn = useCallback(
async (id: number, body: any) => {
async (id: number, body: PlanEntryPatchBody) => {
// Find the user for this entry so we know which cell to pulse /
// which cache to roll back. The hook's optimistic patch already
// discovered this; we read it from the current grid.
@@ -473,7 +483,7 @@ export default function PlanWork() {
);
const saveOverride = useCallback(
async (body: any) => {
async (body: PlanOverrideBody) => {
try {
await createOverride.mutateAsync(body);
firePulse(body.user_id, body.shift_date);
@@ -488,7 +498,7 @@ export default function PlanWork() {
);
const updateOverrideFn = useCallback(
async (id: number, body: any) => {
async (id: number, body: PlanOverridePatchBody) => {
// Find the user/date for the pulse + rollback.
let userId: number | null = null;
let date: string | null = null;

View File

@@ -11,7 +11,6 @@ import MenuItem from "@mui/material/MenuItem";
import {
projectDetailOptions,
type ProjectData,
type ProjectNote,
} from "../lib/queries/projects";
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
@@ -32,22 +31,19 @@ import {
PageEnter,
headerActionsSx,
} from "../ui";
import {
PROJECT_STATUS,
ORDER_STATUS,
statusLabel,
statusColor,
} from "../lib/documentStatus";
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
aktivni: "Aktivní",
dokonceny: "Dokončený",
zruseny: "Zrušený",
};
const STATUS_COLORS: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
aktivni: "success",
dokonceny: "info",
zruseny: "default",
const TRANSITION_LABELS: Record<string, string> = {
dokonceny: "Dokončit projekt",
zruseny: "Zrušit projekt",
aktivni: "Obnovit projekt",
};
interface User {
@@ -57,7 +53,6 @@ interface User {
interface ProjectForm {
name: string;
status: string;
start_date: string;
end_date: string;
responsible_user_id: string;
@@ -86,11 +81,15 @@ export default function ProjectDetail() {
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ProjectForm>({
name: "",
status: "aktivni",
start_date: "",
end_date: "",
responsible_user_id: "",
});
const [statusChanging, setStatusChanging] = useState<string | null>(null);
const [statusConfirm, setStatusConfirm] = useState<{
show: boolean;
status: string | null;
}>({ show: false, status: null });
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -131,7 +130,6 @@ export default function ProjectDetail() {
if (project && !formInitialized.current) {
setForm({
name: project.name || "",
status: project.status || "aktivni",
start_date: (project.start_date || "").substring(0, 10),
end_date: (project.end_date || "").substring(0, 10),
responsible_user_id: project.responsible_user_id || "",
@@ -151,7 +149,6 @@ export default function ProjectDetail() {
const projectSaveMutation = useApiMutation<
{
name: string;
status: string;
start_date: string | null;
end_date: string | null;
responsible_user_id: string | null;
@@ -163,6 +160,14 @@ export default function ProjectDetail() {
invalidate: ["projects", "warehouse"],
});
// Status transitions (header buttons). A project transition may cascade to
// the linked order (and its documents), so invalidate broadly.
const statusMutation = useApiMutation<{ status: string }, unknown>({
url: () => `${API_BASE}/projects/${id}`,
method: () => "PUT",
invalidate: ["projects", "orders", "offers", "invoices", "warehouse"],
});
const projectDeleteMutation = useApiMutation<
{ delete_files: boolean },
unknown
@@ -199,7 +204,6 @@ export default function ProjectDetail() {
try {
await projectSaveMutation.mutateAsync({
name: form.name,
status: form.status,
start_date: form.start_date || null,
end_date: form.end_date || null,
responsible_user_id: form.responsible_user_id || null,
@@ -212,6 +216,51 @@ export default function ProjectDetail() {
}
};
const handleStatusChange = async () => {
if (!statusConfirm.status) return;
const newStatus = statusConfirm.status;
setStatusChanging(newStatus);
setStatusConfirm({ show: false, status: null });
try {
await statusMutation.mutateAsync({ status: newStatus });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setStatusChanging(null);
}
};
// Confirm message with the cascade note — the linked-order cascade only
// exists when the project actually has a linked order; reopening never
// cascades.
const statusConfirmMessage = (status: string | null): string => {
if (!status || !project) return "";
if (status === "aktivni") {
return (
'Projekt se vrátí do stavu "Aktivní".' +
(project.order_id ? " Propojená objednávka zůstane beze změny." : "")
);
}
const base = `Označit projekt "${project.project_number} ${project.name}" jako ${
status === "zruseny" ? "zrušený" : "dokončený"
}?`;
// The cascade only fires while the order is still open (prijata /
// v_realizaci) — a terminal order (e.g. after a project reopen) stays
// untouched, so don't promise a change that won't happen.
const orderCascades =
project.order_id &&
(project.order_status === "prijata" ||
project.order_status === "v_realizaci");
if (!orderCascades) return base;
return (
base +
(status === "zruseny"
? " Propojená objednávka bude automaticky stornována."
: " Propojená objednávka bude automaticky dokončena.")
);
};
const handleDelete = async () => {
setDeleting(true);
try {
@@ -298,7 +347,17 @@ export default function ProjectDetail() {
mb: 3,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{/* flexWrap: the title must drop below the Zpět button on phones
instead of being squeezed beside it (mirrors Offer/Invoice
detail headers). */}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Button
component={RouterLink}
to="/projects"
@@ -308,21 +367,49 @@ export default function ProjectDetail() {
>
Zpět
</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
flexWrap: "wrap",
}}
>
<Typography variant="h4">
Projekt {project.project_number}
Projekt
<Box
component="span"
sx={{ color: "text.secondary", ml: 1, fontWeight: 400 }}
>
{project.project_number}
</Box>
</Typography>
<StatusChip
label={STATUS_LABELS[project.status] || project.status}
color={STATUS_COLORS[project.status] || "default"}
label={statusLabel(PROJECT_STATUS, project.status)}
color={statusColor(PROJECT_STATUS, project.status)}
/>
</Box>
</Box>
{canEdit && (
<Box sx={headerActionsSx}>
<Button onClick={handleSave} disabled={saving}>
<Button
onClick={handleSave}
disabled={saving || statusChanging !== null}
>
{saving ? "Ukládání..." : "Uložit"}
</Button>
{(project.valid_transitions ?? []).map((status) => (
<Button
key={status}
color={status === "zruseny" ? "error" : "primary"}
onClick={() => setStatusConfirm({ show: true, status })}
disabled={saving || statusChanging !== null}
>
{statusChanging === status
? "Zpracovávám…"
: TRANSITION_LABELS[status] || status}
</Button>
))}
{!project.order_id && (
<Button
variant="outlined"
@@ -386,21 +473,10 @@ export default function ProjectDetail() {
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Stav">
<Select
value={form.status}
onChange={(v) => updateForm("status", v)}
disabled={!canEdit}
>
<MenuItem value="aktivni">Aktivní</MenuItem>
<MenuItem value="dokonceny">Dokončený</MenuItem>
<MenuItem value="zruseny">Zrušený</MenuItem>
</Select>
</Field>
<Field label="Datum zahájení">
<DateField
value={form.start_date}
@@ -534,30 +610,36 @@ export default function ProjectDetail() {
<Typography variant="caption" color="text.secondary">
Objednávka
</Typography>
<Typography variant="body2">
<Typography
variant="body2"
component="div"
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
{project.order_id ? (
<Box
component={RouterLink}
to={`/orders/${project.order_id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{project.order_number}
<>
<Box
component={RouterLink}
to={`/orders/${project.order_id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{project.order_number}
</Box>
{project.order_status && (
<Box
component="span"
sx={{ color: "text.secondary", ml: 1 }}
>
(
{STATUS_LABELS[project.order_status] ||
project.order_status}
)
</Box>
<StatusChip
label={statusLabel(ORDER_STATUS, project.order_status)}
color={statusColor(ORDER_STATUS, project.order_status)}
/>
)}
</Box>
</>
) : (
"—"
)}
@@ -588,6 +670,22 @@ export default function ProjectDetail() {
</Box>
</Card>
{/* Status change confirmation */}
<ConfirmDialog
isOpen={statusConfirm.show}
onClose={() => setStatusConfirm({ show: false, status: null })}
onConfirm={handleStatusChange}
title="Změnit stav projektu"
message={statusConfirmMessage(statusConfirm.status)}
confirmText={
TRANSITION_LABELS[statusConfirm.status || ""] || "Potvrdit"
}
confirmVariant={
statusConfirm.status === "zruseny" ? "danger" : "primary"
}
cancelText="Zrušit"
/>
<ConfirmDialog
isOpen={deleteConfirm}
onClose={() => {

View File

@@ -7,6 +7,9 @@ import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import StatusChipMenu, {
type StatusChipAction,
} from "../components/StatusChipMenu";
import { formatDate } from "../utils/formatters";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
@@ -30,7 +33,6 @@ import {
TextField,
Select,
DateField,
StatusChip,
CheckboxField,
LoadingState,
PageEnter,
@@ -38,29 +40,20 @@ import {
type TabDef,
type DataColumn,
} from "../ui";
import {
PROJECT_STATUS,
statusLabel,
statusColor,
statusOptions,
} from "../lib/documentStatus";
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
aktivni: "Aktivní",
dokonceny: "Dokončený",
zruseny: "Zrušený",
};
const STATUS_COLORS: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
aktivni: "success",
dokonceny: "info",
zruseny: "default",
};
// Status filter tabs (mirrors the offers page): "" = all.
const STATUS_TABS: TabDef[] = [
{ value: "", label: "Všechny" },
...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })),
];
const STATUS_TABS: TabDef[] = statusOptions(PROJECT_STATUS, {
value: "",
label: "Všechny",
});
interface Project {
id: number;
@@ -153,6 +146,17 @@ export default function Projects() {
},
});
// Quick status change from the list chip menu. A project transition may
// cascade to the linked order (and its documents), so invalidate broadly.
const statusMutation = useApiMutation<
{ id: number; status: string },
unknown
>({
url: ({ id }) => `${API_BASE}/projects/${id}`,
method: () => "PUT",
invalidate: ["projects", "orders", "offers", "invoices", "warehouse"],
});
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({
name: "",
@@ -268,6 +272,78 @@ export default function Projects() {
return <LoadingState />;
}
const canEditStatus = hasPermission("projects.edit");
// "Číslo Název" reference for confirm messages (name may be empty on
// legacy rows).
const projectRef = (p: Project) =>
p.name ? `${p.project_number} ${p.name}` : p.project_number;
const changeStatus = async (p: Project, status: string) => {
try {
await statusMutation.mutateAsync({ id: p.id, status });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
throw e; // rejection keeps the ConfirmDialog open (app convention)
}
};
// Quick actions per current status; unknown/legacy statuses get none
// (plain chip) — those are resolved on the detail page.
const statusActions = (p: Project): StatusChipAction[] => {
if (p.status === "aktivni") {
return [
{
key: "dokonceny",
label: "Dokončit",
confirm: {
title: "Dokončit projekt",
message:
`Označit projekt "${projectRef(p)}" jako dokončený?` +
(p.order_id
? " Propojená objednávka bude automaticky dokončena."
: ""),
confirmText: "Dokončit",
},
onAction: () => changeStatus(p, "dokonceny"),
},
{
key: "zruseny",
label: "Zrušit",
danger: true,
confirm: {
title: "Zrušit projekt",
message:
`Označit projekt "${projectRef(p)}" jako zrušený?` +
(p.order_id
? " Propojená objednávka bude automaticky stornována."
: ""),
confirmText: "Zrušit projekt",
},
onAction: () => changeStatus(p, "zruseny"),
},
];
}
if (p.status === "dokonceny" || p.status === "zruseny") {
return [
{
key: "aktivni",
label: "Obnovit",
confirm: {
title: "Obnovit projekt",
message:
'Projekt se vrátí do stavu "Aktivní".' +
(p.order_id ? " Propojená objednávka zůstane beze změny." : ""),
confirmText: "Obnovit",
},
onAction: () => changeStatus(p, "aktivni"),
},
];
}
return [];
};
const columns: DataColumn<Project>[] = [
{
key: "project_number",
@@ -315,9 +391,11 @@ export default function Projects() {
width: "10%",
sortKey: "status",
render: (p) => (
<StatusChip
label={STATUS_LABELS[p.status] || p.status}
color={STATUS_COLORS[p.status] || "default"}
<StatusChipMenu
label={statusLabel(PROJECT_STATUS, p.status)}
color={statusColor(PROJECT_STATUS, p.status)}
actions={statusActions(p)}
disabled={!canEditStatus}
/>
),
},
@@ -394,9 +472,10 @@ export default function Projects() {
const rowSx = (p: Project) => {
if (p.status === "dokonceny") {
return {
backgroundColor: "rgba(var(--mui-palette-info-mainChannel) / 0.12)",
backgroundColor: "rgba(var(--mui-palette-success-mainChannel) / 0.12)",
"&:hover": {
backgroundColor: "rgba(var(--mui-palette-info-mainChannel) / 0.18)",
backgroundColor:
"rgba(var(--mui-palette-success-mainChannel) / 0.18)",
},
};
}

View File

@@ -16,6 +16,7 @@ import {
} from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
companySettingsOptions,
@@ -248,6 +249,7 @@ export default function ReceivedInvoices({
const queryClient = useQueryClient();
const { sort, order, handleSort } = useTableSort("created_at");
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [editOpen, setEditOpen] = useState(false);
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null);
@@ -257,6 +259,10 @@ export default function ReceivedInvoices({
show: boolean;
invoice: ReceivedInvoice | null;
}>({ show: false, invoice: null });
const [paidConfirm, setPaidConfirm] = useState<{
show: boolean;
invoice: ReceivedInvoice | null;
}>({ show: false, invoice: null });
const hasLoadedOnce = useRef(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
@@ -286,7 +292,7 @@ export default function ReceivedInvoices({
receivedInvoiceListOptions({
month: statsMonth,
year: statsYear,
search,
search: debouncedSearch,
sort,
order,
page,
@@ -306,7 +312,7 @@ export default function ReceivedInvoices({
receivedInvoiceTotalsOptions({
month: statsMonth,
year: statsYear,
search,
search: debouncedSearch,
}),
);
@@ -362,8 +368,9 @@ export default function ReceivedInvoices({
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
blobTimeouts.forEach(clearTimeout);
};
}, []);
@@ -579,10 +586,14 @@ export default function ReceivedInvoices({
}
};
const toggleStatus = async (inv: ReceivedInvoice) => {
if (inv.status === "paid") return;
const handleMarkPaid = async () => {
if (!paidConfirm.invoice) return;
try {
await toggleStatusMutation.mutateAsync({ id: inv.id, status: "paid" });
await toggleStatusMutation.mutateAsync({
id: paidConfirm.invoice.id,
status: "paid",
});
setPaidConfirm({ show: false, invoice: null });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Nepodařilo se změnit stav");
}
@@ -674,7 +685,8 @@ export default function ReceivedInvoices({
<StatusChip
label={statusLabel(RECEIVED_INVOICE_STATUS, inv.status)}
color={statusColor(RECEIVED_INVOICE_STATUS, inv.status)}
onClick={() => toggleStatus(inv)}
title="Označit jako uhrazenou"
onClick={() => setPaidConfirm({ show: true, invoice: inv })}
/>
),
},
@@ -854,7 +866,7 @@ export default function ReceivedInvoices({
sortDir={order}
onSort={handleSort}
empty={
search ? (
debouncedSearch ? (
<EmptyState title="Žádné faktury neodpovídají hledání." />
) : (
<EmptyState
@@ -1149,11 +1161,9 @@ export default function ReceivedInvoices({
? "Zavřít"
: "Uložit"
}
cancelText={
editInvoice && editInvoice._originalStatus === "paid"
? "Zavřít"
: "Zrušit"
}
// Read-only (paid) detail has only a close action — a single "Zavřít"
// button, not a redundant cancel button beside it.
hideCancel={editInvoice?._originalStatus === "paid"}
loading={saving}
>
{editInvoice &&
@@ -1330,6 +1340,17 @@ export default function ReceivedInvoices({
confirmVariant="danger"
loading={deleting}
/>
<ConfirmDialog
isOpen={paidConfirm.show}
onClose={() => setPaidConfirm({ show: false, invoice: null })}
onConfirm={handleMarkPaid}
title="Označit fakturu jako uhrazenou"
message={`Označit fakturu "${paidConfirm.invoice?.invoice_number || paidConfirm.invoice?.supplier_name || ""}" jako uhrazenou?`}
confirmText="Označit jako uhrazenou"
cancelText="Zrušit"
loading={toggleStatusMutation.isPending}
/>
</>
);
}

View File

@@ -6,6 +6,9 @@ import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import StatusChipMenu, {
type StatusChipAction,
} from "../components/StatusChipMenu";
import apiFetch from "../utils/api";
import {
formatCurrency,
@@ -32,7 +35,6 @@ import {
Field,
TextField,
Select,
StatusChip,
CheckboxField,
FileUpload,
FilterBar,
@@ -180,6 +182,17 @@ export default function OrdersReceived({
},
});
// Quick status change from the table chip (StatusChipMenu). The `id` rides
// along in the input only to build the URL; UpdateOrderSchema strips it.
const statusMutation = useApiMutation<
{ id: number; status: string },
unknown
>({
url: ({ id }) => `${API_BASE}/orders/${id}`,
method: () => "PUT",
invalidate: ["orders", "offers", "projects", "invoices"],
});
const [createForm, setCreateForm] = useState({
customer_id: "",
customer_order_number: "",
@@ -352,6 +365,79 @@ export default function OrdersReceived({
return <LoadingState />;
}
// Quick actions for the status chip menu, mirroring the order status
// machine (VALID_TRANSITIONS incl. the reopen edges). Rejections propagate
// so the ConfirmDialog stays open per app convention; we toast here.
const statusActions = (o: Order): StatusChipAction[] => {
const changeStatus = (status: string) => async () => {
try {
await statusMutation.mutateAsync({ id: o.id, status });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
throw e;
}
};
const stornovat: StatusChipAction = {
key: "stornovana",
label: "Stornovat",
danger: true,
confirm: {
title: "Stornovat objednávku",
message: `Opravdu chcete stornovat objednávku „${o.order_number}“? Propojený projekt bude automaticky zrušen.`,
confirmText: "Stornovat",
},
onAction: changeStatus("stornovana"),
};
switch (o.status) {
case "prijata":
return [
{
key: "v_realizaci",
label: "Zahájit realizaci",
confirm: {
title: "Zahájit realizaci",
message: `Opravdu chcete zahájit realizaci objednávky „${o.order_number}“?`,
confirmText: "Zahájit realizaci",
},
onAction: changeStatus("v_realizaci"),
},
stornovat,
];
case "v_realizaci":
return [
{
key: "dokoncena",
label: "Dokončit",
confirm: {
title: "Dokončit objednávku",
message: `Opravdu chcete dokončit objednávku „${o.order_number}“? Propojený projekt bude automaticky dokončen.`,
confirmText: "Dokončit",
},
onAction: changeStatus("dokoncena"),
},
stornovat,
];
case "dokoncena":
case "stornovana":
// Reopen — deliberately no cascade to the linked project.
return [
{
key: "v_realizaci",
label: "Obnovit",
confirm: {
title: "Obnovit objednávku",
message: `Opravdu chcete obnovit objednávku „${o.order_number}“? Objednávka se vrátí do stavu "V realizaci". Propojený projekt zůstane beze změny.`,
confirmText: "Obnovit",
},
onAction: changeStatus("v_realizaci"),
},
];
default:
return [];
}
};
const columns: DataColumn<Order>[] = [
{
key: "order_number",
@@ -403,9 +489,11 @@ export default function OrdersReceived({
width: "13%",
sortKey: "status",
render: (o) => (
<StatusChip
<StatusChipMenu
label={statusLabel(ORDER_STATUS, o.status)}
color={statusColor(ORDER_STATUS, o.status)}
actions={statusActions(o)}
disabled={!hasPermission("orders.edit")}
/>
),
},
@@ -589,7 +677,7 @@ export default function OrdersReceived({
title="Smazat objednávku"
message={
deleteConfirm.order
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}? Bude smazán i přidružený projekt. Tato akce je nevratná.`
: ""
}
confirmText="Smazat"

View File

@@ -715,7 +715,25 @@ export default function Settings() {
</Box>
);
}
const si = systemInfo as Record<string, any>;
type NasInfo = { configured?: boolean; path?: string | null };
type SystemInfo = {
app_version?: string;
node_version?: string;
platform?: string;
uptime?: string;
environment?: string;
timezone?: string;
memory?: {
rss?: string;
heap_used?: string;
heap_total?: string;
system_free?: string;
system_total?: string;
};
database?: { status?: string; migrations_applied?: number | string };
nas?: Record<string, NasInfo | undefined>;
};
const si = systemInfo as SystemInfo;
const Row = ({
label,
children,
@@ -800,7 +818,7 @@ export default function Settings() {
["Faktury", si.nas?.invoices],
["Objednávky", si.nas?.orders],
["Nabídky", si.nas?.offers],
] as [string, Record<string, any>][]
] as [string, NasInfo | undefined][]
).map(([label, info]) => (
<Row key={label} label={label}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>

View File

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

View File

@@ -7,10 +7,11 @@ import IconButton from "@mui/material/IconButton";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import Forbidden from "../components/Forbidden";
import { useApiMutation } from "../lib/queries/mutations";
import { useApiMutation, apiErrorMessage } from "../lib/queries/mutations";
import {
userListOptions,
roleListOptions,
USER_INVALIDATE,
type User,
} from "../lib/queries/users";
import {
@@ -114,15 +115,6 @@ export default function Users() {
is_active: true,
});
const USER_INVALIDATE = [
"users",
"trips",
"attendance",
"leave-requests",
"leave",
"projects",
];
const saveUser = useApiMutation<UserPayload, void>({
url: () =>
editingUser ? `${API_BASE}/users/${editingUser.id}` : `${API_BASE}/users`,
@@ -168,6 +160,9 @@ export default function Users() {
input.is_active ? "Uživatel byl aktivován" : "Uživatel byl deaktivován",
);
},
onError: (err) => {
alert.error(apiErrorMessage(err, "Nepodařilo se změnit stav uživatele"));
},
});
if (!hasPermission("users.view")) return <Forbidden />;
@@ -290,6 +285,9 @@ export default function Users() {
label={u.is_active ? "Aktivní" : "Neaktivní"}
color={u.is_active ? "success" : "default"}
onClick={u.id === currentUser?.id ? undefined : () => toggleActive(u)}
title={
u.id === currentUser?.id ? undefined : "Kliknutím přepnete stav"
}
/>
),
},

View File

@@ -8,7 +8,7 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { formatKm } from "../utils/formatters";
import { vehicleListOptions } from "../lib/queries/vehicles";
import { useApiMutation } from "../lib/queries/mutations";
import { useApiMutation, apiErrorMessage } from "../lib/queries/mutations";
import {
Button,
Card,
@@ -157,6 +157,9 @@ export default function Vehicles() {
: "Vozidlo bylo deaktivováno",
);
},
onError: (err) => {
alert.error(apiErrorMessage(err, "Nepodařilo se změnit stav vozidla"));
},
});
if (!hasPermission("vehicles.manage")) return <Forbidden />;
@@ -265,6 +268,7 @@ export default function Vehicles() {
label={v.is_active ? "Aktivní" : "Neaktivní"}
color={v.is_active ? "success" : "default"}
onClick={() => toggleActive(v)}
title="Kliknutím přepnete stav"
/>
),
},

View File

@@ -23,7 +23,7 @@ interface InventoryItem {
}
function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, "");
const cleaned = raw.replace(/[eE+-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useId } from "react";
import { useState, useEffect, useRef, useId, useCallback } from "react";
import {
useParams,
useNavigate,
@@ -65,7 +65,7 @@ interface BatchesResponse {
}
function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, "");
const cleaned = raw.replace(/[eE+-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
@@ -174,7 +174,10 @@ export default function WarehouseIssueForm() {
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const nextKey = useCallback(
() => `${baseId}-item-${counterRef.current++}`,
[baseId],
);
// Pre-fill data from reservation ("Create výdej" button)
const prefill = location.state as {
@@ -262,7 +265,7 @@ export default function WarehouseIssueForm() {
}
formInitialized.current = true;
}
}, [isEdit, issue]);
}, [isEdit, issue, nextKey]);
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,

View File

@@ -266,6 +266,7 @@ export default function WarehouseLocations() {
label={l.is_active ? "Aktivní" : "Neaktivní"}
color={l.is_active ? "success" : "default"}
onClick={() => toggleActive(l)}
title="Kliknutím přepnete stav"
/>
),
},

View File

@@ -50,7 +50,7 @@ interface MovementItem {
}
function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, "");
const cleaned = raw.replace(/[eE+-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
@@ -126,7 +126,10 @@ export default function WarehouseReceiptForm() {
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const nextKey = useCallback(
() => `${baseId}-item-${counterRef.current++}`,
[baseId],
);
const [form, setForm] = useState<ReceiptForm>({
supplier_id: null,
@@ -198,7 +201,7 @@ export default function WarehouseReceiptForm() {
}
formInitialized.current = true;
}
}, [isEdit, receipt]);
}, [isEdit, receipt, nextKey]);
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,

View File

@@ -367,6 +367,7 @@ export default function WarehouseSuppliers() {
label={s.is_active ? "Aktivní" : "Neaktivní"}
color={s.is_active ? "success" : "default"}
onClick={() => toggleActive(s)}
title="Kliknutím přepnete stav"
/>
),
},

View File

@@ -1,6 +1,9 @@
import { describe, it, expect } from "vitest";
import { theme } from "./theme";
/** Loose shape for poking into MUI styleOverrides objects in assertions. */
type StyleObj = Record<string, unknown>;
/** Minimal palette shape used by the colorSchemes assertions below. */
interface SchemesPalette {
primary: { main: string };
@@ -44,18 +47,20 @@ describe("MUI theme", () => {
});
it("makes buttons pill-shaped and cards 16px by default", () => {
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
expect(btn?.borderRadius).toBe(999);
const card = theme.components?.MuiCard?.styleOverrides?.root as any;
expect(card?.borderRadius).toBe(16);
const btn = theme.components?.MuiButton?.styleOverrides?.root as StyleObj;
expect(btn.borderRadius).toBe(999);
const card = theme.components?.MuiCard?.styleOverrides?.root as StyleObj;
expect(card.borderRadius).toBe(16);
});
it("adds 200-300ms interaction motion (press + focus ring)", () => {
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
expect(btn?.transition).toContain("transform");
expect(btn?.["&:active"]?.transform).toBe("scale(0.97)");
const btn = theme.components?.MuiButton?.styleOverrides?.root as StyleObj;
expect(btn.transition).toContain("transform");
const active = btn["&:active"] as StyleObj;
expect(active.transform).toBe("scale(0.97)");
const input = theme.components?.MuiOutlinedInput?.styleOverrides
?.root as any;
expect(input?.["&.Mui-focused"]?.boxShadow).toContain("rgba");
?.root as StyleObj;
const focused = input["&.Mui-focused"] as StyleObj;
expect(focused.boxShadow).toContain("rgba");
});
});

View File

@@ -1,4 +1,10 @@
import { createTheme, type Theme } from "@mui/material/styles";
import {
createTheme,
type Theme,
type PaletteColor,
type PaletteColorChannel,
} from "@mui/material/styles";
import { csCZ } from "@mui/material/locale";
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
const FONT_HEADING = "'Urbanist', sans-serif";
@@ -20,218 +26,265 @@ export const FILLED_DARK_BG = {
info: { bg: "#1d4ed8", hover: "#1e40af" },
} as const;
export const theme = createTheme({
cssVariables: {
colorSchemeSelector: "[data-theme='%s']",
},
colorSchemes: {
light: {
palette: {
mode: "light",
primary: { main: "#c73030" },
success: { main: "#15803d", contrastText: "#fff" },
warning: { main: "#b45309", contrastText: "#fff" },
error: { main: "#b91c1c", contrastText: "#fff" },
info: { main: "#1d4ed8", contrastText: "#fff" },
background: { default: "#f4f3f1", paper: "#ffffff" },
text: { primary: "#1a1a1a", secondary: "#555555" },
divider: "rgba(0,0,0,0.1)",
},
// The theme composes MUI's Czech component localization (csCZ) as a second
// createTheme argument (the supported deep-merge form). It only injects
// component defaultProps — Czech built-in strings for Autocomplete
// ("Žádné možnosti"), TablePagination, Alert, Pagination, … — and does not
// touch the palette / cssVariables config.
export const theme = createTheme(
{
cssVariables: {
colorSchemeSelector: "[data-theme='%s']",
},
dark: {
palette: {
mode: "dark",
primary: { main: "#d63031" },
success: { main: "#22c55e", contrastText: "#1a1a1a" },
warning: { main: "#f59e0b", contrastText: "#1a1a1a" },
error: { main: "#ef4444", contrastText: "#1a1a1a" },
info: { main: "#3b82f6", contrastText: "#1a1a1a" },
background: { default: "#0f0f0f", paper: "#1a1a1a" },
text: { primary: "#ffffff", secondary: "#a0a0a0" },
divider: "rgba(255,255,255,0.08)",
},
},
},
shape: { borderRadius: 10 },
typography: {
fontFamily: FONT_BODY,
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
h4: {
fontFamily: FONT_HEADING,
fontWeight: 700,
// Page/detail headlines: MUI's default 2.125rem overflows phone
// viewports (long document titles + number). Scale down on xs only.
"@media (max-width:600px)": { fontSize: "1.5rem" },
},
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
button: { textTransform: "none", fontWeight: 600 },
},
components: {
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: {
borderRadius: 999,
paddingInline: "0.95rem",
// Snappy press (150ms) + smooth color/shadow (250ms).
transition: [
`background-color 250ms ${EASE}`,
`border-color 250ms ${EASE}`,
`color 200ms ${EASE}`,
`box-shadow 250ms ${EASE}`,
`transform 150ms ${EASE}`,
`filter 200ms ${EASE}`,
].join(", "),
"&:active": { transform: "scale(0.97)" },
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
},
containedPrimary: {
backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)",
boxShadow: "0 5px 14px rgba(214,48,49,0.32)",
// The gradient can't transition, so animate lift + glow instead.
"&:hover": {
transform: "translateY(-1px)",
filter: "brightness(1.04)",
boxShadow: "0 8px 20px rgba(214,48,49,0.42)",
colorSchemes: {
light: {
palette: {
mode: "light",
primary: { main: "#c73030" },
success: { main: "#15803d", contrastText: "#fff" },
warning: { main: "#b45309", contrastText: "#fff" },
error: { main: "#b91c1c", contrastText: "#fff" },
info: { main: "#1d4ed8", contrastText: "#fff" },
// Sidebar section hues (full objects so cssVariables emits *Channel
// tokens — the nav tiles use channel-alpha washes).
teal: {
main: "#0e8a7c",
light: "#3aa99c",
dark: "#0a675d",
contrastText: "#fff",
},
"&:active": { transform: "translateY(0) scale(0.97)" },
// Honor reduced-motion like MuiButton.root / MuiOutlinedInput: drop
// the lift/press transform (glow + brightness stay, they don't move).
"@media (prefers-reduced-motion: reduce)": {
"&:hover": { transform: "none" },
"&:active": { transform: "none" },
violet: {
main: "#6a4cb4",
light: "#8a6fd0",
dark: "#54399a",
contrastText: "#fff",
},
slate: {
main: "#5c6470",
light: "#7d8694",
dark: "#454c56",
contrastText: "#fff",
},
background: { default: "#f4f3f1", paper: "#ffffff" },
text: { primary: "#1a1a1a", secondary: "#555555" },
divider: "rgba(0,0,0,0.1)",
},
// Filled colored buttons: WHITE text in BOTH themes (was per-scheme
// contrastText → near-black text on colored fills in dark mode, which
// also clashed with the always-white primary — "black text on one red
// button, white on another"). In dark mode the fill drops to a darker
// shade so white stays legible.
containedError: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.error.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.error.hover },
},
}),
}),
containedSuccess: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.success.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.success.hover },
},
}),
}),
containedWarning: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.warning.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.warning.hover },
},
}),
}),
containedInfo: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.info.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.info.hover },
},
}),
}),
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 16,
boxShadow:
"0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)",
transition: `box-shadow 250ms ${EASE}, transform 250ms ${EASE}`,
dark: {
palette: {
mode: "dark",
primary: { main: "#d63031" },
success: { main: "#22c55e", contrastText: "#1a1a1a" },
warning: { main: "#f59e0b", contrastText: "#1a1a1a" },
error: { main: "#ef4444", contrastText: "#1a1a1a" },
info: { main: "#3b82f6", contrastText: "#1a1a1a" },
// Sidebar section hues — brighter mains so the glyphs read on dark.
teal: {
main: "#2dd4bf",
light: "#5eead4",
dark: "#14b8a6",
contrastText: "#1a1a1a",
},
violet: {
main: "#a78bfa",
light: "#c4b5fd",
dark: "#8b5cf6",
contrastText: "#1a1a1a",
},
slate: {
main: "#94a3b8",
light: "#cbd5e1",
dark: "#64748b",
contrastText: "#1a1a1a",
},
background: { default: "#0f0f0f", paper: "#1a1a1a" },
text: { primary: "#ffffff", secondary: "#a0a0a0" },
divider: "rgba(255,255,255,0.08)",
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 999,
fontWeight: 700,
transition: `background-color 200ms ${EASE}, box-shadow 200ms ${EASE}`,
},
// Filled colored chips follow the same rule as filled buttons: white
// label in both themes, darker fill in dark mode so white stays legible.
// (Chip has no per-color `filledX` key, so target the color class and
// scope to the filled variant.)
colorError: ({ theme }) => ({
"&.MuiChip-filled": {
shape: { borderRadius: 10 },
typography: {
fontFamily: FONT_BODY,
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
h4: {
fontFamily: FONT_HEADING,
fontWeight: 700,
// Page/detail headlines: MUI's default 2.125rem overflows phone
// viewports (long document titles + number). Scale down on xs only.
"@media (max-width:600px)": { fontSize: "1.5rem" },
},
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
button: { textTransform: "none", fontWeight: 600 },
},
components: {
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: {
borderRadius: 999,
paddingInline: "0.95rem",
// Snappy press (150ms) + smooth color/shadow (250ms).
transition: [
`background-color 250ms ${EASE}`,
`border-color 250ms ${EASE}`,
`color 200ms ${EASE}`,
`box-shadow 250ms ${EASE}`,
`transform 150ms ${EASE}`,
`filter 200ms ${EASE}`,
].join(", "),
"&:active": { transform: "scale(0.97)" },
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
},
containedPrimary: {
backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)",
boxShadow: "0 5px 14px rgba(214,48,49,0.32)",
// The gradient can't transition, so animate lift + glow instead.
"&:hover": {
transform: "translateY(-1px)",
filter: "brightness(1.04)",
boxShadow: "0 8px 20px rgba(214,48,49,0.42)",
},
"&:active": { transform: "translateY(0) scale(0.97)" },
// Honor reduced-motion like MuiButton.root / MuiOutlinedInput: drop
// the lift/press transform (glow + brightness stay, they don't move).
"@media (prefers-reduced-motion: reduce)": {
"&:hover": { transform: "none" },
"&:active": { transform: "none" },
},
},
// Filled colored buttons: WHITE text in BOTH themes (was per-scheme
// contrastText → near-black text on colored fills in dark mode, which
// also clashed with the always-white primary — "black text on one red
// button, white on another"). In dark mode the fill drops to a darker
// shade so white stays legible.
containedError: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.error.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.error.hover,
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.error.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.error.hover },
},
}),
},
}),
colorSuccess: ({ theme }) => ({
"&.MuiChip-filled": {
}),
containedSuccess: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.success.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.success.hover,
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.success.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.success.hover },
},
}),
},
}),
colorWarning: ({ theme }) => ({
"&.MuiChip-filled": {
}),
containedWarning: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.warning.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.warning.hover,
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.warning.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.warning.hover },
},
}),
},
}),
colorInfo: ({ theme }) => ({
"&.MuiChip-filled": {
}),
containedInfo: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.info.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.info.hover,
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.info.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.info.hover },
},
}),
},
}),
},
},
MuiOutlinedInput: {
styleOverrides: {
root: {
// Soft brand focus ring fades in over 200ms.
transition: `box-shadow 200ms ${EASE}`,
"&.Mui-focused": {
boxShadow: "0 0 0 3px rgba(199, 48, 48, 0.12)",
},
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
}),
},
notchedOutline: {
transition: `border-color 200ms ${EASE}`,
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 16,
boxShadow:
"0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)",
transition: `box-shadow 250ms ${EASE}, transform 250ms ${EASE}`,
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 999,
fontWeight: 700,
transition: `background-color 200ms ${EASE}, box-shadow 200ms ${EASE}`,
},
// Filled colored chips follow the same rule as filled buttons: white
// label in both themes, darker fill in dark mode so white stays legible.
// (Chip has no per-color `filledX` key, so target the color class and
// scope to the filled variant.)
colorError: ({ theme }) => ({
"&.MuiChip-filled": {
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.error.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.error.hover,
},
}),
},
}),
colorSuccess: ({ theme }) => ({
"&.MuiChip-filled": {
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.success.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.success.hover,
},
}),
},
}),
colorWarning: ({ theme }) => ({
"&.MuiChip-filled": {
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.warning.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.warning.hover,
},
}),
},
}),
colorInfo: ({ theme }) => ({
"&.MuiChip-filled": {
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.info.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.info.hover,
},
}),
},
}),
},
},
MuiOutlinedInput: {
styleOverrides: {
root: {
// Soft brand focus ring fades in over 200ms.
transition: `box-shadow 200ms ${EASE}`,
"&.Mui-focused": {
boxShadow: "0 0 0 3px rgba(199, 48, 48, 0.12)",
},
"@media (prefers-reduced-motion: reduce)": { transition: "none" },
},
notchedOutline: {
transition: `border-color 200ms ${EASE}`,
},
},
},
},
},
});
csCZ,
);
export const fonts = {
body: FONT_BODY,
@@ -239,6 +292,24 @@ export const fonts = {
mono: FONT_MONO,
};
// Custom sidebar-section palette entries (teal/violet/slate above). The
// channel intersection matters: theme.vars.palette.<hue>.mainChannel is how
// the nav tiles build their alpha washes.
type NavPaletteColor = PaletteColor & PaletteColorChannel;
declare module "@mui/material/styles" {
interface Palette {
teal: NavPaletteColor;
violet: NavPaletteColor;
slate: NavPaletteColor;
}
interface PaletteOptions {
teal?: PaletteOptions["primary"];
violet?: PaletteOptions["primary"];
slate?: PaletteOptions["primary"];
}
}
export type IconBadgeColor =
| "default"
| "primary"

View File

@@ -11,6 +11,7 @@ import { setLogoutAlert } from "../utils/api";
import SidebarNav from "./SidebarNav";
import LoadingState from "./LoadingState";
import ShortcutsHelp from "../components/ShortcutsHelp";
import TitleSync from "../components/TitleSync";
const DRAWER_WIDTH = 248;
@@ -98,6 +99,7 @@ export default function AppShell() {
return (
<ScopedCssBaseline>
<TitleSync />
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={
@@ -110,6 +112,38 @@ export default function AppShell() {
ease: [0.4, 0, 0.2, 1],
}}
>
{/* Skip link: first focusable element; visually hidden (clipped)
until keyboard focus reveals it above the drawer. */}
<Box
component="a"
href="#main-content"
sx={(theme) => ({
position: "absolute",
top: 8,
left: 8,
zIndex: theme.zIndex.drawer + 2,
width: "1px",
height: "1px",
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
px: 2,
py: 1,
borderRadius: 1,
bgcolor: theme.vars!.palette.primary.main,
color: theme.vars!.palette.primary.contrastText,
textDecoration: "none",
fontWeight: 600,
"&:focus": {
width: "auto",
height: "auto",
overflow: "visible",
clipPath: "none",
},
})}
>
Přeskočit na obsah
</Box>
<Box
sx={{
display: "flex",
@@ -209,10 +243,15 @@ export default function AppShell() {
</Box>
<Box
component="main"
id="main-content"
tabIndex={-1}
sx={{
flex: 1,
px: immersiveOnMobile ? { xs: 0, md: 3 } : { xs: 2, md: 3 },
pb: immersiveOnMobile ? { xs: 0, md: 4 } : 4,
// Skip-link target: focus lands here programmatically — the
// region-wide focus ring would be noise, not a signal.
"&:focus": { outline: "none" },
...(immersiveOnMobile && {
minHeight: 0,
overflow: { xs: "hidden", md: "visible" },

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