78 KiB
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:
-
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) -
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)
-
Fix list-page failure and loading states. No list page reads
isError, and there is no globalQueryCache.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) -
Make the app usable on a phone. The shared
Tabsuses MUI's non-scrollingvariant="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) -
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.
OrderDetailstatus/notes/delete (OrderDetail.tsx:162,168,177) invalidate only[orders,invoices]while theReceivedOrderslist delete (ReceivedOrders.tsx:175) uses[orders,offers,projects,invoices].InvoiceDetailsave/status/delete (InvoiceDetail.tsx:1054,1067,1073) use[invoices,orders]while theInvoiceslist addsprojects(Invoices.tsx:328,352). Why it matters: marking an invoice paid or changing an order status from the detail page leavesProjectDetail'sorder_statuslabel (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/Offersare 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 usesUSER_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: exportUSER_INVALIDATEfrom a shared module and reuse it inDashProfile. -
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) addusers,dashboard. Why: the genuinely-stale gap is[users]balance/history when an approved future request is cancelled (the[dashboard]part self-heals viarefetchOnMount). Fix: hoist a sharedLEAVE_INVALIDATEset 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: addrefetchOnMount:'always'(andstaleTime:0) to the AuditLog page's inlineuseQuery(AuditLog.tsx:135), mirroringdashboardOptions— don't sprinkle[audit-log]into dozens of arrays. (Verification note:auditLogOptionsinauditLog.tsis 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:362vs 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 structuredsklad_supplierstable (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 unionsklad_suppliersnames 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=paidwith 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 aConfirmDialog, but this one doesn't, and the chip only differs from a static one by a pointer cursor. Fix: route the chip click through aConfirmDialog("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
StatusChipis secretly anonClicktoggle with no tooltip/label (Vehicles.tsx:263,Users.tsx:288). Deactivation fires on one click while the less-destructive delete gets a fullConfirmDialog. The Vehicles/Users toggle mutations declare onlyonSuccess, noonErrorand 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; addonErrortoasts. (WarehouseLocations/WarehouseSuppliersalready toast via try/catch — copy them.) -
ConfirmDialogin-flightloadingis applied inconsistently — some dialogs allow duplicate submits.IssuedOrderDetailpassesloadingto both status and delete dialogs (:958);OrderDetail/InvoiceDetailpass it to delete but not status (OrderDetail.tsx:780-790,InvoiceDetail.tsx:2453-2463); Vehicles delete (:417-429),AttendanceAdmindelete (rawapiFetch, noisPending,useAttendanceAdmin.ts:1293-1320) and CompanySettings bank delete omit it entirely → a second click fires a duplicate request. Fix: passloading={mutation.isPending}to everyConfirmDialog; convertAttendanceAdmin's raw delete touseApiMutation; standardize onIssuedOrderDetail'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-356commits stock movements on one click;:508-517requires 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 aConfirmDialogbefore "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" viacancelText(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-70defaults 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 onlyOfferDetail/IssuedOrderDetailuse it. ~30 pages doalert.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 withapiErrorMessage(e, "<Czech fallback>")across the ~32 files. Do not add a blanketuseApiMutationdefaultonError— pages callmutateAsyncinside 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
useUnsavedChangesGuardis used only byOfferDetail/IssuedOrderDetail;OrderDetail(:130-143) andInvoiceDetail(:942-958) re-implementisDirty+beforeunloadinline;ProjectDetail,CompanySettings,WarehouseIssueForm/ReceiptForm/InventoryFormandAttendanceCreateregister 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) whereuseBlocker/usePromptaren't available — this needs a migration tocreateBrowserRouter/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
WarehouseItemDetailhas an edit/cancel toggle that restores the snapshot (:247-269), butProjectDetail(only Uložit+Smazat,:343) andCompanySettings(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/IssuedOrderDetailacquire a server lock, heartbeat, renderLockBannerand force read-only when another user holds it;InvoiceDetailimports none of it (:22-88), so two users can clobber each other's line items (last-save-wins). Same gap for received-order notes onOrderDetail. Fix (structural): the client pieces exist (useDocumentLock+LockBanner) but the server trio does not — needs a migration addinglocked_by/locked_atto invoices (and orders), a lock/heartbeat/unlock route trio +locked_byenrichment in the detail endpoint (mirrorissued-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 inlineend_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.
Modalrenders children inDialogContentwith anonClickbutton (defaulttype=button) and no surrounding<form>(Modal.tsx:84,92), so Enter never submits a modal.OfferDetaileven hand-rolled anonKeyDownEnter workaround (:1059-1061). Fix: wrapDialogContent+DialogActionsin<form onSubmit>, make the primary actiontype=submit, mark secondary/in-content buttonstype=button, and remove the workaround. -
Failed validation doesn't move focus to the first invalid field nor announce to AT. Handlers just
setErrorsand return (Vehicles.tsx:192-197); theFielderror is a plain caption wired viaaria-describedby/aria-invalid, notaria-live/role=alert(Field.tsx:81-89). In a long modal the error can be off-screen. Fix: aftersetErrors, focus+scrollIntoViewthe first errored field and render the inline error withrole=alert; centralize inField.tsx/Modal.tsx. -
Required-asterisk marking is inconsistent with what's validated; DateField/TimeField can't show an error border.
Fieldpaints the asterisk only whenrequiredis passed (Field.tsx:74), but it's applied unevenly:WarehouseIssueFormvalidates Projekt-required with no asterisk (:474vs:328-329);ProjectDetailvalidates Název-required with no asterisk (:377vs:192);DateField/TimeFieldtake noerrorprop so a failed required date only reddens helper text, not the border. Fix: makerequiredthe single source of truth (drives asterisk + ideally validation); thread anerrorprop throughDateField/TimeField. -
Save-button feedback differs (spinner vs text swap); dual-save warehouse forms don't show which action is running. Document pages render a
CircularProgressand tracksavingActionso only the clicked button spins (OfferDetail.tsx:764); most other forms just swap to text (ProjectDetail.tsx:346). OnWarehouseIssueFormboth 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), butProjectDetailrenders 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 editableproject_notessystem lives only on the detail page. Fix: drop the Poznámka field from the create modal, or route the create-time note throughcreateProjectNote.
4.6 Loading, empty & error states
-
Failed list fetches silently render the empty state.
usePaginatedQueryexposesisError/error(:47-48) but no list page reads them, andqueryClient.tshas noQueryCache.onError. So a 500/network drop falls through to theDataTableempty slot — Projects shows "Zatím nejsou žádné projekty" (:480). Detail pages handle errors loudly;WarehouseReports.tsx:350shows an inline<Alert severity=error>. Fix: add aQueryCache.onErrortoast and/or render an inline error distinct from empty whenisError, with retry wired torefetch. -
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-rollBox(textAlign,py:6)+Typography+Button(Projects.tsx:471/480,Vehicles.tsx:323,Users.tsx:350), reinventing theactionprop. 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
isFetchingopacity (Offers.tsx:815).TripsHistory/TripsAdminkeep stale data with NO feedback (:304-306/:810-812).AttendanceHistoryuses plainuseQuerywith noplaceholderData, so a month change flipsisPendingand swaps the whole table for a centered spinner (:523,646). Fix: standardize on theisFetchingcard-dim; give Trips screens the dim andAttendanceHistorykeepPreviousData. -
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 likeshowListSkeleton(ReceivedInvoices.tsx:357). Fix: renderPageHeader+FilterBarimmediately and confine the spinner (or a real DataTable skeleton — theskeleton-bohaskill 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 ownisPendingshows a SECOND centered spinner. SeparatelyReceivedInvoicesnever early-returns:renderKpishows<LoadingState/>(:752) AND the list shows one (:846) — two stacked spinners on cold load, where siblingInvoicesshows 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-316andDashSessions.tsx:161-164inlineBox+CircularProgress(different padding/size, no aria-label) vs the shared<LoadingState/>.DashTodayPlan/DashSessionshand-roll Typography empties whileDashActivityFeed/DashAttendanceTodayuse<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.
DashProfileandDashSessionsrender only inside{!dashLoading && (...)}(Dashboard.tsx:505), wheredashLoadingis the multi-domain aggregate (attendance+offers+invoices+orders+projects+leave). Neither block depends on that data —DashProfile's 2FA comes fromtotpStatusOptions,DashSessionshas its own query (:74). Fix: renderDashProfile/DashSessionsindependently; gate only the data-dependent cards. -
Autocomplete pickers show English "No options."
MuiProvidersets only the date-picker locale, no globalcsCZlocaleText(:15-22);CustomerPicker.tsx:49/SupplierPicker.tsx:49omitnoOptionsText, so empty search shows MUI's built-in English.ItemPicker.tsx:61correctly sets "Žádné položky." Fix: add MUIcsCZcomponentlocaleText(fixes every Autocomplete/pagination at once) or at minimumnoOptionsTexton 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), butInvoices.tsx:282,ReceivedInvoices.tsx:250andAuditLog.tsx:202feed the raw value into the query key — laggier and hammering the API on the busiest financial screens. Fix: wrap inuseDebounce(or a sharedSearchField). -
DataTable
onRowClickrows 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 setonRowClick. Worse, the clickableTableRowhas notabIndex/role/onKeyDown(DataTable.tsx:285-303,137-161), so a keyboard user cannot open a warehouse record.OdinSidebaralready has the correct pattern (:126-140). Fix: addrole="button",tabIndex=0, Enter/Space handling whenonRowClickis set; then pick one row convention. -
Column sorting absent on warehouse-document, audit-log and trips tables that look sortable.
DataTablerenders sort headers only when columns carrysortKeyand the page wiresonSort. Offers/Invoices/Orders/Projects/WarehouseItems do;WarehouseIssues.tsx:258,AuditLog.tsx:364,TripsAdmin.tsx:815and 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: wireuseTableSort+sortKey+onSortinto the large paginated tables. -
Sibling Order tabs disagree on the "all" status label.
IssuedOrdersuses "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:149has only a status dropdown (no search despitesession_number);WarehouseReservations.tsx:354has no text search or date filter;AuditLog.tsx:321(5 filters) andTripsAdmin.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 viaofferCustomersOptions, wirecustomer_idinto list+totals; may need a small backend add). -
Count subtitle missing on Projects/Users/Vehicles/Customers and the Přijaté tab; count copy splits
czechPluralvs 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) whereczechPlural()exists (formatters.ts:69). Fix: add a result-count subtitle viaPageHeader; replace the hand-rolled ternaries withczechPlural(). -
Many pages hand-roll the title bar instead of
PageHeader, so multi-button headers wrap raggedly on mobile.PageHeader'sheaderActionsSxmakes header buttons stack full-width on phones (PageHeader.tsx:19-30), but ~20 pages hand-rollBox+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 inheaderActionsSx. -
Three forked invoice PDF openers → inconsistent feedback and different error copy. Shared
useDocumentListPdf/useDocumentPdfpre-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-invoiceopenFilestreams 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 oninv.invoice_numbertoo, matchingOffers.tsx:660/IssuedOrders.tsx:303. -
Customers and Suppliers directories use opposite data/search/pagination models. Built as mirrors, but
OffersCustomersfetches the entire list once and filters client-side with no debounce/pagination (:159,380), whileWarehouseSuppliersis server-paginated, debounced, page-sized with real Pagination (:139,449). Fix: align Customers on the Suppliers model (server pagination/search), preserving instant responsiveness viakeepPreviousData/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/orderswith 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: pointOrderDetail'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);AttendanceCreateuses a text "← Zpět na správu" on the right (:173). All hardcode the list route. Fix: add an optionalback={{to,label}}/onBackprop toPageHeaderrendering one canonical left-aligned icon+label control. -
No per-page browser title — every tab/bookmark/history entry reads "Admin."
index.html:22hardcodes<title>Admin</title>and nothing updates it (zerodocument.titlerefs). With multiple tabs open (common here), the tab strip/history/bookmarks all read "Admin." Fix: add a<TitleSync>settingdocument.titleper route fromnavData.tsxlabels (+ " · 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 › " trail. Combined with the missing title, deep-page orientation is thin. Fix: add a lightweight section › list › record breadcrumb derived from route +navDatametadata. -
settings.templatesgates the Nastavení nav item but Settings bounces such users to the dashboard. The gear shows if the user holds any of five permissions includingsettings.templates, butcanAccessSettingschecks only roles/company/system/banking andNavigate-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: dropsettings.templatesfrom 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 silentlyNavigate-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 thematchExclude:['/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 fromsklad_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) intermixwarehouse.operatedaily drivers with rarely-touchedwarehouse.manageconfig (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) targetingid="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 toLeaveRequestsopening 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.
DashTodayPlanrendersprojectLabelas plain text (:69) while other cards link "Vše →"/"Detail →." Fix: wrapprojectLabelin aRouterLinkto/projects/:id(id is already inTodayPlan); 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 matchReservationPicker's own label (:65). -
ReservationPickeris 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 setsreservation_idfrom router prefill (WarehouseIssueForm.tsx:183). Fix: render it in the issue-form line (gated on item_id+project_id, mirroringBatchSelect) — 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_logsstoresold_values/new_valuesand 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;
ShortcutsHelpis a permanent no-op.ShortcutsHelpis 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 addtitlehints (copyOdinComposer'stitle="Odeslat (Enter)"ontoNoteCard's Ctrl+Enter). -
Plan grid resets to the current week on every remount.
usePlanWorkinits view/anchor tonew Date()with no persistence (:112-113), so leaving and returning to/planloses your scroll position. Fix: persist view+anchor (URL query or localStorage). -
"Zpět na správu" / post-save links pass
?month=YYYY-MMbut AttendanceAdmin ignores it.AttendanceCreate(:141) andAttendanceLocation(:211) link to/attendance/admin?month=…, butuseAttendanceAdminnever reads the URL (hard-inits month to current). Fix: seed the initial month fromuseSearchParams()and keep it synced. -
Receipt/Issue details never show a document-level total. They compute per-line "Celkem" but the
DataTablehas 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 viaformatCurrency) 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.
WarehouseInventoryFormoffers 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 collectsactual_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.
TripsAdminoffers only Tisk + a Vozidla link (:710), no "add trip," and its edit modal has no driver picker. The/tripsPOST already acceptsbody.user_id(trips.ts:341). Fix: add an "Přidat jízdu" action with a driver selector, mirroringAttendanceAdmin. -
⚠ Found during verification (backend, not UX):
POST /tripsacceptsbody.user_idwithout atrips.managecheck. Any user holding onlytrips.recordcan create a trip attributed to another user's id (src/routes/admin/trips.ts:341,trips.schema.ts:13) — reads are correctly scoped (buildTripsWherelimits non-managers to their own trips) but the create path is not. Minor impersonation-on-create authz gap. Fix: ignorebody.user_id(forceauthData.userId) unless the caller holdstrips.manage. -
Personal Trips "Záznam" shows company-wide trips/stats for managers. Unlike "Moje historie" (filters by
userId), the personal Trips page callstripListOptions/tripStatsOptionswith nouserIdand renders a "Řidič" column (Trips.tsx:166,359) — surfacing all drivers' trips and company-wide totals, unlike the strictly-personal attendance punch screen. Fix: passuserId(matchingTripsHistory), or relabel/drop the Řidič column. Keep list and stats on one scope.
4.10 Forked/duplicated shared modules & code drift
-
InvoiceDetailforks the shared document editor (and drops the item-description maxLength cap). Offers/issued orders compose sharedDocumentItemsEditor/useDocumentPdf/useUnsavedChangesGuard;InvoiceDetailreimplements 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 ashowVat/vatOptionsflag toDocumentItemsEditor(respecting "VAT only on invoices") and migrateInvoiceDetailonto it plus the shared hooks. -
Status label/color maps duplicated across warehouse, projects and leave.
documentStatus.tsexists 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-50are byte-identical, andLeaveRequests.tsx:25-55/LeaveApproval.tsx:36-66are byte-identical. Fix: addWAREHOUSE_DOC_STATUS,MOVEMENT_TYPE,RESERVATION_STATUS,PROJECT_STATUS,LEAVE_STATUS/LEAVE_TYPEtodocumentStatus.tsand import them. -
AttendanceAdminfetches outside React Query (rawapiFetchinuseEffect+ manual refetch dance).useAttendanceAdminloads projects/users/records via rawapiFetchwith its ownsetData/setLoading(:823,871), so it doesn't participate in['attendance']invalidations — each mutation must invalidate AND manually re-runfetchDatawith asetTimeout(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/createANDShiftFormModal(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: deleteAttendanceCreate.tsxand 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-1112vsOffers.tsx:894-929). Same dialog, two attachment UIs. Fix: replace the raw input withFileUpload(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.
WarehouseItemDetailtoasts +navigate('/warehouse/items')(:153-158);WarehouseIssueDetail/ReceiptDetail/InventoryDetailrender 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.AttendanceLocationdefines a localformatDatetimeLocalparsing withnew Date()(:176); the rest of attendance uses the timezone-safeformatDatetime(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, whichformatDatetimeomits). -
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-313vsThemeToggle.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.tsestablishes 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 aPROJECT_STATUSmap (aktivni→info, dokonceny→success, zruseny→error) and consume viastatusLabel/statusColor. -
Linked order status on Project detail renders as a raw untranslated token.
ProjectDetailshowsSTATUS_LABELS[project.order_status]butSTATUS_LABELSis the project map whileorder_statusisprijata/v_realizaci/… — zero key overlap (:577), so a completed order shows "(dokoncena)" lowercase plain text. Fix: render viastatusLabel(ORDER_STATUS, project.order_status)(ideally aStatusChip). -
Settings admin-role callout uses a solid
info.light/info.darkfill (the forbidden .light-fill anti-pattern). The "Administrátor má vždy plný přístup" box is hand-styled withbgcolor:'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.
DashQuickActionssets 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" usesdangertoo — 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
DayInRangeModalshows 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 informatDate(). -
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 onhas_nas_folderand uses richer copy + "Tato akce je nevratná." (ProjectDetail.tsx:621,626). Fix: gate the list checkbox onhas_nas_folderand 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
submitTextmid-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 hardcodesloading?'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 usestornovana→"Stornována" (:49); issued orders usecancelled→"Stornovaná" (:58) — side-by-side under the same/orderspage. 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 withstartIcon={PlusIcon}(WarehouseIssueForm.tsx:601etc.). 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 throughformatCurrencyand decimals through cs-CZ/Intl; extract oneformatFileSize()with one unit casing. -
Leave-request modal day/hour estimate ignores public holidays. The modal previews "X pracovních dnů" using
calculateBusinessDays(Mon–Fri only,Attendance.tsx:498,1242), but the rest of the system is holiday-aware (serverleave-requests.ts:99excludes weekends AND holidays). A vacation spanning e.g. 1 May over-counts vs the storedtotal_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
Tabshas no scrollable variant.Tabs.tsx:28-49uses MUI's defaultvariant="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: setvariant="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.
Modalrenders a plainfullWidth+maxWidthDialog with no responsivefullScreen(: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 hardcodedminmax(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: addfullScreen={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
headerActionsSxslot (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.AttendanceCreatepins 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.
DocumentItemsEditorhas a dedicatedisMobilebranch — a bordered card per row with "Položka N" index, labeled fields, per-row total, drag/remove (:170-342).WarehouseIssueForm.tsx:513-567just collapses a grid toxs:'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,205useflex:'0 0 180px'/'0 0 220px'andAttendanceHistory.tsx:516usesflex:'0 0 180px'(fixed, never grow), so on a phone they sit narrow with dead space beside them, whileOffers.tsx:786usesflex:'1 1 320px'which fills the row. Fix: adopt one convention (e.g.flex:'1 1 <min>px') or haveFilterBarstretch children full-width belowsm.
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 anaria-labelto 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: addaria-label="Hledat"+type="search"— best via a sharedSearchField(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.tsmanualChunksonly 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-livedvendor-muichunk. -
framer-motion (147 kB) is a static dependency of the eager AppShell, loading on first paint incl. Login.
AppShell/PageEnter/ThemeTogglestatically import framer-motion (PageHeaderdeliberately does not);AppShellis eagerly imported byAdminApp(:8,AppShell.tsx:101). Login pays for it too. Fix: migrate toLazyMotion+ the lightweightmcomponent (domAnimation) — note Login and the Dash* components import framer-motion directly, so lazy-gating onlyPageEnterwon'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/logindownloads the entire authenticated dashboard's code before typing a password. Fix: lazy-load Dashboard vialazyWithReloadlike 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/preloadhits). AProgressBarexists but is reused only as an attendance-fund meter. Fix: add an unobtrusive topLinearProgressduring 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.