Files
app/POSSIBLE_IMPROVEMENTS.md

78 KiB
Raw Blame History

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