) with delete via prev.filter((_,i)=>i!==idx) (line 1594) and swap-reorder (lines 1540/1567). Per React docs, indices as keys on a list whose order/length changes cause React to reuse the wrong DOM node and component state — here that means after deleting a row the input values/focus shift to the wrong row. This is a genuine correctness bug, not a style nit.
+- **fix:** Key by a stable id. ConfirmationItem/ScopeSection should carry a stable client id (e.g. crypto.randomUUID() assigned when the row is created, or the server item id when present) and use key={item.id}. Read-only, non-reordering tables (Warehouse recentMovements, WarehouseReports rows) can keep index keys but ideally use the row's domain id.
+
+### 23. [MEDIUM/needs-judgment] Inconsistent modal implementation: FormModal/ConfirmModal vs hand-rolled overlays with diverging accessibility
+- **dimension:** react
+- **where:** src/admin/components/FormModal.tsx:88; src/admin/components/dashboard/DashProfile.tsx:266; src/admin/components/dashboard/DashProfile.tsx:405; src/admin/components/dashboard/DashProfile.tsx:660; src/admin/components/dashboard/DashQuickActions.tsx:321; src/admin/components/BulkAttendanceModal.tsx:47; src/admin/components/ShiftFormModal.tsx:252; src/admin/components/OrderConfirmationModal.tsx:108; src/admin/pages/WarehouseLocations.tsx:339; src/admin/pages/Attendance.tsx:936
+- **problem:** There are two good reusable modal primitives (FormModal handles body-scroll lock, Esc-to-close, role=dialog/aria-modal/aria-labelledby, focus-grouped footer; ConfirmModal similar), but at least 7 components reimplement the admin-modal-overlay + backdrop + motion markup by hand with inconsistent behavior. DashProfile's three modals (lines 266/405/660) and DashQuickActions (line 321) omit role="dialog", aria-modal, and aria-labelledby entirely; none of the hand-rolled overlays wire Esc-to-close (only FormModal/ConfirmModal do). BulkAttendanceModal/ShiftFormModal/OrderConfirmationModal include the ARIA roles but still lack Esc handling. The result is duplicated markup and an accessibility/UX behavior that varies modal-to-modal.
+- **fix:** For the smaller content modals (DashProfile edit-profile, 2FA setup/disable, DashQuickActions trip modal) switch to FormModal — they fit its API and would inherit Esc/lock/ARIA. For the large complex forms (ShiftFormModal, BulkAttendanceModal, OrderConfirmationModal) where FormModal's per-child stagger animation is awkward, factor the overlay+backdrop+Esc+ARIA shell into a shared ModalShell component and have these consume it, so a11y/Esc behavior is uniform. At minimum, add role="dialog"/aria-modal/aria-labelledby to the DashProfile/DashQuickActions overlays.
+
+### 24. [MEDIUM/safe-auto] Raw reply.send({ success: true, ... }) used instead of success()/paginated() helpers
+- **dimension:** routes
+- **where:** src/routes/admin/attendance.ts:32; src/routes/admin/attendance.ts:108; src/routes/admin/attendance.ts:118; src/routes/admin/attendance.ts:128; src/routes/admin/attendance.ts:142; src/routes/admin/attendance.ts:165; src/routes/admin/attendance.ts:179; src/routes/admin/attendance.ts:190; src/routes/admin/attendance.ts:203; src/routes/admin/attendance.ts:235; src/routes/admin/trips.ts:73; src/routes/admin/leave-requests.ts:57; src/routes/admin/invoices.ts:62; src/routes/admin/projects.ts:41; src/routes/admin/received-invoices.ts:80; src/routes/admin/customers.ts:100
+- **problem:** CLAUDE.md explicitly states: 'Use the success() and error() helpers in routes — never write raw reply.send().' Many handlers hand-roll the envelope. The paginated-list cases (attendance.ts:235, trips.ts:73, leave-requests.ts:57, invoices.ts:62, projects.ts:41, received-invoices.ts:80, customers.ts:100) duplicate exactly what paginated(reply, data, meta) produces; the single-object cases (attendance.ts:32,108,118,...) duplicate success(reply, data). This is purely a consistency/maintainability gap (the shapes happen to match today), but it bypasses the central helper, so any future change to the response envelope or status handling won't propagate.
+- **fix:** Replace the raw `return reply.send({ success: true, data, pagination })` calls with `return paginated(reply, data, buildPaginationMeta(total, page, limit))`, and `return reply.send({ success: true, data })` with `return success(reply, data)`. attendance.ts and customers.ts already import these helpers; trips/leave-requests/invoices/projects/received-invoices import paginated where needed.
+
+### 25. [MEDIUM/needs-judgment] Session-termination mutations have no audit logging
+- **dimension:** routes
+- **where:** src/routes/admin/sessions.ts:80-99; src/routes/admin/sessions.ts:102-127
+- **problem:** DELETE /sessions/:id (revoke a specific refresh token) and DELETE /sessions?action=all (revoke all other sessions) mutate security-relevant state — they invalidate refresh tokens — but call no logAudit(). CLAUDE.md and the database conventions say data that is created/updated/deleted should be audit-logged, and these are exactly the kind of security events (forced logout / session revocation) that belong in the forensic trail. Every other mutating route in the module logs an audit entry; these two are the notable omissions among security-sensitive endpoints.
+- **fix:** Add logAudit() calls after the successful update/updateMany in both handlers (e.g. action 'delete' or 'logout', entityType 'session' or 'refresh_token', entityId the session id for the single case), mirroring the audit pattern used in totp.ts and auth.ts for login/logout events.
+
+### 26. [MEDIUM/needs-judgment] Two competing mutation patterns coexist; ~27 raw apiFetch mutations bypass useApiMutation
+- **dimension:** rq-data
+- **where:** src/admin/lib/queries/mutations.ts:88; src/admin/pages/WarehouseReservations.tsx:106; src/admin/pages/WarehouseReservations.tsx:136; src/admin/pages/Invoices.tsx:208; src/admin/pages/Invoices.tsx:234; src/admin/pages/Offers.tsx:222; src/admin/pages/OfferDetail.tsx:734; src/admin/pages/OfferDetail.tsx:757; src/admin/pages/OfferDetail.tsx:782; src/admin/hooks/useAttendanceAdmin.ts:1039
+- **problem:** The project ships a dedicated useApiMutation hook (mutations.ts) that wraps useMutation + apiFetch + envelope unwrapping + broad invalidation, and it is adopted in 34 files. But 14 files (~27 call sites) still hand-roll the same flow: `const response = await apiFetch(url,{method:...}); const result = await response.json(); if(result.success){ queryClient.invalidateQueries(...); alert.success(...) } else { alert.error(...) } } catch { alert.error('Chyba připojení') }`. This duplicates exactly what useApiMutation centralizes (error throwing as ApiError, success/error branching, invalidation), and the two paths diverge in behavior: useApiMutation throws typed ApiError with .status so callers can branch on HTTP code, whereas the raw path collapses all failures into a generic Czech 'Chyba připojení' string and never exposes status. This is the dominant inconsistency in the data layer. TanStack v5 docs document the useMutation→onSuccess→invalidateQueries flow that useApiMutation implements; the raw path is the non-idiomatic outlier.
+- **fix:** Standardize new and touched mutations on useApiMutation (pass `invalidate: ["warehouse"]` etc. instead of manual invalidateQueries, and use mutate/mutateAsync's onSuccess/onError for UI side-effects). Migrate the raw-apiFetch mutation sites incrementally, starting with the simplest CRUD pages (WarehouseReservations, Invoices delete/toggleStatus). Document useApiMutation as the canonical mutation entry point in CLAUDE.md's Frontend Conventions so the pattern stops regrowing.
+
+### 27. [MEDIUM/safe-auto] Broad-domain invalidation convention violated: OfferDetail uses narrow ["offers","list"]
+- **dimension:** rq-data
+- **where:** src/admin/pages/OfferDetail.tsx:681; src/admin/pages/OfferDetail.tsx:693; src/admin/pages/OfferDetail.tsx:739; src/admin/pages/OfferDetail.tsx:765; src/admin/pages/OfferDetail.tsx:792; src/admin/pages/Offers.tsx:227
+- **problem:** CLAUDE.md mandates invalidating the broad domain key (`["offers"]` over `["offers","list"]`) because React Query prefix-matches. OfferDetail.tsx invalidates the narrow key `["offers","list"]` in five handlers (save edit/create, create order, invalidate, delete), while the sibling Offers.tsx (line 227) and CompanySettings.tsx (line 437) correctly invalidate broad `["offers"]`. Consequence: the narrow key does NOT prefix-match the offer-detail cache entry `["offers", id]` (offerDetailOptions, offers.ts:144) nor `["offers","next-number"]`, so after editing an offer the detail query for OTHER offers / next-number stays stale until refetched by other means. The TanStack v5 docs' prefix-match example (`invalidateQueries({queryKey:['projects']})`) is the exact rationale CLAUDE.md cites — OfferDetail is the lone violator.
+- **fix:** Change the five `["offers","list"]` invalidations in OfferDetail.tsx to broad `["offers"]` to match the convention and the rest of the offers code. (The delete handler additionally removeQueries(["offers",id]) which is fine to keep.) Mechanical and behavior-safe: broadening only marks more inactive queries stale; active ones already refetch.
+
+### 28. [MEDIUM/manual] useAttendanceAdmin runs a parallel data system instead of query factories / useApiMutation
+- **dimension:** rq-data
+- **where:** src/admin/hooks/useAttendanceAdmin.ts:1049; src/admin/hooks/useAttendanceAdmin.ts:1050; src/admin/hooks/useAttendanceAdmin.ts:1121; src/admin/hooks/useAttendanceAdmin.ts:1256; src/admin/hooks/useAttendanceAdmin.ts:1286
+- **problem:** This 1200+ line hook does its own data orchestration: each mutation does `queryClient.invalidateQueries(["attendance"])` AND `await fetchData(false)` AND `await new Promise(r=>setTimeout(r,300))` to wait for the manual refetch (e.g. lines 1049-1052, 1121-1123). It runs a hand-rolled `fetchData` alongside React Query rather than relying on the attendance.ts queryOptions factories and letting invalidation drive refetch. The fixed 300ms sleep is a race-condition smell (it guesses how long the refetch takes) and the double invalidate+fetchData duplicates work. This is the single largest divergence from the otherwise-consistent data layer.
+- **fix:** Treat as a larger refactor (out of scope for safe-auto): migrate the hook's reads to the attendance.ts queryOptions factories and its writes to useApiMutation so invalidation alone drives refetch, removing fetchData and the setTimeout(300). At minimum, drop the redundant manual fetchData+sleep where invalidateQueries already covers it. Flag for a dedicated cleanup ticket.
+
+### 29. [MEDIUM/needs-judgment] Three coercion idioms duplicated ~150+ times instead of shared helpers
+- **dimension:** schemas
+- **where:** src/schemas/common.ts (only has parseBody); src/schemas/invoices.schema.ts:5-33; src/schemas/orders.schema.ts:6-27; src/schemas/warehouse.schema.ts:85-219; src/schemas/attendance.schema.ts:19-145; src/schemas/settings.schema.ts:15-62; src/schemas/trips.schema.ts:4-37; src/schemas/vehicles.schema.ts:8-39; src/schemas/bank-accounts.schema.ts:14-34; src/schemas/received-invoices.schema.ts:5-60
+- **problem:** The same three sub-schemas are copy-pasted across nearly every file: (1) number-from-form `z.union([z.number(), z.string()]).transform((v) => Number(v))`, (2) its nullable form `z.union([z.number(), z.string(), z.null()]).transform((v) => (v === null ? null : Number(v)))`, and (3) boolean-from-form `z.preprocess((v) => v === true || v === 1 || v === '1', z.boolean())`. There are 150+ occurrences. common.ts only exports parseBody — no shared id/number/bool/date primitives exist, even though plan.schema.ts (intFromForm/isoDate) and planCategory.schema.ts (hexColor) already prove the team knows how to factor these out locally. This is the single biggest source of drift: any fix to coercion behavior must be applied in dozens of places.
+- **fix:** Add shared exports to src/schemas/common.ts, e.g. `numFromForm`, `nullableNumFromForm`, `boolFromForm`, `idFromForm`, `isoDate`, and import them everywhere. Zod 4 (in use here, v4.3.6) provides `z.coerce.number()` and `z.stringbool()` which replace idioms (1) and (3) outright; per Zod 4 docs `z.stringbool()` throws on unrecognized strings (safer than the silent preprocess). Consolidating also lets you fix the NaN bug (next finding) in one spot.
+
+### 30. [MEDIUM/needs-judgment] Number coercion silently produces NaN (no validation guard in most files)
+- **dimension:** schemas
+- **where:** src/schemas/trips.schema.ts:4,12-13; src/schemas/vehicles.schema.ts:8-17,29-34; src/schemas/invoices.schema.ts:26-29,48-52,89-92; src/schemas/settings.schema.ts:15-62; src/schemas/attendance.schema.ts:20-37,90-129; src/schemas/received-invoices.schema.ts:5-6,35-60; src/schemas/bank-accounts.schema.ts:14-18,31-34
+- **problem:** Most `.transform((v) => Number(v))` coercions have no follow-up refine, so a non-numeric string parses to `NaN` and passes validation. e.g. CreateTripSchema vehicle_id/start_km/end_km (trips.schema.ts:4,12,13) accept garbage as NaN; settings break_threshold_hours, clock_rounding_minutes, max_login_attempts etc. all coerce to NaN silently. Notably this is applied INCONSISTENTLY: orders.schema.ts and offers.schema.ts DO add `.refine((v) => !Number.isNaN(v), ...)` after every numeric transform, and invoices.schema.ts:8-12 throws inside the transform for quantity — so the same project has three different behaviors for the identical concern. NaN then flows into Prisma/business logic.
+- **fix:** Standardize on one safe numeric primitive (ideally the shared helper from the previous finding) that rejects NaN — e.g. `z.coerce.number()` which natively errors on non-numeric input, or `.refine((v) => !Number.isNaN(v))`. Apply it uniformly so trips/vehicles/settings/attendance match the stricter orders/offers behavior.
+
+### 31. [MEDIUM/needs-judgment] Email validation applied inconsistently across schemas
+- **dimension:** schemas
+- **where:** src/schemas/users.schema.ts:5,21; src/schemas/profile.schema.ts:4; src/schemas/customers.schema.ts (no email field, but); src/schemas/warehouse.schema.ts:30,42; src/schemas/settings.schema.ts:38-41
+- **problem:** Email fields are validated as proper emails only in users.schema.ts and profile.schema.ts via `z.string().email(...)`. Other schemas that accept email-typed data use plain `z.string().nullish()` with no format check: warehouse supplier `email` (warehouse.schema.ts:30,42), and company-settings `invoice_alert_email`, `leave_notify_email`, `smtp_from` (settings.schema.ts:38-40). The notify/alert emails in settings are used to actually send mail, so an invalid value is a silent operational failure.
+- **fix:** Apply email-format validation to the email-typed fields in warehouse and settings schemas (e.g. `z.string().email(...).nullish()`, allowing empty/null). Note: Zod 4 prefers the top-level `z.email()` over the now-deprecated `z.string().email()`; consider migrating the existing users/profile usages too for consistency.
+
+### 32. [MEDIUM/needs-judgment] Services return error with no status, forcing routes to hardcode or cast the HTTP code
+- **dimension:** svc-errors
+- **where:** src/services/attendance.service.ts:376; src/routes/admin/attendance.ts:50; src/routes/admin/projects.ts:73; src/routes/admin/quotations.ts:286
+- **problem:** Many service functions return an error object with NO status field, while the documented convention is error plus status. Routes compensate inconsistently: attendance hardcodes the status at the call site (400 at attendance.ts:50, 404 at :70 for the same shape), and projects/quotations cast result.status with as-any. The same conceptual error yields 400 from one route and 404 from another by call-site choice, not service intent.
+- **fix:** Make every error path return error plus status. Then collapse route call sites to the uniform check already used by plan.ts, removing the as-any casts.
+
+### 33. [MEDIUM/needs-judgment] String-sentinel error values decoded by routes instead of self-describing error plus status
+- **dimension:** svc-errors
+- **where:** src/services/invoices.service.ts:393; src/services/offers.service.ts:231; src/services/projects.service.ts:149; src/routes/admin/invoices.ts:165
+- **problem:** updateInvoice, deleteProject and updateOffer return machine-codes as the error string (not_found, has_order, invalidated) without a status. The Czech message and HTTP status are reconstructed in the route via if/else chains ending in a fall-through 500 Neznama chyba. This splits one entity error vocabulary across two files and is easy to desync.
+- **fix:** Return the final Czech message plus status directly, as updateOrder/createOffer already do. The route then needs only the uniform one-line check and the 500 fall-throughs disappear.
+
+### 34. [MEDIUM/needs-judgment] Duplicated and contradictory `PaginationMeta` / pagination shape (3 definitions)
+- **dimension:** ts-safety
+- **where:** src/types/index.ts:85; src/admin/lib/apiAdapter.ts:33; src/admin/hooks/usePaginatedQuery.ts:8
+- **problem:** The pagination response shape is declared three times with diverging fields. The server type `PaginationMeta` (types/index.ts:85) has `page, limit, per_page, total, total_pages`. The frontend `PaginationMeta` (apiAdapter.ts:33) drops `limit` entirely (`total, page, per_page, total_pages`). A third inline `PaginatedResult.pagination` (usePaginatedQuery.ts:8-16) re-declares the same fields again. These are independent definitions that can drift; the missing `limit` on the frontend copy means a consumer expecting the server contract is silently incompatible.
+- **fix:** Pick one canonical `PaginationMeta` (the server one in types/index.ts is the contract) and have the frontend import/derive from it, or at minimum collapse apiAdapter.ts and usePaginatedQuery.ts onto a single shared interface so the `limit` discrepancy is resolved and can't drift.
+
+### 35. [MEDIUM/needs-judgment] Missing return types on ~47 of 107 exported service functions force `as any` casts in routes
+- **dimension:** ts-safety
+- **where:** src/services/projects.service.ts:95; src/routes/admin/projects.ts:73; src/routes/admin/projects.ts:106; src/routes/admin/quotations.ts:286; src/services/users.service.ts:59; src/services/offers.service.ts:161; src/services/invoices.service.ts:339
+- **problem:** Roughly 47 of 107 exported `async function`s in src/services have no explicit return type and rely on inference. For functions like `updateProject` (projects.service.ts:95) the inferred type is a union of `null | { error, status } |
`; after `"error" in result` narrowing the row branch still has no `status`, so route handlers reach for `(result as any).status ?? 400` (projects.ts:73, projects.ts:106, quotations.ts:286) to read a field the compiler can't guarantee. The `any` cast erases all type checking on the result at exactly the error-handling boundary. The standard `{ data } | { error, status }` service contract documented in CLAUDE.md is not enforced because the return type is left implicit.
+- **fix:** Annotate service functions with an explicit discriminated return type (e.g. a shared `ServiceResult = { data: T } | { error: string; status: number }`, or `Result` like plan.service.ts:387 already defines). With a uniform `status` on the error branch, the `(result as any).status` casts in routes can be deleted and become type-safe. This is the systemic root cause of the route-level casts.
+
+### 36. [MEDIUM/needs-judgment] Heavy `any` in usePlanWork mutations plus mutation-object monkey-patching (`(createEntry as any)._rolled`)
+- **dimension:** ts-safety
+- **where:** src/admin/hooks/usePlanWork.ts:235; src/admin/hooks/usePlanWork.ts:241; src/admin/hooks/usePlanWork.ts:261; src/admin/hooks/usePlanWork.ts:273; src/admin/hooks/usePlanWork.ts:322; src/admin/hooks/usePlanWork.ts:355; src/admin/hooks/usePlanWork.ts:388; src/admin/hooks/usePlanWork.ts:430; src/admin/hooks/usePlanWork.ts:457; src/admin/hooks/usePlanWork.ts:478
+- **problem:** The plan-work optimistic-update hook is the single densest `any` site in the frontend (20 occurrences). Mutation `mutationFn`/`onSuccess` bodies and vars are typed `any` (e.g. `(body: any)`, `(data: any, body: any)`), and rollback state is stored by mutating the mutation object: `(createEntry as any)._rolled = rolled` (and 5 more `_rolled` writes). `qc.getQueryData(currentGridKey as any)` also casts the typed query key away. This defeats type safety on the optimistic-update path where cell-patching correctness matters most, and the `_rolled` side-channel is invisible to the type system, so a typo in the property name would silently break rollback.
+- **fix:** Type the mutation bodies with the request DTOs (the plan entry/override create/update shapes already exist in schemas/ and lib/queries/plan.ts) and `onSuccess` data with the API response type. Replace the `(mutation as any)._rolled` side-channel with React Query's `onMutate`/context return value (the supported, fully-typed rollback mechanism), which removes the casts.
+
+### 37. [LOW/safe-auto] Configured @/* path alias is unused and not wired into the Vite/vitest build
+- **dimension:** config-structure
+- **where:** tsconfig.app.json:19-22; tsconfig.server.json:16-19; vite.config.ts:4-32; vitest.config.ts:5-14
+- **problem:** Both tsconfig.app.json and tsconfig.server.json declare baseUrl:'.' with paths {'@/*':['src/*']}, but a search for imports from '@/' across src returns zero matches — the alias is dead config. Worse, if someone did adopt it, vite.config.ts and vitest.config.ts define no matching resolve.alias, so TypeScript would resolve it but the Vite client build and Vitest would fail at runtime. The codebase consistently uses relative imports instead.
+- **fix:** Either remove the baseUrl/paths block from both tsconfigs to avoid implying an alias convention the bundler does not support, or commit to it by adding the matching resolve.alias in vite.config.ts/vitest.config.ts and migrating imports. Removal is the lower-risk option given current usage.
+
+### 38. [LOW/needs-judgment] package.json db:push / db:pull scripts contradict the documented migration golden rule
+- **dimension:** config-structure
+- **where:** package.json:16-17; CLAUDE.md
+- **problem:** CLAUDE.md states the golden rule 'NEVER use prisma db push' (it bypasses migration history and causes drift), yet package.json exposes "db:push": "prisma db push" (and "db:pull": "prisma db pull") as first-class npm scripts, which invites exactly the prohibited workflow. The documented schema-change path is prisma migrate dev, which has no script alias.
+- **fix:** Remove the db:push (and likely db:pull) scripts, or rename/guard them so they can't be run casually; add a db:migrate script wrapping `prisma migrate dev` to match the documented workflow.
+
+### 39. [LOW/needs-judgment] Cross-boundary import of a server util by the client is inconsistent and structurally fragile
+- **dimension:** config-structure
+- **where:** src/admin/utils/attendanceHelpers.ts:1; src/admin/hooks/useAttendanceAdmin.ts:4; src/utils/czech-holidays.ts:1-8; tsconfig.server.json:21-30
+- **problem:** src/admin code imports getHolidays/isHoliday from the server-side util src/utils/czech-holidays via '../../utils/czech-holidays'. This is the ONLY place server logic is genuinely shared with the client; every other shared concern (formatters, label maps, EntityType) is duplicated instead, so the sharing strategy is inconsistent. It also reaches across the build boundary: tsconfig.server.json compiles src/utils into the server bundle while Vite separately bundles the same file into the client. It works today only because czech-holidays has no Node-only dependencies (it imports just ./date). If czech-holidays ever pulls in a Node API (fs, crypto, prisma), the client build breaks silently.
+- **fix:** Decide on one strategy: either introduce a dedicated src/shared/ (or src/common/) module for genuinely isomorphic helpers like czech-holidays and date formatters, referenced by both builds with an explicit boundary, or duplicate intentionally. Avoid ad-hoc src/admin -> src/utils relative imports that silently couple the two bundles.
+
+### 40. [LOW/safe-auto] .env.example has drifted from the variables actually read in config/env.ts
+- **dimension:** config-structure
+- **where:** .env.example:1-32; src/config/env.ts:55-110
+- **problem:** config/env.ts reads several env vars absent from .env.example: TOTP_ALGORITHM, TOTP_DIGITS, TOTP_PERIOD, LOGIN_TOKEN_EXPIRY_MINUTES, NAS_FINANCIALS_PATH, NAS_OFFERS_PATH, SMTP_FROM_NAME, INVOICE_ALERT_EMAIL, RATE_LIMIT_MAX/WINDOW/LOGIN/TOTP/REFRESH, and TRUST_PROXY. All have defaults so nothing breaks, but the example file no longer documents the full configuration surface, making prod tuning (rate limits, trust proxy) undiscoverable.
+- **fix:** Add the missing optional variables (with their default values as comments) to .env.example so it stays a complete reference for config/env.ts.
+
+### 41. [LOW/manual] Inconsistent badge/class naming scheme (admin- prefix dropped)
+- **dimension:** css
+- **where:** src/admin/components.css:142; src/admin/components.css:146; src/admin/components.css:150; src/admin/components.css:154; src/admin/attendance.css:333; src/admin/invoices.css:24; src/admin/invoices.css:70
+- **problem:** Class naming is inconsistent within the single global namespace. Most badges use admin-badge-* (admin-badge-order-prijata, admin-badge-invoice-paid), but the leave-status badges in components.css:142-154 are bare badge-pending/badge-approved/badge-rejected/badge-cancelled, and attendance.css reuses bare badge-vacation/sick/holiday/unpaid. invoices.css mixes three schemes in one file: admin-badge-invoice-* (:5), invoice-month-* (:24), and received-upload-* (:70). Because all CSS is global, these bare badge-* names risk collision and make the convention ambiguous (admin-* for shared vs feature-prefix for page-local).
+- **fix:** Document and enforce one rule: shared/reusable components use admin-*, page-local elements use a feature prefix (already done for plan-*, dash-*, fm-*). Rename bare badge-* to admin-badge-* (or feature-prefix them) — coordinate the matching className strings in the TSX. Not safe-auto because it requires synchronized TSX edits.
+
+### 42. [LOW/needs-judgment] Defined-but-unused CSS variables (dead tokens)
+- **dimension:** css
+- **where:** src/admin/variables.css:27; src/admin/variables.css:34; src/admin/variables.css:14; src/admin/variables.css:15; src/admin/variables.css:26
+- **problem:** Several tokens are declared but never referenced anywhere in *.css: --gradient-subtle (variables.css:27), --transition-slow (:34), --space-10 (:14) and --space-12 (:15). Additionally --gradient (:26) is used only once (attendance.css:220) and is just a duplicate literal of --accent-color (#d63031), so it is a redundant alias rather than a real gradient token. These are dead/redundant and add noise to the token system.
+- **fix:** Remove the unused tokens, or wire them up if intended. For --gradient, either point attendance.css:220 at var(--accent-color) and delete --gradient, or rename it to something meaningful. Verify against TSX inline styles before deleting (grep showed no CSS consumers).
+
+### 43. [LOW/needs-judgment] Empty responsive.css stub imported into the bundle
+- **dimension:** css
+- **where:** src/admin/responsive.css:1; src/admin/AdminApp.tsx:22
+- **problem:** responsive.css contains only a 6-line comment ('reserved for responsive rules that span multiple components') and no rules, yet is imported at AdminApp.tsx:22. All responsive media queries actually live in their per-feature files (forms.css, layout.css, components.css, etc.), so the file is dead. It is harmless but misleading — a reader may expect cross-cutting breakpoints to live here.
+- **fix:** Either delete the file and its import, or actually consolidate the repeated breakpoints here. Note there is no shared breakpoint token: 480/640/768/1024 recur across ~15 files as raw px (e.g. forms.css:293-312, components.css, layout.css). If kept, this file is the natural place to at least document the canonical breakpoint set.
+
+### 44. [LOW/safe-auto] plan.css imported twice
+- **dimension:** css
+- **where:** src/admin/AdminApp.tsx:26; src/admin/pages/PlanWork.tsx:18
+- **problem:** plan.css is imported globally in AdminApp.tsx:26 (alongside all other stylesheets) and again in PlanWork.tsx:18. Since AdminApp already loads it globally, the second import is redundant. The bundler de-dupes so there is no runtime double-application, but it is inconsistent — no other page re-imports its own CSS (e.g. WarehousePage does not re-import warehouse.css), so this is an outlier that suggests uncertainty about the loading model.
+- **fix:** Remove the import at PlanWork.tsx:18 to match the convention that all admin CSS is loaded once in AdminApp.tsx. Low risk since the global import already covers it.
+
+### 45. [LOW/needs-judgment] getCzechDate week number uses a non-ISO local formula that disagrees with the plan module's ISO week
+- **dimension:** dates
+- **where:** src/admin/utils/dashboardHelpers.ts:75-78; src/admin/pages/PlanWork.tsx:61-69
+- **problem:** The dashboard header (getCzechDate) computes "Týden N" with a homegrown formula: Math.ceil(((now - Jan1)/86400000 + Jan1.getDay() + 1)/7) using local getters. This is not ISO-8601 (ISO weeks start Monday; week 1 is the week containing the first Thursday) and is also locale-dependent via getDay(). PlanWork.tsx:61-69 implements a correct ISO-8601 week (Thursday-anchored, UTC). For dates near year boundaries the two will display different week numbers for the same day, an internal inconsistency users can notice.
+- **fix:** Extract PlanWork's isoWeekNumber into a shared util and reuse it in getCzechDate so the week number is computed one consistent (ISO) way across the UI.
+
+### 46. [LOW/needs-judgment] Two divergent conventions for writing @db.Date columns (UTC-midnight vs local-noon)
+- **dimension:** dates
+- **where:** src/routes/admin/trips.ts:234; src/routes/admin/trips.ts:294; src/services/invoices.service.ts:362-364; src/services/attendance.service.ts:1176; src/services/attendance.service.ts:1227-1234
+- **problem:** Date-only (@db.Date) columns are written two different ways. Trips and invoices do new Date(String(body.x)) where body.x is "YYYY-MM-DD" → UTC-midnight. Attendance constructs new Date(year,month,day,12,0,0) → local noon. Both currently store the correct date in Prague only because the local offset is always positive and 12:00 local is comfortably inside the day in UTC. The local-noon form is the robust one (immune to offset sign and DST edges); the UTC-midnight form is the fragile one. Mixing them is an inconsistency that invites a future off-by-one if anyone copies the wrong pattern or the deployment TZ ever changes.
+- **fix:** Standardize on one convention for @db.Date writes — prefer the local-noon construction (or a single shared helper like dateOnly("YYYY-MM-DD")) and use it in trips/invoices too. Behavior is unchanged for Prague today, so this is a consistency/robustness cleanup, not an urgent fix.
+
+### 47. [LOW/needs-judgment] escapeHtml is defined six times across the codebase
+- **dimension:** deadcode
+- **where:** src/routes/admin/invoices-pdf.ts:35; src/routes/admin/orders-pdf.ts:50; src/routes/admin/offers-pdf.ts:51; src/services/invoice-alerts.ts:18; src/services/leave-notification.ts:21; src/admin/hooks/useAttendanceAdmin.ts:341
+- **problem:** There are six separate definitions of an escapeHtml(str) function. Five escape &,<,>," identically; the sixth (useAttendanceAdmin.ts:341) additionally escapes the single quote ('). They are used for HTML email bodies, PDF templates, and print HTML — all contexts where consistent escaping matters. The subtle difference (one escapes the apostrophe, the others don't) is the kind of inconsistency that hides XSS/output bugs.
+- **fix:** Move escapeHtml into a single shared util (server-side src/utils, plus one for the frontend, or share via a common module). Standardize on the stricter variant that also escapes the single quote. Replace the six local copies with imports.
+
+### 48. [LOW/safe-auto] formatDate duplicated between two frontend util files
+- **dimension:** deadcode
+- **where:** src/admin/utils/formatters.ts:20; src/admin/utils/attendanceHelpers.ts:24
+- **problem:** formatters.ts exports `formatDate(dateStr)` => `d.toLocaleDateString('cs-CZ')` with a '—' fallback, and attendanceHelpers.ts exports an identical `formatDate` (same body, same '—' fallback). Both are imported across the app, so two names resolve to the same logic in different modules — a maintenance hazard if one is changed.
+- **fix:** Keep the canonical formatDate in formatters.ts and have attendanceHelpers re-export or import it instead of redefining. Update attendanceHelpers consumers to the shared one.
+
+### 49. [LOW/safe-auto] Dead export: previewPattern() in numbering.service.ts
+- **dimension:** deadcode
+- **where:** src/services/numbering.service.ts:343
+- **problem:** `previewPattern(pattern, prefix, code)` is exported but has zero references anywhere in src (verified by grep across .ts/.tsx). It is the only public function in the numbering service with no callers; all sibling preview/generate functions are used.
+- **fix:** Remove previewPattern, or wire it into the settings UI/route that previews a numbering pattern if that was its intent. As-is it is dead code.
+
+### 50. [LOW/safe-auto] Dead export: usersQuery + planKeys.users() in plan query module
+- **dimension:** deadcode
+- **where:** src/admin/lib/queries/plan.ts:111; src/admin/lib/queries/plan.ts:95
+- **problem:** `usersQuery()` (plan.ts:111) is exported but never imported or used by any component/page. Its query key `planKeys.users()` (plan.ts:95) is referenced only by usersQuery itself. The `/api/admin/plan/users` endpoint string appears only inside this dead query (the PlanWork grid gets its users from the grid payload instead). So the frontend query, its key entry, and the `users: () => ['plan','users']` key are an orphaned cluster. (The backend route src/routes/admin/plan.ts:63 still exists and was not assessed as dead from the server side.)
+- **fix:** Remove usersQuery and the planKeys.users key entry. Separately confirm whether the backend GET /plan/users route still has any consumer; if not, it can be removed too.
+
+### 51. [LOW/needs-judgment] Dead export: getMonthlyWorkFund() in czech-holidays.ts
+- **dimension:** deadcode
+- **where:** src/utils/czech-holidays.ts:95
+- **problem:** `getMonthlyWorkFund(...)` is exported but has zero references across src (verified by grep). Sibling exports getHolidays/isHoliday/getBusinessDaysInMonth are all used; this one is not.
+- **fix:** Remove getMonthlyWorkFund, or use it where monthly work-fund hours are currently computed inline (e.g. attendance.service.ts computes fund hours as `getBusinessDaysInMonth(...) * 8` in several places — that inline math is what this helper appears intended to replace).
+
+### 52. [LOW/needs-judgment] Unused file: ReservationPicker.tsx
+- **dimension:** deadcode
+- **where:** src/admin/components/warehouse/ReservationPicker.tsx:11
+- **problem:** The whole file (54 lines, default export ReservationPicker) has no importers anywhere in the project — grep for 'ReservationPicker' returns only self-references inside the file. The sibling warehouse pickers (ItemPicker, BatchPicker, SupplierSelect, LocationSelect) are all imported and used; this one is an orphan.
+- **fix:** Delete src/admin/components/warehouse/ReservationPicker.tsx, or wire it into the warehouse issue/reservation flow if it was intended for an unfinished feature.
+
+### 53. [LOW/safe-auto] Inconsistent Czech phrasing for "not found" (nenalezen vs nebyl nalezen)
+- **dimension:** i18n
+- **where:** src/routes/admin/project-files.ts:61; src/routes/admin/project-files.ts:73; src/routes/admin/project-files.ts:92; src/routes/admin/project-files.ts:112; src/routes/admin/project-files.ts:165; src/routes/admin/project-files.ts:228; src/routes/admin/project-files.ts:270; src/services/warehouse.service.ts:967; src/services/warehouse.service.ts:1021; src/routes/admin/projects.ts:56
+- **problem:** Two phrasings express the same concept. The dominant, compact form "nenalezen/nenalezena/nenalezeno" appears ~92 times across routes/services; the verbose form "nebyl/nebyla nalezen*" appears ~16 times (all of project-files.ts plus warehouse.service.ts reservations/inventory). The clash is sometimes on the identical entity: projects.ts:56 "Projekt nenalezen" vs project-files.ts:61 "Projekt nebyl nalezen"; warehouse routes "Kategorie nenalezena"/"Dodavatel nenalezen" vs warehouse.service.ts "Rezervace nebyla nalezena"/"Inventarizace nebyla nalezena". Same product, two wordings for the same 404.
+- **fix:** Standardize on the dominant compact form ("Projekt nenalezen", "Soubor nenalezen", "Složka nenalezena", "Rezervace nenalezena", "Inventarizace nenalezena"). Pure string normalization, no logic change. Low priority polish.
+
+### 54. [LOW/needs-judgment] parseBody leaks default English Zod messages for validators without a custom message
+- **dimension:** i18n
+- **where:** src/schemas/common.ts:12; src/schemas/received-invoices.schema.ts:27; src/schemas/attendance.schema.ts:25; src/schemas/leave-requests.schema.ts:4; src/schemas/offers.schema.ts:92; src/schemas/orders.schema.ts:93; src/schemas/attendance.schema.ts:8; src/schemas/plan.schema.ts:42; src/schemas/auth.schema.ts:26
+- **problem:** parseBody (common.ts:12) joins each Zod issue's .message and returns it directly as the user-facing API error. Fields with explicit Czech messages (e.g. .min(1, "Název je povinný")) are fine, but validators relying on Zod defaults emit English to the user: z.enum([...]) without a message -> "Invalid enum value..."; .max(500) on note/address -> "String must contain at most 500 character(s)"; TOTP secret/code .min(1) (auth.schema.ts:26-33) -> "String must contain at least 1 character(s)". Most are hard for a normal user to trigger (enum values come from dropdowns), but the length caps on free-text note/address fields are reachable.
+- **fix:** Add Czech messages to the user-reachable validators (the free-text .max() caps and any enum a user could submit), or centralize a Czech errorMap on the Zod instance so defaults are Czech globally. Lower urgency for purely internal enums/tokens. Treat as needs-judgment because deciding which validators are user-reachable requires per-field review.
+
+### 55. [LOW/safe-auto] Best-effort frontend background calls swallow errors silently with .catch(() => {})
+- **dimension:** logging
+- **where:** src/admin/pages/Attendance.tsx:235; src/admin/pages/Attendance.tsx:238; src/admin/pages/InvoiceDetail.tsx:778
+- **problem:** Three fire-and-forget calls (reverse-geocode address update, and post-save PDF pre-generation) use '.catch(() => {})' with no logging. These are genuinely non-critical side effects, so failing silently is a defensible product choice, but the empty handler gives no breadcrumb when the address never updates or the PDF isn't pre-generated. Unlike the rest of the frontend, which consistently surfaces errors via alert.error with a Czech fallback, these are invisible.
+- **fix:** Add a console.warn (or a debug-level log) in the catch so the swallow is intentional-and-traceable rather than fully silent, e.g. '.catch((e) => console.warn("address update failed", e))'. No user-facing alert is warranted for these background ops.
+
+### 56. [LOW/needs-judgment] plan.ts query factories use '*Query' suffix instead of the established '*Options' convention
+- **dimension:** naming
+- **where:** src/admin/lib/queries/plan.ts:98; src/admin/lib/queries/plan.ts:111
+- **problem:** Across src/admin/lib/queries, 55 query factories use the '*Options' suffix (userListOptions, orderListOptions, attendanceBalancesOptions, planCategoriesOptions, etc.). plan.ts itself uses planCategoriesOptions correctly, but then defines gridQuery (line 98) and usersQuery (line 111) with a '*Query' suffix instead. These are the only two '*Query'-suffixed factories in the whole queries directory, so the names read as a different kind of thing than they are (they are queryOptions() factories identical in shape to the *Options ones).
+- **fix:** Rename gridQuery -> planGridOptions and usersQuery -> planUsersOptions for parity with the surrounding 55 *Options factories. Both are used inside the same module/feature, so the blast radius is small.
+
+### 57. [LOW/needs-judgment] '.service.ts' suffix applied to only half of service files, with two borderline cases
+- **dimension:** naming
+- **where:** src/services/exchange-rates.ts; src/services/system-settings.ts; src/services/numbering.service.ts
+- **problem:** Of 20 files in src/services, exactly 10 carry the '.service.ts' suffix and 10 do not. The split largely tracks a sensible distinction (entity CRUD services like invoices.service.ts / users.service.ts are suffixed; infra/integration helpers like mailer.ts, audit.ts, nas-*-manager.ts, leave-notification.ts are not), so this is not pure randomness. However the boundary is fuzzy: exchange-rates.ts (exports getRate/toCzk) and system-settings.ts (exports getSystemSettings) are read/query services that resemble the suffixed group but lack the suffix, while numbering.service.ts is suffixed despite being a low-level sequence helper. A reader cannot rely on the suffix to predict a file's role.
+- **fix:** Document the intended rule in CLAUDE.md (e.g. '*.service.ts = entity business-logic services; bare name = infra/integration helpers') and rename the borderline files to match it, OR drop the suffix entirely since the directory name 'services/' already conveys the role. Either way pick one rule; do not leave it implicit.
+
+### 58. [LOW/needs-judgment] queryKey helper naming: only 'plan' defines a 'planKeys' key-factory object; other domains inline keys
+- **dimension:** naming
+- **where:** src/admin/lib/queries/plan.ts:87
+- **problem:** plan.ts introduces a dedicated planKeys object (all/grid/entries/overrides/users) to centralize query keys, which is a good pattern. But it is the only domain in src/admin/lib/queries that does so; every other module (users.ts, orders.ts, invoices.ts, trips.ts, etc.) inlines its queryKey arrays directly inside each *Options factory. The result is two coexisting key-management styles with no documented reason, so a contributor cannot tell which pattern to follow when adding a query.
+- **fix:** Decide on one approach: either adopt the planKeys-style key-factory object per domain (more robust against typos and easier invalidation), or treat planKeys as a deliberate exception and note it. Low priority — purely a consistency/discoverability issue, no behavior impact.
+
+### 59. [LOW/needs-judgment] getInvoiceStats walks the dataset four times with sequential per-invoice currency conversion
+- **dimension:** prisma
+- **where:** src/services/invoices.service.ts:247-256; src/services/invoices.service.ts:284-297
+- **problem:** sumCzk awaits toCzk per invoice in a loop, called three times plus a fourth VAT loop; serializes many awaits. Cached so it is await overhead not network.
+- **fix:** Pre-fetch the rate map once and convert synchronously, or resolve conversions together. Low priority.
+
+### 60. [LOW/needs-judgment] getBalances runs a sequential leave_balances query per user
+- **dimension:** prisma
+- **where:** src/services/attendance.service.ts:490-504; src/services/attendance.service.ts:944-946
+- **problem:** Loops users running leave_balances findFirst each; getPrintData already uses one findMany for the year then a map, so this is inconsistent.
+- **fix:** One findMany filtered by user_id in the list and year; index by user_id.
+
+### 61. [LOW/needs-judgment] enrich and body handlers typed as any, dropping Prisma types
+- **dimension:** prisma
+- **where:** src/services/orders.service.ts:41; src/services/offers.service.ts:49; src/services/invoices.service.ts:339; src/services/invoices.service.ts:391; src/services/projects.service.ts:95
+- **problem:** enrichOrder and enrichQuotation and several handlers take any, losing the types Prisma generates for includes and mapped rows; code otherwise strict TS, deriving a getPayload type at attendance.service.ts 23-28.
+- **fix:** Type enrich inputs with the Prisma getPayload helper; tighten bodies to existing input interfaces.
+
+### 62. [LOW/needs-judgment] Server data synced into form state via useEffect instead of derived/key-reset (accepted-but-not-ideal)
+- **dimension:** react
+- **where:** src/admin/pages/InvoiceDetail.tsx:371; src/admin/pages/InvoiceDetail.tsx:454; src/admin/pages/OfferDetail.tsx:383; src/admin/pages/OfferDetail.tsx:428; src/admin/pages/CompanySettings.tsx:256; src/admin/pages/Settings.tsx:213
+- **problem:** Multiple pages copy query data into local form state inside an effect, guarded by a one-shot ref/flag (formInitializedRef / dataReady / sysFormInitialized) or a derived-default merge (InvoiceDetail:371, OfferDetail:383 adjust currency/vat from companySettings on prop change). The React 'You Might Not Need an Effect' guidance explicitly lists 'adjusting/resetting state on prop change in an Effect' as an anti-pattern (causes an extra render with stale data) and recommends either computing during render or resetting state with a `key`. The flag-guarded population effects are the commonly-accepted compromise for editable forms seeded from server data, so this is low severity, but it is the same family of effect the docs steer away from.
+- **fix:** Where feasible, prefer resetting form state by giving the editor a key (e.g. key={id} on the detail component) so a fresh mount re-seeds state without an effect, and compute the companySettings-derived defaults (currency/vat) during render rather than patching state in an effect. Treat as cleanup, not a blocker — the current guards prevent the worst (re-clobbering user edits).
+
+### 63. [LOW/manual] 2FA/profile state prop-drilled through DashProfile (18 props)
+- **dimension:** react
+- **where:** src/admin/components/dashboard/DashProfile.tsx:10; src/admin/components/dashboard/DashProfile.tsx:40
+- **problem:** DashProfile receives 18 props, ~14 of which are a single concern: the 2FA enrollment/disable flow state and its setters (totpEnabled, totpLoading, totpSubmitting, totpSecret, totpQrUri, totpCode/setTotpCode, backupCodes/setBackupCodes, show2FASetup/setShow2FASetup, show2FADisable/setShow2FADisable, disableCode/setDisableCode, plus onStart/onConfirm/onDisable callbacks). This is all owned by the Dashboard parent and threaded down, making the component signature brittle and the 2FA logic split across two files.
+- **fix:** Encapsulate the 2FA flow in a dedicated hook (e.g. use2FA()) colocated with DashProfile, or move the 2FA modals fully into DashProfile and have it own that state. The parent then passes only totpEnabled (or nothing). This is a maintainability refinement, not a bug.
+
+### 64. [LOW/safe-auto] Minor: derived-state effect in ItemPicker resets activeIndex via useEffect
+- **dimension:** react
+- **where:** src/admin/components/warehouse/ItemPicker.tsx:48
+- **problem:** The effect at line 48 watches [items, activeIndex] to reset the highlighted index when the result list shrinks. This is derived state maintained through an effect (the docs' 'you might not need an effect' territory) and introduces an extra render cycle; it also lists activeIndex in deps while setting it, which is benign here only because of the guard. Low impact — the component otherwise correctly uses refs for keyboard nav.
+- **fix:** Clamp activeIndex during render (e.g. const safeActive = activeIndex >= items.length ? -1 : activeIndex) or reset it in the same handler that changes the search/items rather than reacting in an effect. Optional cleanup.
+
+### 65. [LOW/safe-auto] Inconsistent numeric ID parsing — raw parseInt instead of the parseId helper
+- **dimension:** routes
+- **where:** src/routes/admin/trips.ts:192-193; src/routes/admin/trips.ts:264-265; src/routes/admin/trips.ts:338-339; src/routes/admin/sessions.ts:84-85
+- **problem:** response.ts exports parseId(raw, reply) specifically to centralize param-id parsing (it rejects NaN and id<=0 with a uniform 400 'Neplatné ID'). Most files use it, but trips.ts and sessions.ts hand-roll `parseInt(...,10)` + `isNaN` checks. Besides the duplication, the hand-rolled versions accept id <= 0 (e.g. 0 or negative) because they only check isNaN, whereas parseId also rejects id <= 0 — a subtle behavioral divergence. Error messages also differ ('Neplatné ID vozidla', 'Neplatné ID relace' vs the helper's 'Neplatné ID').
+- **fix:** Replace the parseInt/isNaN blocks with `const id = parseId(request.params.id, reply); if (id === null) return;` (trips.ts uses request.params.id / vehicleId; keep a tailored message only if the distinct wording is desired). This unifies the <=0 rejection and the 400 response shape.
+
+### 66. [LOW/needs-judgment] Several not-found / precondition errors in project-files.ts fall back to default 400 instead of an explicit status
+- **dimension:** routes
+- **where:** src/routes/admin/project-files.ts:68; src/routes/admin/project-files.ts:69; src/routes/admin/project-files.ts:88; src/routes/admin/project-files.ts:114; src/routes/admin/project-files.ts:124; src/routes/admin/project-files.ts:138; src/routes/admin/project-files.ts:179; src/routes/admin/project-files.ts:202; src/routes/admin/project-files.ts:229; src/routes/admin/project-files.ts:241; src/routes/admin/project-files.ts:279
+- **problem:** Within the same file, downloadFile-not-found correctly returns error(reply, 'Soubor nebyl nalezen', 404) (line 73) and listFiles folder-not-found returns 404 (line 92), but the parallel 'Projekt nemá číslo projektu' (88,114,229), required-path messages (68,279), and fm-returned-error passthroughs (138,202,241) all omit the status and silently use the helper's default 400. Most are genuinely validation/precondition (400 is fine), but 'Projekt nemá číslo projektu' is a state precondition inconsistently a 400 here vs config-missing cases returning 500, and the omitted statuses make intent unclear. Consistency/clarity issue, not a correctness bug.
+- **fix:** Pass an explicit status to each error() call so the intended HTTP semantics are visible and uniform (400 for client validation, 404/409/422 for state preconditions as appropriate). At minimum make the 'Projekt nemá číslo projektu' and required-path messages explicit rather than relying on the default.
+
+### 67. [LOW/safe-auto] Redundant non-null assertions on parseBody result (body.data!) in plan.ts
+- **dimension:** routes
+- **where:** src/routes/admin/plan.ts:170; src/routes/admin/plan.ts:194; src/routes/admin/plan.ts:238-242; src/routes/admin/plan.ts:267; src/routes/admin/plan.ts:319; src/routes/admin/plan.ts:361
+- **problem:** plan.ts writes `const body = parseBody(...); if ('error' in body) return ...; ... body.data!`. parseBody returns `{ data: T } | { error: string }` (schemas/common.ts:3-6), so after the `'error' in body` guard TypeScript already narrows body to `{ data: T }` and `.data` is non-null. Every other route file uses the cleaner `const parsed = parseBody(...); if ('error' in parsed) return ...; const body = parsed.data;` form without the `!`. The `!` is harmless but inconsistent and signals (falsely) that data might be nullable.
+- **fix:** Adopt the prevailing pattern: bind `const parsed = parseBody(...)`, guard, then `const body = parsed.data` without the non-null assertion, matching customers.ts / warehouse.ts / attendance.ts.
+
+### 68. [LOW/needs-judgment] usePlanWork re-implements the gridQuery factory inline with untyped apiFetch parsing
+- **dimension:** rq-data
+- **where:** src/admin/hooks/usePlanWork.ts:87; src/admin/lib/queries/plan.ts:98
+- **problem:** plan.ts exports a `gridQuery(dateFrom,dateTo,view)` queryOptions factory (line 98) that uses jsonQuery for type-safe envelope handling. usePlanWork.ts:87 ignores it and inlines its own useQuery with `apiFetch(...).then(r=>r.json().then((b:any)=>b.data as GridData))` — duplicating the URL construction, skipping jsonQuery's `!response.ok || !result.success` error check (so a backend `{success:false}` silently yields undefined data instead of throwing), and using `b:any`. The planKeys factory is reused for the key, but the queryFn diverges. This is the only place a query bypasses the apiAdapter.
+- **fix:** Replace the inline useQuery body with the existing gridQuery factory: `useQuery({ ...gridQuery(isoDate(range.from), isoDate(range.to), view), placeholderData: keepPreviousData })`. Restores jsonQuery's error handling and removes the `any`. Low risk but should be eyeballed since the hook also reads this query's cache key elsewhere (key is unchanged, so cache stays compatible).
+
+### 69. [LOW/safe-auto] Duplicate require2FAOptions factory (dead copy in dashboard.ts) and unused deprecated systemSettingsOptions alias
+- **dimension:** rq-data
+- **where:** src/admin/lib/queries/dashboard.ts:12; src/admin/lib/queries/settings.ts:82; src/admin/lib/queries/settings.ts:80
+- **problem:** `require2FAOptions` is defined identically in two query files — dashboard.ts:12 and settings.ts:82 — both with the same key `["settings","2fa"]` and same URL. Both consumers (Dashboard.tsx:10, Settings.tsx:14) import the settings.ts copy, so the dashboard.ts copy is dead code. Because keys match it is not a cache-collision bug, but two sources of truth for one query is a maintenance hazard (edit one, miss the other). Separately, settings.ts:80 exports a `/** @deprecated */ systemSettingsOptions = systemInfoOptions` alias that has no importers (only systemInfoOptions is used).
+- **fix:** Delete the duplicate require2FAOptions from dashboard.ts:12 (keep the settings.ts canonical one). Delete the unused deprecated systemSettingsOptions alias at settings.ts:80. Both are confirmed unreferenced by grep, so removal is mechanical and behavior-free.
+
+### 70. [LOW/needs-judgment] Inconsistent filter-key serialization across list query factories
+- **dimension:** rq-data
+- **where:** src/admin/lib/queries/invoices.ts:129; src/admin/lib/queries/invoices.ts:157; src/admin/lib/queries/trips.ts:40; src/admin/lib/queries/warehouse.ts:194; src/admin/lib/queries/projects.ts:45
+- **problem:** List factories are inconsistent in how the filters object becomes part of the query key. Most spread the raw filters object directly (invoices.ts:129 `["invoices","list",filters]`; warehouse, projects, offers, audit-log do the same), but trips.ts:40 and invoices received (invoices.ts:157) instead spell out an explicit object literal of the same fields. Functionally both work (React Query hashes the key deterministically and ignores object key order), but the inconsistency is gratuitous: the explicit form silently drops any new filter field that isn't manually listed, whereas the spread form auto-includes it. Not a bug today, just a divergence and a latent footgun for the spelled-out ones.
+- **fix:** Pick one convention (spreading the filters object is the simpler, more future-proof choice) and apply it uniformly. Low priority — cosmetic consistency, no behavior change. Verify each endpoint's filters object contains only key-relevant fields before spreading.
+
+### 71. [LOW/needs-judgment] Wrong TOTP code does not increment failed-login attempts (relies on rate limit only)
+- **dimension:** security
+- **where:** src/routes/admin/auth.ts:160-163; src/services/system-settings.ts
+- **problem:** In the /login/totp handler, an invalid TOTP code returns 'Neplatný TOTP kód' (line 161-163) without touching failed_login_attempts or locked_until. The password stage (auth.ts service) and the backup-code endpoint (totp.ts:315-335) both implement lockout, but the primary TOTP step does not. The only protection against TOTP brute force after a valid login_token is the per-minute rate limit (config.rateLimit.loginTotp, default 5/min). The login_token lives 5 minutes and a fresh one can be re-minted by re-submitting the password, so a determined attacker who has the password gets a sustained ~5 guesses/minute against a 6-digit (1M) space without ever tripping account lockout.
+- **fix:** On invalid TOTP code in /login/totp, increment failed_login_attempts inside a transaction and set locked_until when it reaches max_login_attempts, mirroring the backup-verify logic. This makes the second factor enforce the same lockout policy as the first.
+
+### 72. [LOW/needs-judgment] Server-side PDF endpoints return attacker-influenceable HTML as same-origin text/html
+- **dimension:** security
+- **where:** src/routes/admin/invoices-pdf.ts:1076; src/routes/admin/offers-pdf.ts:764; src/routes/admin/invoices-pdf.ts:521; src/routes/admin/offers-pdf.ts:357
+- **problem:** In non-save mode, the invoices-pdf and offers-pdf routes render the full document HTML and send it with reply.type('text/html').send(html) directly to the browser (not application/pdf). The notes/scope content is user-controlled rich text. It IS sanitized (DOMPurify.sanitize wrapped by cleanQuillHtml), so this is defense-in-depth rather than an open hole, but serving same-origin HTML built from user data is fragile: any future regression in the sanitizer, or any unescaped interpolation added to these large templates, becomes stored XSS on the app origin. The production CSP (security.ts:24-36) does mitigate inline script, but these PDF responses are served by the same Fastify app and inherit that origin.
+- **fix:** Prefer returning application/pdf (render via htmlToPdf) to the client instead of raw HTML, as the orders-pdf route already does (orders-pdf.ts:889-892). If an HTML preview must be served, consider a Content-Disposition or a sandboxed delivery path. At minimum, keep the DOMPurify + cleanQuillHtml layering and avoid adding any non-escapeHtml interpolation of DB/user fields into these templates.
+
+### 73. [LOW/needs-judgment] Scope/notes rich-text is sanitized only at render time, never on write
+- **dimension:** security
+- **where:** src/routes/admin/invoices-pdf.ts:521; src/routes/admin/orders-pdf.ts:409; src/admin/pages/InvoiceDetail.tsx:1206; src/admin/pages/OrderDetail.tsx:687
+- **problem:** Invoice notes and quotation/order scope HTML are stored raw in the DB and sanitized at every read/render site (3 PDF routes + 2 frontend render sites). This is consistent and currently complete, but it means correctness depends on every present and future consumer remembering to sanitize. Any new feature that renders these fields (e.g. an email body, a new export, an admin preview) without DOMPurify reintroduces stored XSS. Sanitizing on write would make the stored value safe-by-default for all consumers.
+- **fix:** Consider sanitizing rich-text scope/notes once on the write path (in the quotations/orders/invoices create+update services or Zod transforms) so stored data is already clean, treating render-time sanitization as defense-in-depth. Not urgent given current coverage is complete; flagging so the invariant is documented and enforced centrally.
+
+### 74. [LOW/needs-judgment] HSTS header omits preload and is gated entirely on APP_ENV=production
+- **dimension:** security
+- **where:** src/middleware/security.ts:18-22; src/middleware/security.ts:23-36
+- **problem:** Strict-Transport-Security is set to 'max-age=31536000; includeSubDomains' without 'preload', and both HSTS and the full CSP are only emitted when config.isProduction (APP_ENV==='production'). The max-age/includeSubDomains is good, but without preload the first-visit TLS-strip window remains. More notably, all CSP protection is absent in any non-production deployment (e.g. a staging box on real DNS), so the same app code runs without script-src/frame-ancestors restrictions there.
+- **fix:** Add 'preload' to the HSTS value if the domain is (or will be) submitted to the preload list. Consider emitting at least a baseline CSP and the security headers in non-local non-production environments too, so staging/preprod is not left unprotected.
+
+### 75. [LOW/needs-judgment] TOTP backup-code comparison does not stop at first match (minor timing/clarity)
+- **dimension:** security
+- **where:** src/routes/admin/totp.ts:307-312
+- **problem:** The backup-code verify loop runs bcrypt.compare against every stored hash and records matchIndex but never breaks, so it always performs all 8 comparisons. This is intentional-looking constant-time behavior, but the loop also lets a later match overwrite an earlier one and does an unnecessary 8x bcrypt work per attempt. With bcryptCost=12, 8 comparisons per submission under a 5/min rate limit is a small but real CPU amplification, and the non-break pattern is easy to misread. The CLAUDE.md error-handling rule is not violated here; this is a robustness nit.
+- **fix:** Either document that the full-scan is deliberate constant-time, or break on first match. Given backup codes are unique random values, breaking on match is safe and reduces bcrypt load; combine with the existing lockout to keep brute-force resistance.
+
+### 76. [LOW/safe-auto] Error-message language mixed Czech/English within schemas
+- **dimension:** schemas
+- **where:** src/schemas/orders.schema.ts:9,16,26,37,45,71,81,106; src/schemas/offers.schema.ts:9,16,26,37,56,87; src/schemas/invoices.schema.ts:10,19 (thrown Error strings)
+- **problem:** Project convention is Czech for user-facing messages. Most schemas follow this, but orders.schema.ts and offers.schema.ts mix English `{ message: 'Must be a valid number' }` (orders:9,16,26,37,71,81,106; offers:9,16,26,37,56,87) alongside Czech `'Musí být platné číslo'` (orders:55,60; offers:47) in the SAME file for the same validation. parseBody surfaces issue.message directly to the client (common.ts:12), so end users can see the English strings. invoices.schema.ts:10,19 also throw English `'Invalid quantity'`/`'Invalid unit_price'`.
+- **fix:** Translate the English validation messages to Czech to match the rest of the codebase (e.g. 'Musí být platné číslo'). If a shared numeric helper is introduced (finding 1), it can carry one canonical Czech message and eliminate the divergence.
+
+### 77. [LOW/safe-auto] Inline route schemas use Zod-3-deprecated .strict()/.passthrough()
+- **dimension:** schemas
+- **where:** src/routes/admin/audit-log.ts:15-20; .strict(); src/routes/admin/orders-pdf.ts:15-30; .passthrough()
+- **problem:** The only two object-mode modifiers in the codebase use the Zod 3 forms `.strict()` (audit-log.ts:20) and `.passthrough()` (orders-pdf.ts:30). Per the Zod 4 changelog (the project is on zod ^4.3.6) these are deprecated in favor of the top-level `z.strictObject({...})` and `z.looseObject({...})`. They still work but are flagged for removal and are inconsistent with a Zod-4 codebase.
+- **fix:** Migrate to `z.strictObject({ days: ..., confirm: ... })` and `z.looseObject({ items: ... })` respectively. Mechanical, no behavior change.
+
+### 78. [LOW/needs-judgment] No schemas use strict object mode — unknown keys silently stripped everywhere
+- **dimension:** schemas
+- **where:** src/schemas/common.ts:8 (schema.parse, default strip); all src/schemas/*.schema.ts object definitions
+- **problem:** Every body schema is a default `z.object(...)`, which strips unknown keys silently. Only audit-log.ts uses `.strict()`. For mutation endpoints this means a client typo (e.g. `is_activ` instead of `is_active`) is silently dropped rather than rejected, masking client bugs. This is a deliberate-tradeoff call (lenient APIs are sometimes intentional), so flagged as low, but the near-total absence of strictness combined with the broad Create/Update surface is worth a conscious decision rather than an accident.
+- **fix:** Decide on a policy: either keep lenient (document it) or adopt `z.strictObject` for Create/Update bodies to catch client mistakes early. If adopting strict, do it via the shared pattern so it is uniform; watch for fields the frontend sends but the schema omits (audit before flipping).
+
+### 79. [LOW/needs-judgment] Duplicated ScopeSection/Item sub-schemas across offers/orders/scope-templates
+- **dimension:** schemas
+- **where:** src/schemas/offers.schema.ts:3-39 (QuotationItemSchema, ScopeSectionSchema); src/schemas/orders.schema.ts:3-39 (OrderItemSchema, OrderSectionSchema); src/schemas/scope-templates.schema.ts:3-11 (ScopeSectionSchema); src/schemas/invoices.schema.ts:3-34 (InvoiceItemSchema)
+- **problem:** QuotationItemSchema (offers:3-28) and OrderItemSchema (orders:3-28) are byte-for-byte identical. OrderSectionSchema (orders:30-39), ScopeSectionSchema (offers:30-39) and ScopeSectionSchema (scope-templates:3-11) are the same shape (title/title_cz/content/position) duplicated three times. These line/quote/section structures are the same domain concept reimplemented per file, so a change to e.g. position validation must be made in 3-4 places.
+- **fix:** Extract a shared `LineItemSchema` and `ScopeSectionSchema` (e.g. in common.ts or a schemas/shared.ts) and import them. Note invoices.schema.ts:6-12 deliberately diverges (throws on quantity<=0), so keep that one specialized or make the shared schema configurable.
+
+### 80. [LOW/needs-judgment] buildApp and token helpers duplicated instead of reusing helpers.ts
+- **dimension:** tests part2b
+- **where:** src/__tests__/helpers.ts:7; src/__tests__/warehouse.test.ts:26; src/__tests__/plan.test.ts:649
+- **problem:** helpers.ts exports buildApp for auth and totp routes only. warehouse.test.ts and plan.test.ts each redefine their own buildApp, generateToken, and auth header helpers with slightly different setup, since warehouse adds securityHeaders and helpers does not. The token-minting and app-bootstrap logic now lives in three places and will drift, and a token minted wrong in one file is not caught by the others.
+- **fix:** Extract a shared buildApp that takes routes and options plus one token and auth-header helper into helpers.ts and import everywhere, then re-run the full suite since it changes test bootstrap.
+
+### 81. [LOW/safe-auto] Test named soft delete actually asserts a hard delete that its own comment flags as buggy
+- **dimension:** tests part2b
+- **where:** src/__tests__/warehouse.test.ts:630; src/__tests__/warehouse.test.ts:637
+- **problem:** The test titled deactivates an item soft delete asserts a 404 after delete. Its own comment at lines 636 to 638 notes that the route hard-deletes the row and calls this inconsistent with the supplier soft-delete pattern, so the misleading name hides the discrepancy from future readers.
+- **fix:** Rename the test to hard-deletes an item, and if soft-delete is the intended contract convert it to a todo or expected-fail test documenting the desired behaviour. Confirm the decision with the team.
+
+### 82. [LOW/needs-judgment] `Record` / untyped record builders in attendance PDF/HTML construction
+- **dimension:** ts-safety
+- **where:** src/admin/hooks/useAttendanceAdmin.ts:350; src/admin/hooks/useAttendanceAdmin.ts:382; src/admin/hooks/useAttendanceAdmin.ts:397; src/admin/hooks/useAttendanceAdmin.ts:598; src/services/offers.service.ts:49; src/services/orders.service.ts:41
+- **problem:** Several HTML/PDF/total-calculation builders take `Record` and iterate with `(log: any)`/`(i: any)` (useAttendanceAdmin buildUserSectionHtml/recordsByDate; offers `enrichQuotation`, orders `enrichOrder`). These produce HTML that is interpolated into PDF/print output, so an `any`-typed field that is unexpectedly an object or undefined can render `[object Object]` or `NaN` totals with no compile-time signal. Lower severity than the auth bug because output is internal documents, but it is the broadest untyped surface after the plan stack.
+- **fix:** Define narrow input interfaces for the attendance record and quotation/order item shapes (the Prisma row types or the existing schemas can be reused) and replace the `Record` parameters. At minimum type the `.map`/`.reduce` callbacks (`i`, `log`) with the item type to catch field-name and numeric-coercion mistakes.
+
+### 83. [LOW/safe-auto] `category: input.category as any` casts bypass the Prisma category column type
+- **dimension:** ts-safety
+- **where:** src/services/plan.service.ts:451; src/services/plan.service.ts:518; src/services/plan.service.ts:639; src/services/plan.service.ts:710
+- **problem:** Four `create`/`update` calls cast `input.category` (a plain `string`) to `any` to satisfy the Prisma `category` column type. Validity is enforced at runtime via `assertActiveCategory` (a DB lookup), so it's not unsafe in practice, but the `as any` discards the compiler's check and would also silence a future schema type change for this column.
+- **fix:** If the Prisma column is an enum, cast to that specific enum type (e.g. `Prisma.$Enums.`) instead of `any`; if it is a `String` column, the cast is unnecessary and can be removed. Either way avoid `as any` so the field stays type-checked.
+
+### 84. [LOW/manual] `request.authData!` non-null assertions: acceptable post-auth pattern (no action)
+- **dimension:** ts-safety
+- **where:** src/routes/admin/attendance.ts:30; src/middleware/auth.ts:44; src/routes/admin/plan.ts:119; src/routes/admin/warehouse.ts:1066; src/routes/admin/quotations.ts:139
+- **problem:** Non-null assertions are widespread but almost entirely `request.authData!` inside handlers guarded by `requirePermission`/`requireAuth`, plus `result.error!`/`result.status!` after an `"error" in result` narrowing. These are correct given the middleware contract; the Fastify `authData` is declared optional on the request type but is always populated after the auth preHandler. Flagging only to note it was reviewed and is intentional, not a defect.
+- **fix:** No change required. If you want to eliminate the `authData!` assertions, declare an `AuthenticatedRequest` type (or a module augmentation making `authData` required) for routes mounted behind the auth hook, so the `!` becomes unnecessary. Optional, cosmetic.
+