# Audit Findings — boha-app-ts

Full file-by-file audit, 2026-06-09/10. Method: 45 independent review agents (every file read in full), plus a project-level architecture review and a dependency review; every CRITICAL/HIGH per-file finding was then re-examined by an independent adversarial verifier before being accepted into this report.

## Summary

**Coverage:** 304/304 in-scope files reviewed (see `REVIEW.md`); 130 files clean, 174 with findings.

**Per-file findings:** 0 CRITICAL, **19 HIGH**, 161 MEDIUM, 247 LOW. All 19 HIGH findings were independently verified as real (5 further candidates were downgraded during verification; none of the verified findings were refuted).

**Project-level findings:** 1 CRITICAL, 2 HIGH, 13 MEDIUM, 17 LOW (details in PROJECT-LEVEL below).

**Project structure** — Structurally healthy: clean Fastify request pipeline, strict TypeScript across all tsconfig projects, solid ESLint/Prettier setup, exemplary `.env.example` and secret hygiene, and a real-DB Supertest suite. The main weaknesses are an incompletely applied service layer (trips/customers/received-invoices/leave-requests/quotations/warehouse keep Prisma + business logic in route files; `warehouse.ts` is 2,565 lines), ~3,700 lines of copy-pasted PDF template code across four `*-pdf.ts` route files, a fragile tsconfig-carved frontend/backend split inside one `src/` tree, naming drift (camelCase vs kebab-case, quotations-vs-offers), zero component tests for a 114-file React frontend, and documentation drift in README.md/CLAUDE.md. Consolidation work, not rescue work.

**Dependencies** — Healthy and modern (Fastify 5, Prisma 7, React 19, Vite 8, Zod 4). The 2 critical npm-audit vulnerabilities come entirely from `concurrently`, an **unused** devDependency — deleting it clears them. The 3 moderates are one Prisma-CLI-tooling chain best fixed with an npm `overrides` pin of `@hono/node-server@^1.19.13` (do NOT take npm's suggested Prisma 6 downgrade). The quill XSS advisory is already mitigated by the existing DOMPurify/cleanQuillHtml discipline. Two deprecated `@types` stubs (`@types/dompurify`, `@types/bcryptjs`) should be removed; ~19 packages need only safe minor/patch bumps. Strategic majors to plan deliberately: MUI 7→9, react-router 6→7, puppeteer 25 (ESM-only — blocked on a CJS→ESM server move), TS 6.0 (wait for toolchain).

### Top 5 issues to address first

1. **Attendance creation/editing is broken since commit 519edce (2026-06-09)** — `src/admin/pages/AttendanceCreate.tsx` (80-99): every submit 400s because always-sent empty time strings fail the new `timeString` validation, breaks/overnight fields are silently stripped, and validated "HH:MM" values crash the service (`new Date('08:00')` → Invalid Date → 500). Same schema/client/service contradiction breaks the AttendanceAdmin create/edit modals (407-442). A core daily workflow is non-functional.
2. **Backup-code 2FA login is broken — lockout risk** — `src/admin/context/AuthContext.tsx` (263-272): backup codes are posted to `/login/totp` as `totp_code`, which the 6-digit schema always rejects; the real `POST /totp/backup-verify` endpoint is never called from the frontend. Any user who loses their authenticator is locked out. Related: the 2FA enrollment QR (`DashProfile.tsx:556`, api.qrserver.com) is blocked by the production CSP, and `Dashboard.tsx` (89-91) shows the company-wide 2FA _policy_ flag as the user's personal enrollment status.
3. **Silent invoice data corruption on re-save** — `src/admin/pages/InvoiceDetail.tsx` (728): edit-mode hydration overwrites per-line VAT rates with the invoice-level rate (and `|| 21` turns legitimate 0% into 21%) — re-saving a mixed-rate invoice corrupts stored VAT data; `billing_text` edits are silently dropped because `UpdateInvoiceSchema` lacks the field. Same falsy-zero hydration bug in `IssuedOrderDetail.tsx` (606-611).
4. **Trips lists/totals silently truncated at 25 rows** — `src/admin/lib/queries/trips.ts` (52-63, 95-103): the paginated response's meta is discarded, so Trips/TripsAdmin/TripsHistory compute km and business-trip totals from at most 25/100 rows with no pagination UI — wrong business numbers once data grows. Same class of bug: `orders.service.ts` ships the full PDF `attachment_data` blob inside every list/detail/totals JSON response (severe payload bloat).
5. **`concurrently` devDependency (unused) carries both CRITICAL npm-audit vulnerabilities** (shell-quote command injection, CVSS 8.1) — remove the package, zero migration risk; then pin `@hono/node-server@^1.19.13` via `overrides` to clear the moderates.

## PROJECT-LEVEL

### Architecture & structure

Overall, boha-app-ts is a structurally healthy, above-hobbyist-grade codebase that mostly lives up to its own documented conventions: a clean Fastify request pipeline (CORS → cookies → rate-limit → security headers → permission guard → Zod → service → response helpers), strict TypeScript across all three tsconfig projects, a solid flat ESLint config with rules-of-hooks as an error, Prettier + format-on-save hooks, an exemplary .env.example, genuinely good secret hygiene (no env file ever committed, placeholder-only template, hard-throw on missing required vars), and a 25-file Supertest suite against a real isolated test DB. The main architectural weaknesses are (1) an incompletely-applied service layer — roughly half the entities (trips, customers, leave-requests, received-invoices, quotations, most warehouse CRUD) keep Prisma queries and business logic directly in route files, with warehouse.ts at 2,565 lines/46 endpoints and four ~800–1,100-line PDF route files containing copy-pasted inline HTML templates and helpers; (2) a single src/ tree shared by server and SPA that is carved up by fragile tsconfig include/exclude lists, leaving stragglers like src/context/ThemeContext.tsx, App.tsx and main.tsx outside the frontend's logical home; and (3) naming drift (camelCase vs kebab-case, .service suffix vs none, quotations-vs-offers domain split) plus documentation rot in README.md and a few spots in CLAUDE.md. Nothing found is critical; the recommendations below are consolidation work, not rescue work.

- [HIGH] **Separation of concerns — service layer** — The documented 'services own Prisma, routes stay thin' pattern is applied to only ~half the entities: trips.ts (20 prisma calls), customers.ts (12), received-invoices.ts (15), leave-requests.ts, quotations.ts and warehouse.ts (79 prisma calls, 46 endpoints, 2,565 lines) all run Prisma queries and business decisions directly in route handlers, while attendance/plan/orders/invoices have proper \*.service.ts files.
  - Recommendation: Incrementally extract services for the route-resident entities (start with warehouse CRUD and received-invoices since they carry the most logic), and split src/routes/admin/warehouse.ts into a folder of sub-route files (items, receipts, issues, reservations, inventory) registered under the same prefix. Do it opportunistically per the existing 'convert when you touch them' rule, but track it so it converges.
- [HIGH] **Code duplication — PDF routes** — invoices-pdf.ts (1,097), orders-pdf.ts (917), issued-orders-pdf.ts (880) and offers-pdf.ts (784 lines) each embed a full inline HTML template plus four copy-pasted helper sets (formatNum, formatDate, cleanQuillHtml, a per-file JSDOM+DOMPurify instance) — ~3,700 lines of near-parallel presentation code living in the routes layer, where a sanitization or layout fix must be applied four times.
  - Recommendation: Extract a shared src/pdf/ (or src/services/pdf/) module: one sanitize+cleanQuillHtml pipeline, one number/date formatter set, one base document template with per-document sections. The PDF routes then shrink to data-fetch + template-call, matching the thin-route convention.
- [MEDIUM] **Project layout — frontend/backend split** — Server and SPA share one src/ tree carved up by tsconfig include/exclude lists: tsconfig.server.json includes src/\*_/_ then excludes src/admin, src/context, App.tsx, main.tsx, **tests** — so any new frontend folder created outside src/admin silently lands in the server build. Symptoms already exist: src/context/ThemeContext.tsx lives outside src/admin/context (where AuthContext/AlertContext live), and the SPA entry files App.tsx/main.tsx sit at src/ root next to server.ts.
  - Recommendation: Move ThemeContext.tsx into src/admin/context (it is only excluded from the server build, nothing server-side imports it), relocate App.tsx/main.tsx under src/admin (adjust index.html entry), and flip tsconfig.server.json from an exclude-list to an explicit include of server directories (config, routes, services, schemas, middleware, utils, types, server.ts). That makes the boundary structural instead of memorized.
- [MEDIUM] **Consistency — file naming** — Mixed naming conventions in the same directories: planCategory.service.ts / planCategory.schema.ts (camelCase) vs every sibling in kebab-case; services split between suffixed (warehouse.service.ts) and unsuffixed (auth.ts, audit.ts, mailer.ts, exchange-rates.ts, nas-\*.ts, system-settings.ts); src/admin/lib/queries has auditLog.ts beside issued-orders.ts; src/utils has planAuditDescription.ts beside czech-holidays.ts; and the offers domain is named 'quotations' only in routes/admin/quotations.ts (registered at /api/admin/offers, with offers.service.ts, offers.schema.ts, Offers.tsx everywhere else).
  - Recommendation: Standardize on kebab-case files with .service.ts/.schema.ts suffixes for entity modules (rename planCategory._ → plan-category._, auditLog.ts → audit-log.ts, planAuditDescription.ts → plan-audit-description.ts, quotations.ts → offers.ts), and either suffix the bare services or document that unsuffixed files are infrastructure helpers, not entity services. Pure renames — git tracks them, low risk, one PR.
- [MEDIUM] **Tooling — package.json contradicts migration policy** — package.json ships db:push (prisma db push) and db:pull scripts even though CLAUDE.md's golden rule is 'NEVER use prisma db push' — a one-keystroke footgun that bypasses migration history, and there are no db:migrate/db:deploy scripts for the blessed path.
  - Recommendation: Remove db:push (and db:pull, or rename it db:pull:DANGEROUS), and add scripts for the sanctioned workflow instead: db:migrate → prisma migrate dev, db:deploy → prisma migrate deploy, keeping db:generate/db:studio.
- [MEDIUM] **Testing — zero component tests for a 114-file React frontend** — All 25 test files in src/**tests** are server-side API tests (good breadth there); the only frontend test is a theme-token unit test (src/admin/theme.test.ts). Pages of 800–2,300 lines (InvoiceDetail.tsx 2,278; OfferDetail.tsx 2,012; CompanySettings.tsx 1,542) carry untested form/validation/permission logic, and the single node-environment vitest.config.ts cannot host jsdom component tests.
  - Recommendation: Adopt Vitest projects (workspace) config: the existing node+real-DB project unchanged, plus a jsdom project with @testing-library/react for src/admin/\*_/_.test.tsx. Start with the shared kit (DataTable, Select, ConfirmDialog) and the most logic-heavy pages; even a handful of tests around the detail-page save flows would cover the riskiest untested surface.
- [MEDIUM] **Frontend structure — monolithic page components** — Detail/settings pages routinely exceed 1,000 lines (InvoiceDetail 2,278; OfferDetail 2,012; CompanySettings 1,542; IssuedOrderDetail 1,465; Settings 1,356; ReceivedInvoices 1,316) because each page owns its forms, modals, line-item editors and mutations inline, while src/admin/components only holds a small set of shared pieces.
  - Recommendation: When touching these pages, split per-page subcomponents into feature folders (e.g. src/admin/pages/invoices/{InvoiceDetail.tsx, InvoiceItemsEditor.tsx, InvoicePaymentsPanel.tsx}) keeping data logic in the parent. This is the natural prerequisite for the component-testing recommendation and reduces merge conflicts in the busiest files.
- [MEDIUM] **Documentation accuracy — README.md and CLAUDE.md drift** — README.md claims 'Prisma 6' and 'React 18' (actual: Prisma 7.8.0, React 19.2.7) and points to 'eslint.config.js' (actual: eslint.config.mjs); CLAUDE.md says logAudit lives in src/utils/audit.ts (actual: src/services/audit.ts), lists utils as 'totp.ts, pdf.ts, email.ts, audit.ts' (actual: html-to-pdf.ts in utils, mailer.ts in services, no email.ts/pdf.ts/audit.ts), says 'admin/ # React 18' in the structure tree while its own tech-stack table says React 19, and cites '17 files'/'18 files / 247 tests' (actual: 25 files in src/**tests** plus theme.test.ts).
  - Recommendation: One doc-sync pass: fix README versions and the eslint filename, correct CLAUDE.md's audit.ts path, utils listing, React-18 remnant and test counts. These docs are actively used as ground truth by tooling and agents, so drift here propagates into wrong code.
- [LOW] **Repo hygiene — tracked .claude/settings.local.json** — .claude/settings.local.json is committed (git ls-files confirms) even though .gitignore lists it — it was tracked before the ignore rule was added, so ignore no longer applies and future machine-local permission edits will keep landing in commits. Current content is harmless (bash permission allowlists, no secrets); .claude/settings.json (hooks only) is fine to track.
  - Recommendation: Run git rm --cached .claude/settings.local.json and commit; the existing .gitignore entry then takes effect. Keep settings.json tracked since the format-on-save and block-env hooks are useful shared config.
- [LOW] **Repo hygiene — root directory clutter** — The repo root carries six release tarballs (app-ts-2.0.0…2.2.0.tar.gz, ~1 MB each), scratch outputs (npm-audit.json, npm-outdated.json, review_scope.txt, review_batches.json, frontend_Design_flaws.md, simon/plan_boha.pdf), and historical review reports (REVIEW\*.md, currently staged for deletion). All are gitignored or being removed, but they obscure the real project files and risk accidental tarball-of-a-tarball in releases.
  - Recommendation: Move release archives to a gitignored releases/ folder (and update the release-process docs), delete or relocate one-off scan outputs to docs/ or a scratch dir, and finish committing the REVIEW\*.md deletions.
- [LOW] **Dead configuration — unused path alias and stale ESLint ignore** — All three tsconfigs define the '@/_' → 'src/_' path alias but zero source files use it, and Vite has no matching resolve.alias so it could never work in the frontend anyway; eslint.config.mjs also ignores 'src/admin/bones/\*_' which no longer exists, and the blanket '_.config.ts' ignore leaves vite.config.ts/vitest.config.ts/prisma.config.ts unlinted.
  - Recommendation: Either wire the alias properly (add resolve.alias to vite.config.ts and start using it for deep imports) or delete the paths blocks from all three tsconfigs; drop the bones ignore; consider linting the config files via the typescript-eslint node block.
- [LOW] **Server module system — CJS forces an eval-import hack** — The server compiles to CommonJS, so src/server.ts loads ESM-only Vite via Function('return import("vite")')() — an eval-style escape hatch that defeats type checking and bundler analysis, and the same CJS choice drives 'as any' casting around the Vite middleware and the documented CJS/ESM test friction.
  - Recommendation: Plan a server migration to ESM (module: nodenext, "type": "module" or .mts), which removes the eval hack, aligns with Fastify 5/Vitest defaults, and future-proofs against the growing set of ESM-only dependencies. Not urgent — it only bites in dev — but worth scheduling before more dynamic-import workarounds accumulate.
- [LOW] **Environment validation — manual parsing instead of schema** — src/config/env.ts hand-rolls required()/parseInt with per-field fallbacks; the project already depends on Zod 4 but does not use it for env validation, so a typo like ACCESS_TOKEN_EXPIRY=15m silently becomes NaN→fallback rather than a boot error (a few fields like MAX_UPLOAD_SIZE guard this individually, most do not). The .env.example itself is excellent and complete vs env.ts.
  - Recommendation: Define a Zod schema for the whole env (z.coerce.number() with bounds, enums for APP_ENV) and parse once at boot — same fail-fast behavior the codebase already mandates for request bodies, and it keeps .env.example verifiably in sync with one source of truth.
- [LOW] **Test placement — theme.test.ts outside the test tree** — src/admin/theme.test.ts is the only test colocated with source; every other test lives in src/**tests** per the documented convention, and it is type-checked by tsconfig.app.json rather than tsconfig.test.json, so it follows different compiler settings than the rest of the suite.
  - Recommendation: Either move it to src/**tests**/theme.test.ts for consistency, or — if adopting the component-testing recommendation — keep colocation as the new convention for frontend tests and document that split (server tests in **tests**, frontend tests colocated).

### Dependencies

Overall dependency health is good: the stack is modern (Fastify 5, Prisma 7, React 19, Vite 8, Zod 4, Vitest 4) and ~19 of the 26 outdated packages are trivial minor/patch bumps clearable in one npm update pass. The npm audit total of 7 vulnerabilities (2 critical, 3 moderate, 2 low) is far less scary than it looks: both criticals come from `concurrently`, an UNUSED devDependency (no npm script references it — delete it and they vanish); the 3 moderates are one chain (prisma 7.x → @prisma/dev pinning vulnerable @hono/node-server) whose npm-suggested "fix" is a Prisma 6 downgrade you must NOT take — use an npm `overrides` entry for `@hono/node-server@^1.19.13` instead; the lows are an unpatched-upstream quill XSS already mitigated by the codebase's DOMPurify/cleanQuillHtml sanitization. Verified maintenance status (registry + web, June 2026): bcryptjs, node-cron, otpauth, nodemailer, puppeteer, react-quill-new and dnd-kit are all actively maintained — the only genuinely dormant package is the transitive quill core editor (last publish Jan 2025, open CVE), worth watching under its maintained react-quill-new wrapper. Two officially deprecated `@types` stubs (@types/dompurify, @types/bcryptjs) should be removed. Strategic upgrades to triage, none urgent: MUI 7→9 (double-major, codemods available), react-router-dom 6→7, TypeScript 5.9→6.0 (wait for toolchain support), and note puppeteer 25 is ESM-only + Node ≥22, which conflicts with the CommonJS server build — stay on the still-supported 24.x line until an ESM migration is planned.

- [CRITICAL] **concurrently (+ shell-quote)** — Both critical audit findings (GHSA-w7jw-789q-3m8p, shell-quote command injection CVSS 8.1, via concurrently 9.2.1) sit in a devDependency that is completely unused — no npm script in package.json references concurrently (the dev scripts run tsx/vite separately); the only 'concurrently' matches in src/ are the English word in comments.
  - Recommendation: Remove concurrently from devDependencies entirely (zero migration risk — it is dead weight). If it is ever wanted again, install concurrently@10.0.3 which has the patched shell-quote. This single deletion clears both critical audit entries.
- [MEDIUM] **prisma / @prisma/dev / @hono/node-server** — All 3 moderate audit findings are one chain: prisma 7.x ships @prisma/dev (<=0.24.8) which pins vulnerable @hono/node-server <1.19.13 (GHSA-92pp-h63x-v22m, middleware bypass via repeated slashes in serveStatic). Exposure is the Prisma CLI dev tooling, not the production runtime query path. npm audit's suggested fix is a DOWNGRADE to prisma 6.19.3 (semver-major backwards) — unacceptable, the app is built on the Prisma 7 Rust-free client + @prisma/adapter-mariadb.
  - Recommendation: Do NOT take npm's downgrade fix. Add an npm overrides entry in package.json: "overrides": { "@hono/node-server": "^1.19.13" }, and track prisma/prisma#29605 for an upstream @prisma/dev fix. Migration risk: trivial (one package.json field + reinstall).
- [MEDIUM] **@types/dompurify** — Officially deprecated on npm: "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed." dompurify >=3.2 bundles its own types; the stub is dead weight and can shadow/conflict with the real types.
  - Recommendation: Remove @types/dompurify from devDependencies. Migration risk: none — run npm run typecheck after removal to confirm.
- [MEDIUM] **@types/bcryptjs** — Officially deprecated on npm: "This is a stub types definition. bcryptjs provides its own type definitions." Worse, the installed stub is ^2.4.6 (2.x API) while bcryptjs 3.0.3 is the runtime — the stale stub can mask the bundled 3.x typings.
  - Recommendation: Remove @types/bcryptjs from devDependencies. Migration risk: none — bcryptjs 3.x ships correct types; verify with npm run typecheck.
- [MEDIUM] **quill (via react-quill-new)** — Two-part finding. (1) Audit low: quill 2.0.3 has an unpatched XSS in the HTML export feature (GHSA-v3m3-f69x-jf25 / CVE-2025-15056); NO fixed quill release exists, and npm's 'fix' is a downgrade of react-quill-new to 3.7.0 — not a real fix. The codebase already mitigates this (server-side DOMPurify via jsdom, frontend sanitization before dangerouslySetInnerHTML, cleanQuillHtml on all PDF routes). (2) Maintenance: quill core has had no release since Jan 2025 and Snyk flags it as possibly discontinued; the react-quill-new wrapper itself IS actively maintained (3.8.3, Feb 2026).
  - Recommendation: Keep react-quill-new 3.8.3 and the existing sanitization discipline (do not take the downgrade 'fix'). Watch quill core: if it stays dormant through an open CVE for another cycle, plan a medium-term migration to TipTap or Lexical — that is a LARGE refactor (RichEditor/RichEditorRoot/RichTextView components, Quill Delta/HTML content model, existing stored rich-text in invoices/quotations/orders), so it should be a deliberate project, not a casual swap.
- [MEDIUM] **puppeteer** — Behind on two levels: 24.40.0 → 24.43.1 (patch, safe) and major 25.1.0 available. Puppeteer is very actively maintained (verified, 25.1.0 published May 2026), but v25 is ESM-ONLY, requires Node >=22, and makes executablePath/defaultArgs async — this directly conflicts with the server build, which compiles to CommonJS (tsconfig.server.json, "type": "commonjs").
  - Recommendation: Bump within 24.x to 24.43.1 now (safe). Defer v25 until/unless the server build migrates to ESM (or use dynamic import() shims); treat the v25 jump as HIGH migration risk and bundle it with any future CJS→ESM move. The 24.x line is still supported.
- [MEDIUM] **@mui/material + @mui/x-date-pickers** — @mui/material 7.3.11 → 9.1.0 and @mui/x-date-pickers 8.29.0 → 9.4.0. MUI skipped v8 for core to re-align majors with MUI X. v9 removes previously-deprecated APIs and system props (sx only), changes Grid (no direction="column"), tweaks ListItemIcon min-width, ~3% smaller bundle; official codemods and an Upgrade-to-v9 guide exist. The app's entire admin UI kit (src/admin/ui/, theme.ts, GlobalStyles.tsx) sits on MUI v7, so the surface is wide.
  - Recommendation: Plan a dedicated upgrade task: run the MUI v9 codemods, upgrade core and x-date-pickers together (one shared major), then visually regression-test the ui-kit showcase route and dense tables. Moderate migration risk, not urgent — v7 still works fine; do it before v7 drops out of support.
- [MEDIUM] **react-router-dom** — 6.30.4 → 7.17.0 (major). v6 is in maintenance mode; v7 is the active line (package consolidation into react-router, react-router-dom kept as a thin re-export). The app uses classic <Routes>/lazy pages in AdminApp.tsx, and already satisfies v7's React 18+ requirement (React 19).
  - Recommendation: Upgrade path: enable the v6 future flags first (v7_startTransition, v7_relativeSplatPath, etc.) on 6.30.x, confirm no warnings, then bump to v7 and optionally switch imports to "react-router". Moderate, mostly-mechanical migration risk for a non-data-router app like this one.
- [LOW] **typescript** — 5.9.3 → 6.0.3 (major). TS 6.0 changes nine compiler defaults (strict by default — this repo is already strict; rootDir inference now '.', types:[] no longer auto-crawls @types, moduleResolution classic removed, ES5 target deprecated, esModuleInterop always on). Toolchain (typescript-eslint 8.61, vite, vitest) must also declare TS 6 support.
  - Recommendation: Hold on 5.9.x until typescript-eslint and the Vite/Vitest toolchain officially support TS 6, then migrate using the ts5to6 codemod (npx @andrewbranch/ts5to6) and re-verify the three tsconfig projects (server/test/solution). Moderate config-level risk, low urgency.
- [LOW] **@fastify/multipart, @fastify/rate-limit** — @fastify/multipart 9.4.0 → 10.0.0 and @fastify/rate-limit 10.3.0 → 11.0.0 — both new majors; Fastify plugin majors historically bump minimum Fastify/Node versions with small API changes (e.g. multipart v10 changes saveRequestFiles return values). Detailed breaking-change lists were not in release summaries found.
  - Recommendation: Read the GitHub release notes for both before bumping; expected low migration risk on Fastify 5, but verify the multipart upload route and rate-limit config against the changelogs, and run the NAS file-manager tests after.
- [LOW] **minor/patch batch (19 packages)** — Safe minor/patch updates available: @anthropic-ai/sdk 0.102.0→0.104.0, @tanstack/react-query 5.100.5→5.101.0, @types/jsdom 28.0.1→28.0.3, @types/node 25.5.0→25.9.2, @vitejs/plugin-react 6.0.1→6.0.2, date-fns 4.1.0→4.4.0, dompurify 3.4.5→3.4.8, dotenv 17.3.1→17.4.2, framer-motion 12.38.0→12.40.0, jsdom 29.0.2→29.1.1, mariadb 3.5.2→3.5.3, nodemailer 8.0.7→8.0.10, otpauth 9.5.0→9.5.1, prettier 3.8.3→3.8.4, puppeteer 24.40.0→24.43.1 (within-range), tsx 4.21.0→4.22.4, vite 8.0.13→8.0.16, vitest 4.1.0→4.1.8, zod 4.3.6→4.4.3.
  - Recommendation: One npm update pass, then the standard gates (tsc -b --noEmit, npm run build, npx vitest run, npm run lint). @anthropic-ai/sdk is 0.x so skim its 0.103/0.104 changelog for the Odin extract-invoices structured-output path, but risk is low.
- [LOW] **@types/nodemailer** — Types package (7.0.11) is a full major behind the installed runtime (nodemailer 8.0.x); @types/nodemailer 8.0.0 exists. Mismatched type majors can hide real API differences in src/utils/email.ts.
  - Recommendation: Bump @types/nodemailer to ^8.0.0 alongside the nodemailer patch bump. Trivial risk; typecheck confirms.
- [LOW] **bcryptjs** — Verified actively maintained (3.0.3, 2026 activity) and NOT deprecated — no forced action. Context: it is pure-JS (~3-4x slower than native bcrypt), and OWASP's current guidance puts Argon2id first with bcrypt 'acceptable'. Existing user hashes are bcrypt (including PHP-legacy $2y$), so any swap needs a rehash-on-login transition.
  - Recommendation: Keep bcryptjs for now (zero native-build hassle on the Windows dev + Linux prod mix). Optional long-term hardening: migrate to argon2 (npm 'argon2', current 0.44.0) with verify-old/rehash-new on login. Migration risk: moderate (dual-verify logic, native dependency in prod deploy).
- [LOW] **node-cron** — Verified maintained (4.2.1, 2026 activity) — not abandoned. However croner (current 10.0.1) is the more robust 2026 choice: TypeScript-native, zero deps, Intl-based timezone/DST handling, and a catch option so job errors aren't silently swallowed. Relevant here because the app pins TZ=Europe/Prague where DST transitions can shift naive cron schedules, and the codebase has a 'never swallow errors' rule.
  - Recommendation: No urgent action. If cron-time DST correctness or job error handling ever bites, swap to croner@10 — cron expression syntax is compatible and the scheduler surface in this app is small. Low migration risk.
- [LOW] **otpauth, nodemailer, puppeteer (maintenance verification)** — All three flagged-for-checking packages verified healthy via registry + web (June 2026): otpauth 9.5.1 (hectorm, active releases, RFC 6238 — matches the TOTP module), nodemailer 8.0.10 (published May 2026, active), puppeteer 25.1.0 (published May 2026, very active). None deprecated or abandoned.
  - Recommendation: No replacements needed; just take the patch/minor bumps listed in the batch finding (puppeteer stays on 24.x per its separate finding).
- [LOW] **@dnd-kit/\* and leaflet (staleness watch)** — @dnd-kit/core last npm publish Dec 2024, but 2026 ecosystem sources consistently describe dnd-kit as actively maintained and still the React drag-and-drop default (used in 3 detail pages here). leaflet is on 1.9.4 (last stable era 2023) with v2 in progress upstream; used on a single page (AttendanceLocation.tsx). Neither is deprecated.
  - Recommendation: No action now; re-check both at the next dependency review. If dnd-kit goes truly dormant, Atlassian's pragmatic-drag-and-drop is the credible replacement (headless, more DIY). Leaflet 2.0 will be a deliberate migration when it ships stable.
- [LOW] **framer-motion (weight, not redundancy)** — Used in 15 files, but mostly for simple entrance/fade/stagger animations (PageEnter, PageHeader, dashboard widgets, Odin thread) — the full framer-motion bundle (~30-40 kB gz) is heavy for that usage profile. It is genuinely used, so removal is not recommended; no competing animation lib is installed (no duplication).
  - Recommendation: Optional optimization: adopt framer-motion's built-in LazyMotion + m components (domAnimation feature set) to cut the animation bundle to ~6 kB, or convert the simplest fades to CSS transitions. Low risk, purely a bundle-size win; respects the existing useReducedMotion handling.
- [LOW] **dependency placement hygiene (@types/jsdom, dotenv)** — (1) @types/jsdom sits in dependencies instead of devDependencies, so the prod install (npm install --omit=dev) still pulls a types-only package. (2) dotenv 17 is fine and maintained, but Node >=20 supports --env-file natively; dotenv is now optional weight (kept useful here by the .env/.env.test switching workflow).
  - Recommendation: Move @types/jsdom to devDependencies (trivial, no risk). Keep dotenv unless someone wants to slim prod deps — switching to node --env-file would touch the start script and test setup, so only do it deliberately.
- [LOW] **date-fns (redundancy check — clean)** — Date-library redundancy check passed: date-fns v4 is the only date library (no moment/dayjs/luxon duplication) and it is REQUIRED as the @mui/x-date-pickers adapter (AdapterDateFns with cs locale in MuiProvider/DateField/MonthField/TimeField). Not replaceable by native code while MUI pickers are in use.
  - Recommendation: No action — this is the correct setup. Take the 4.1.0 → 4.4.0 minor bump with the batch.

---

# Per-file findings

## .claude/hooks/block-env.js

- [HIGH] (line 10-11) Block decision never takes effect: hook prints {decision:'block'} JSON to stdout then exits 1 — Claude Code only parses stdout JSON on exit 0 and only blocks on exit 2 (reason read from stderr), so exit 1 is treated as a non-blocking error, stdout is ignored, and the .env edit proceeds silently; fix by exiting 0 with the JSON or exiting 2 with the reason on stderr. _(verified)_
- [LOW] (line 13) Empty `catch {}` swallows malformed-hook-input errors with no log and fails open (edit allowed), violating the repo rule that silent expected-condition catches require an explanatory comment.

## .claude/hooks/format-on-save.js

- [MEDIUM] (line 10) Shell-injection risk: filePath from hook JSON input is interpolated unescaped into an execSync shell string (`npx prettier --write "${filePath}"`); filenames containing shell metacharacters (POSIX: quotes/backticks/$( ); cmd.exe: %VAR% expansion) execute arbitrary commands with no permission prompt — use execFileSync with an argument array.
- [LOW] (line 12) Empty `catch {}` plus stdio:'ignore' silently swallows every hook failure (prettier errors, timeouts) with no comment marking it as an expected-condition catch.

## .claude/settings.json

- [LOW] (line 9, 22) Hook commands use relative paths (`node .claude/hooks/...`) instead of "$CLAUDE_PROJECT_DIR"; hooks run in the session's current directory, so if cwd leaves the repo root both hooks fail non-blockingly and the .env guard + auto-format silently stop working.

## .claude/settings.local.json

- [LOW] (line 4-9) Dead permission allowlist: lines 4-5 have backslashes escaped away (`D:cortexboha-appresources`) so the rules can never match a real command, and lines 4-5/8-9 all target the legacy boha-app PHP repo rather than this project — stale entries that should be pruned.

## .env.example

- Clean

## .gitignore

- Clean

## .prettierignore

- Clean

## .prettierrc.json

- Clean

## CLAUDE.md

- [LOW] (line 39) Stale doc: project-structure comment says "React 18 + Material UI frontend" while the tech-stack table (line 17) says React 19.2.7.
- [LOW] (line 50) Inconsistent test counts: line 50 says the suite is "17 files" while the Testing section (line 250) says "18 files / 247 tests".

## boneyard.config.json

- Clean

## eslint.config.mjs

- [LOW] (line 54-61) eslint-plugin-react-refresh is imported and registered in the frontend block but no react-refresh rule is ever enabled — dead plugin registration (e.g. only-export-components is never turned on).

## index.html

- [LOW] (line 2-6) data-theme="dark" and theme-color #12121a are hardcoded with no inline pre-React script syncing the stored 'mui-mode' localStorage key, so light-mode users get a dark flash on every page load until React/ThemeContext mounts.

## package.json

- [LOW] (line 17) "db:push": "prisma db push" provides a first-class npm script for the command CLAUDE.md's golden rule forbids (NEVER use prisma db push — it causes migration drift); the script should be removed.
- [LOW] (line 50) @types/jsdom is listed in production dependencies (installed on prod via npm install --omit=dev) — type packages belong in devDependencies.
- [LOW] (line 88) concurrently is a devDependency but no npm script uses it (dev script is plain `tsx watch`) — unused dependency.

## prisma.config.ts

- Clean

## prisma/schema.prisma

- [MEDIUM] (line 302-309) number_sequences has fully nullable type (String?), year (Int?) and last_number (Int?) on the concurrency-critical numbering table — the [type, year] unique index does not prevent duplicate rows when either column is NULL (MySQL allows multiple NULLs in unique indexes), and a nullable counter forces null-handling in the locking/sequence code; all three should be required.
- [LOW] (line 26, 36) attendance.project_id is a bare Int? with only an index (idx_project_id) and no relation/FK to projects (unlike attendance_project_logs.project_id) — deleting a project leaves dangling references with no referential integrity.
- [LOW] (line 545-560, 624-634) Auth token tables have no FK to users: refresh_tokens.user_id is @db.UnsignedInt (type-mismatched against signed users.id, which blocks adding one) and totp_login_tokens.user_id has no relation — orphaned token rows persist after user deletion; same FK-less pattern on ai_usage.user_id, ai_chat_messages.user_id, received_invoices.uploaded_by and quotations.locked_by.
- [LOW] (line 30, 293, 650, 673, 1043) Most updated_at columns are DateTime? @default(now()) WITHOUT @updatedAt (and the generated DDL has no ON UPDATE CURRENT_TIMESTAMP), so they only change when service code remembers to set them manually — inconsistent with ai_conversations/plan_categories which do use @updatedAt; updates that forget leave stale timestamps.

## prisma/seed.ts

- [MEDIUM] (line 320-327) Seed deleteMany-wipes ALL permissions/role_permissions then re-inserts only the hardcoded PERMISSIONS list, which omits the migration-seeded 'ai.use' (20260608120000_add_ai_assistant) — re-seeding a migrated dev/test DB silently deletes the AI permission row and any non-admin grants, desyncing the DB from the migration baseline (Odin page is gated on hasPermission("ai.use")).
- [LOW] (line 306-315) The destructive-wipe production guard keys solely off APP*ENV === "production"; a DATABASE_URL pointing at prod with APP_ENV unset/local (e.g. after a migrate-diff session against the prod URL) still wipes prod permissions — no DB-target check like the test setup's hard-throw on non-\_test* database names.

## scripts/check-roles.mjs

- [MEDIUM] (line 2) Bare `new PrismaClient()` throws PrismaClientInitializationError at construction under Prisma 7 (driver adapter is mandatory — verified by running the script), and the script never loads dotenv so DATABASE_URL would be undefined anyway; it crashes on every invocation and should import the shared adapter-wired client from src/config/database like seed.ts does.

## scripts/migrate-received-invoices-to-nas.ts

- [MEDIUM] (line 9, 21-30, 41, 61, 87-90) Script is fully dead/broken against the current codebase: it imports a nonexistent `nasFinancialsManager` export (the module now exports nasInvoicesManager/nasOrdersManager after the NAS split), calls removed methods saveReceivedInvoice/readReceivedInvoice (now saveReceived/readReceived), and queries/updates received_invoices.file_data/file_path fields that exist in neither schema.prisma nor any migration; the NAS_FINANCIALS_PATH usage header is stale too. scripts/ is excluded from every tsconfig so typecheck never catches it, and nas-financials-manager.ts (line 19) still references this script as the canonical safe-migration mechanism.
- [LOW] (line 69-73) Reaches into the manager's private basePath via `as unknown as { basePath: string }` cast and concatenates the path manually with "/" — a type-safety hole that bypasses the manager's own path resolution/guards.

## scripts/rotate-totp-key.ts

- [LOW] (line 37-52) The --dry-run path returns from main() without prisma.$disconnect() (only the non-dry-run path disconnects at line 67), so the open mariadb pool keeps the event loop alive and the script hangs after printing "Dry run complete."
- [LOW] (line 19-25) Old/new AES-256 encryption keys are passed as plain command-line arguments — they end up in shell history and are visible in the process list (ps) for the duration of the run; reading from env vars or prompting would avoid the leak.

## scripts/seed-test-users.mjs

- [MEDIUM] (line 10) Bare `new PrismaClient()` throws PrismaClientInitializationError at construction under Prisma 7 (driver adapter is mandatory — empirically verified for the identical pattern in check-roles.mjs), and the script never loads dotenv — it crashes on every invocation, so neither documented invocation in the header (node / npx tsx) works.
- [LOW] (line 57-107) No production guard (unlike prisma/seed.ts's APP_ENV hard-throw): once the client construction is fixed, running it with a production DATABASE_URL would create 20 active accounts with the known shared password "test1234".

## src/App.tsx

- Clean

## src/**tests**/ai.test.ts

- [LOW] (line 323-336) API key is blanked then restored on the line after the assertion — if the 503 expectation fails, the restore is skipped and the rest of the file runs with apiKey="" until afterAll; should use try/finally like the budget test at lines 119-129 does.

## src/**tests**/auth.service.test.ts

- Clean

## src/**tests**/auth.test.ts

- Clean

## src/**tests**/bank-accounts.test.ts

- Clean

## src/**tests**/company-settings-numbering.test.ts

- Clean

## src/**tests**/customers.schema.test.ts

- Clean

## src/**tests**/drafts-aggregation.test.ts

- Clean

## src/**tests**/drafts-deferred-numbering.test.ts

- [LOW] (line 112-118) "Deleting a draft" tests (also 183-189 and 267-274) never push the draft id into the cleanup array and never assert the delete service result — if deleteIssuedOrder/deleteInvoice/deleteOffer returns an error the test still passes (preview unchanged either way) and the row leaks into the shared app_test DB.

## src/**tests**/env.test.ts

- [LOW] (line 36-68) The "failure path" describe asserts locally re-declared copies of the validation predicates (HEX64_RE, portValid, expiryValid) rather than exercising src/config/env.ts — it can never catch a regression in the real validation and drifts silently if env.ts changes (the comment acknowledges this, but only the happy-path assertions touch real code).

## src/**tests**/exchange-rates.test.ts

- Clean

## src/**tests**/helpers.ts

- Clean

## src/**tests**/invoices.service.test.ts

- Clean

## src/**tests**/issued-orders.test.ts

- [LOW] (line 24-29) Vacuous-pass risk: expect(Number(b.number)).toBe(Number(a.number) + 1) — issued_order_number_pattern is settings-configurable, and for any non-numeric pattern both sides become NaN and vitest's toBe (Object.is) treats NaN===NaN as a pass; numbering.test.ts's assertStrictIncrement (which asserts the parsed suffixes are not NaN) exists precisely to avoid this.
- [LOW] (line 191-211) Cleanup gap: the order created in 'deletes, cascades items, frees the latest number' is never pushed to createdIds (unlike every other test in the file), so if any assertion before deleteIssuedOrder throws (e.g. expect(before).toBeTruthy() at line 198), the row and its consumed number leak into app_test and survive afterEach.

## src/**tests**/manual-create.test.ts

- [LOW] (line 157-183) Cleanup gap: in the deleteOrder regression test, orderId is never pushed to createdOrderIds and the auto-created project is never pushed to createdProjectIds (other tests push immediately after create), so a mid-test assertion failure before deleteOrder leaks both rows past afterEach into app_test.

## src/**tests**/nas-document-manager.test.ts

- Clean

## src/**tests**/nas-file-manager.test.ts

- [LOW] (line 7-57) The first describe instantiates new NasFileManager() bound to the real env-configured NAS_PATH, and its two 'rejects path traversal' tests (lines 31-34, 53-56) pass vacuously: project 'PRJ-001' never exists, so resolveProjectPath returns null via findProjectFolder not-found — yielding the same 'Neplatná cesta' message whether or not the traversal guard fired. Only the tmp-dir tests at lines 105-127 genuinely exercise the guard; the first-describe traversal cases are redundant false confidence and should use the constructor seam like the rest.

## src/**tests**/nas-project-folder.test.ts

- Clean

## src/**tests**/numbering.test.ts

- Clean

## src/**tests**/offer-invoice-totals.test.ts

- Clean

## src/**tests**/order-totals.test.ts

- [LOW] (line 22) Dead code: createdCustomerIds is declared and cleaned up in afterEach (lines 29-30) but never populated — verified createOrder/createIssuedOrder never create customer rows in these tests.
- [LOW] (line 157) Month zero-padding via template literal `0${STATS_MONTH}` (also lines 185-186 with STATS_MONTH+1) only works for single-digit months — changing STATS_MONTH to 9+ silently produces an invalid date string like "2097-010-15" instead of failing loudly.

## src/**tests**/orders-list.test.ts

- Clean

## src/**tests**/plan.test.ts

- [LOW] (line 1161-1170) Silent `.catch(() => {})` on the noPerm user/role cleanup lacks the justifying comment the repo convention requires for intentionally-silent catches (the matching scope-user cleanup at lines 85-92 has one).

## src/**tests**/planAuditDescription.test.ts

- Clean

## src/**tests**/planCategory.test.ts

- [LOW] (line 37) Only test file that calls prisma.$disconnect() on the shared client in afterAll — harmless today because vitest isolates each file in its own worker, but inconsistent with every other suite and would break sibling files if isolation were ever turned off.

## src/**tests**/received-invoices-vat.test.ts

- [LOW] (line 49) Duplicated assertion with a stale comment: line 48 says the exact-equality pin is "replacing the toBeCloseTo above", but the original toBeCloseTo assertion at line 9 was never removed.

## src/**tests**/schema-nan.test.ts

- Clean

## src/**tests**/setup.ts

- Clean

## src/**tests**/warehouse.test.ts

- [LOW] (line 91-105) Cleanup order bug: sklad_items.deleteMany runs before sklad_inventory_lines.deleteMany (same in afterAll, 209-223), but sklad_inventory_lines.item_id FK-references sklad_items with onDelete: Restrict — leftover inventory-line test data would make the whole suite crash in beforeAll; latent today only because this file never creates inventory lines.
- [LOW] (line 110-118) Silently swallowed errors: .catch(() => {}) on users/roles/projects cleanup without the explanatory comment the repo's 'never silently swallow' convention requires (the afterAll equivalents at 229-238 do have one); a masked FK-constraint failure here leaves a stale wh*test*\* user that makes the next run fail on the unique-username create with a misleading P2002 instead of the real cause.

## src/admin/AdminApp.tsx

- Clean

## src/admin/GlobalStyles.tsx

- Clean

## src/admin/components/AlertContainer.tsx

- Clean

## src/admin/components/AttendanceShiftTable.tsx

- Clean

## src/admin/components/BulkAttendanceModal.tsx

- Clean

## src/admin/components/BulkPlanModal.tsx

- Clean

## src/admin/components/ErrorBoundary.tsx

- Clean

## src/admin/components/Forbidden.tsx

- Clean

## src/admin/components/OrderConfirmationModal.tsx

- Clean

## src/admin/components/PlanCategoriesModal.tsx

- [LOW] (line 147-158) Rename TextField is uncontrolled and keyed on c.label, so when the PATCH fails (onError only alerts) the input keeps showing the user's unsaved text with no remount/reset — the row silently displays a value that differs from the server's label.

## src/admin/components/PlanCellModal.tsx

- [MEDIUM] (line 276-333) handleSubmit/handleDelete are try/finally with no catch: PlanWork's handlers (PlanWork.tsx:394-584) alert then rethrow, and Modal.tsx:93 / ConfirmDialog.tsx:88 invoke onSubmit/onConfirm as bare onClick handlers, so every failed save/delete surfaces as an unhandled promise rejection.
- [MEDIUM] (line 476-479) DayInRangeModal's 'Vytvořit přepsání' async onClick has no submitting/disabled state — a double-click fires onCreateOverrideFromRange twice and the server is additive up to 3 records per day (plan.service.ts:690-713, no unique constraint on user_id+shift_date), creating duplicate identical overrides; its rejection is also unhandled (parent rethrows after alerting).

## src/admin/components/PlanGrid.tsx

- [LOW] (line 427-433) todayIso() is a third hand-rolled copy of the canonical local-today helper (todayLocalStr in src/admin/utils/formatters.ts; PlanWork.tsx:103 has another, todayIsoLocal) — implementation is correct (local components, not toISOString slicing), but 'today' should have a single source per the conventions.
- [LOW] (line 486-493, 583) The pulse 'nonce' re-trigger mechanism doesn't work as the comments claim: for back-to-back mutations on the same cell the button's className is unchanged and no CSS selector references the root's data-pulse attribute, so changing it cannot restart the descendant keyframe animation — repeat pulses on the same cell never re-animate.

## src/admin/components/PlanRangeChips.tsx

- Clean

## src/admin/components/ProjectFileManager.tsx

- [MEDIUM] (line 355-357) All four mutations (upload, create-folder, delete, rename at 355-357, 419-421, 484-486, 522-524) invalidate only the targeted ["projects", id, "files"] key instead of the broad ["projects"] domain mandated by the convention; the project detail query ["projects", id] is never invalidated, so has_nas_folder (the hasNasFolder prop driving the empty-state text) stays stale after the first upload auto-creates the NAS folder.
- [LOW] (line 50-135) File-type icon colors are hardcoded hex (#e6a817, #e74c3c, #3498db, rgba(230,168,23,0.15), ...) instead of theme tokens; only the fallback at 138 correctly uses var(--mui-palette-text-secondary) — violates the theme.vars color convention.
- [LOW] (line 594-614) Rename has no in-flight guard: pressing Enter starts the async handleRename while renamingItem stays set, so a subsequent blur (or second Enter) fires handleRename again against the already-renamed path, producing a spurious 'Nepodařilo se přejmenovat' error alert after a successful rename.

## src/admin/components/RichEditor.tsx

- [MEDIUM] (line 78, 93-97) lastValueRef is initialized once from the initial value prop and only updated on user edits; external prop changes (form reset / switching records arrive as source 'api' and are ignored), so a later user change whose content exactly equals the stale ref (e.g. re-pasting the same text after a reset) is silently dropped — onChange never fires and parent state desyncs from what the editor displays, losing the content on save.

## src/admin/components/ShiftFormModal.tsx

- Clean

## src/admin/components/ShortcutsHelp.tsx

- Clean

## src/admin/components/dashboard/DashActivityFeed.tsx

- Clean

## src/admin/components/dashboard/DashAttendanceToday.tsx

- Clean

## src/admin/components/dashboard/DashKpiCards.tsx

- Clean

## src/admin/components/dashboard/DashProfile.tsx

- [HIGH] (line 556) The QR <img> from api.qrserver.com is blocked by the production CSP (src/middleware/security.ts:30 img-src only allows 'self' data: blob: and \*.tile.openstreetmap.org), so every production 2FA enrollment shows a broken image and users must fall back to typing the secret manually. _(verified)_
- [MEDIUM] (line 556) 2FA setup sends the full otpauth:// URI — including the plaintext TOTP secret and the account label — as a GET query param to the third-party service api.qrserver.com; the live 2FA secret leaves the system (and lands in that service's logs). Generate the QR locally (client-side QR lib) instead. _(severity adjusted by verification: The code at DashProfile.tsx:556 really does embed the full otpauth:// URI (secret included, from totp.ts:50) in an api.qrserver.com GET URL, but in production the global CSP (security.ts:30, img-src 'self' data: blob: https://\*.tile.openstreetmap.org, applied via server.ts:99 to the served index.html) blocks the request entirely — the secret never egresses in prod; instead the QR renders broken and users fall back to the manual-key entry (DashProfile.tsx:568). The flaw is real (secret leaks in non-CSP local/dev runs, and prod QR enrollment is silently broken) and should be fixed with a local QR render (the `qrcode` dep already exists), but the headline "live secret leaves the system" does not hold in production, so HIGH is overstated — MEDIUM gap/risk.)_

## src/admin/components/dashboard/DashQuickActions.tsx

- [MEDIUM] (line 105-139) openTripModal (109-115) and handleTripVehicleChange (130-135) silently ignore success:false API responses — no log, no alert, and the vehicle list/start-km just stays empty or stale, violating the never-silently-swallow rule (only the network-error catch logs).
- [LOW] (line 121-139) Stale-response race: rapidly switching vehicles fires concurrent /trips/last-km/:id fetches with no cancellation or selected-id check, so a late response for the previous vehicle can overwrite start_km for the currently selected one.

## src/admin/components/dashboard/DashSessions.tsx

- [MEDIUM] (line 101, 122) Both delete handlers use a bare `catch { alert.error(...) }` that discards the error object without logging it (console.error), violating the repo's "every catch logs" convention — the generic 'Chyba připojení' toast loses all diagnostics.

## src/admin/components/dashboard/DashTodayPlan.tsx

- Clean

## src/admin/components/odin/InvoiceReviewCard.tsx

- Clean

## src/admin/components/odin/OdinChat.tsx

- [MEDIUM] (line 254-282) Extraction response handling silently drops per-file `error` entries (filtered out at 261-279 with no per-file notice) and never reads the server's `truncated` flag (routes/admin/ai.ts:141, set when the budget halts the batch mid-way), so failed or unprocessed files vanish without feedback; an all-failed batch reads as the misleading 'V přílohách jsem nenašel žádnou fakturu'.
- [MEDIUM] (line 185-200) ensureActive returns null on a non-ok POST /conversations response (193) without logging or alerting, and submit then skips persist() — the just-sent turn is silently never saved to history (silent error swallow).
- [MEDIUM] (line 287, 315-318) If ensureActive's apiFetch throws (network) AFTER a successful, billed extraction, the catch rolls back the thread to prevTurns (losing the assistant summary), shows the wrong error 'Chyba čtení faktur', yet leaves the review cards and already-cleared attachments — inconsistent UI state for a non-extraction failure.
- [MEDIUM] (line 337-339) saveInvoice coerces user-edited free-text with raw Number(): a Czech-format amount like "1 234,56" becomes NaN, which JSON.stringify serializes as null, and the server's non-nullable nonNegativeNumberFromForm (received-invoices.schema.ts:10) rejects it with a 400 — no client-side number validation or comma handling before submit.
- [MEDIUM] (line 374-400) onRename/onDelete handle !res.ok but have no try/catch around apiFetch, and OdinSidebar invokes them un-awaited (OdinSidebar.tsx:53,61) — a network failure becomes an unhandled promise rejection with no user feedback and no log.

## src/admin/components/odin/OdinComposer.tsx

- [MEDIUM] (line 50) File input accept="application/pdf,image/_" invites image attachments, but the backend hardcodes media_type "application/pdf" for every uploaded file (src/services/ai.service.ts:317-321), so any attached image is sent to the Anthropic API as a malformed PDF and always fails with 'Nepodařilo se přečíst fakturu' — drop image/_ or support images server-side.

## src/admin/components/odin/OdinMark.tsx

- Clean

## src/admin/components/odin/OdinSidebar.tsx

- [MEDIUM] (line 132-140) Row onKeyDown doesn't check e.target, so Enter/Space on the focused kebab IconButton bubbles to the row, preventDefault() cancels the button's native click activation, and the row gets selected instead — rename/delete menu is unreachable by keyboard; the comment claiming the nested button 'handles its own keys' is wrong (stopPropagation is only on the click event, which never fires).

## src/admin/components/odin/OdinThread.tsx

- Clean

## src/admin/components/odin/types.ts

- Clean

## src/admin/components/warehouse/ItemPicker.tsx

- [LOW] (line 53-55) The Autocomplete is clearable (no disableClearable) but onChange ignores null options, so the clear (X) button is a silent no-op — the parent keeps the old itemId and the selected name snaps back on blur; either propagate the clear or set disableClearable.

## src/admin/components/warehouse/ReservationPicker.tsx

- [LOW] (line 1-72) Dead code: ReservationPicker is not imported anywhere in src/ (verified by grep — only its own file matches); the warehouse issue form does not use it.

## src/admin/context/AlertContext.tsx

- [LOW] (line 19, 53-57) addAlert accepts type?: string and force-casts it (type as Alert["type"]) — a typo'd severity string flows unchecked into the Alert union; the parameter should be typed as the "success" | "error" | "warning" | "info" union.

## src/admin/context/AuthContext.tsx

- [HIGH] (line 263-272) Backup-code 2FA login is broken: verify2FA always POSTs to /login/totp with the code as totp*code plus an isBackup flag the server ignores — the route validates with TotpVerifySchema (exactly 6 digits), so the 8-char backup codes Login.tsx collects always fail with 400 'Kód musí mít 6 číslic'; the real endpoint POST /api/admin/totp/backup-verify (expects backup_code) is never called anywhere in the frontend, locking out any user who lost their authenticator. *(verified)\_
- [LOW] (line 139-150) silentRefresh success sets user but never sets cachedUserRef (while the failure path nulls it at 148), so checkSession's degraded fallbacks (lines 176-177 and 195-196: return !!cachedUserRef.current) report false for a session established via cookie refresh — inconsistent cache maintenance.

## src/admin/hooks/useAttendanceAdmin.ts

- [MEDIUM] (line 307-308, 526-528) Screen vs print formula drift: computeUserTotals caps the worked-holiday adjustment with Math.min(freeHolidayHours, workedHolidayCount*8), but the print path subtracts workedHolidayCount*8 uncapped, so the printed 'Vcetne svatku a prescasu'/'Prescas' disagrees with the on-screen totals by up to 8h per worked holiday (triggers whenever a user works the month's only holiday). _(severity adjusted by verification: The formula drift is real (useAttendanceAdmin.ts line 307 caps with Math.min(freeHolidayHours, workedHolidayCount*8) while the print path at lines 526-528 subtracts workedHolidayCount*8 uncapped, diverging 8h when a user works the month's only holiday — and the line-297 comment "matching the print" proves the paths were meant to be identical), but the claimed screen-vs-print disagreement cannot be observed: AttendanceAdmin.tsx never renders vcetne_svatku/prescas (type-only at lines 45-46; the cards at lines 234-358 show only minutes/chips/Fond, none using holidayAdj), so the capped screen values are dead state. Remaining impact is a likely-wrong printed Přesčas/Včetně line (can even print −8h) in an uncommon worked-holiday edge case — a real gap, not a HIGH likely-in-practice bug.)_
- [MEDIUM] (line 827-829) Empty catch ('/_ silent _/') silently swallows the projects-list load failure — violates the 'never swallow errors, every catch logs' convention; the project selector just stays empty with no log or user feedback.
- [MEDIUM] (line 856-858) Same silent empty catch on the attendance-users load — user filter and bulk-modal user list stay empty with no log or alert on failure.
- [LOW] (line 818-922) Data fetching via useEffect + useState instead of React Query (convention: 'use React Query for fetching, not effects'); the hook invalidates ["attendance"] for other consumers but its own records live in local state, so external ["attendance"] invalidations never refresh this page.
- [LOW] (line 350-353, 380-384, 1320) Print path is typed Record<string, any> / log: any despite AttendanceRecord/UserTotal types existing in the same file, and the userId parameter of buildUserSectionHtml is unused.
- [LOW] (line 144-146, 239, 482-488, 497) Stale/dead code: docstring claims fund = (rawBizDays + holidayCount) x 8 but code uses rawBizDays x 8; holidayCount (239, 497) and sickHours (482, 488) are computed but never used.

## src/admin/hooks/useDebounce.ts

- Clean

## src/admin/hooks/useModalLock.ts

- Clean

## src/admin/hooks/usePaginatedQuery.ts

- Clean

## src/admin/hooks/usePlanWork.ts

- [LOW] (line 22-24, 113) Default anchor is new Date() (local now) pushed through isoDate() = toISOString().slice(0,10) UTC slicing — in the 00:00-02:00 Prague window the grid opens on the previous UTC day (previous week on Monday, previous month on the 1st); this is the banned toISOString 'today' derivation, distinct from the exempt plan-module UTC date math (PlanWork.tsx passes no initialDate, so this is the live path).
- [LOW] (line 530) updateOverride optimistic patch uses vars.body.project_id ?? existing?.project_id, so explicitly clearing the project (project_id: null) is treated as 'unchanged' and the old project briefly stays in the cell until refetch — updateEntry (401-404) handles the undefined-vs-null distinction correctly.
- [LOW] (line 138-148) gridQuery's queryFn duplicates apiCall's error-message extraction (44-54) verbatim instead of reusing it.
- [LOW] (line 604-606) rollbackMutation is a retained no-op shim (dead code) kept only so PlanWork.tsx call sites compile — both ends should be cleaned up.

## src/admin/hooks/useReducedMotion.ts

- Clean

## src/admin/hooks/useTableSort.ts

- Clean

## src/admin/lib/apiAdapter.ts

- Clean

## src/admin/lib/documentStatus.ts

- Clean

## src/admin/lib/entityTypeLabels.ts

- Clean

## src/admin/lib/queries/ai.ts

- Clean

## src/admin/lib/queries/attendance.ts

- [LOW] (line 173) Route-param `id` (user-controlled string from useParams) is interpolated unencoded into the query string (`?action=location&id=${id}`) instead of using URLSearchParams/encodeURIComponent like the file's other helpers — an id containing `&` injects extra query params or breaks the request.

## src/admin/lib/queries/auditLog.ts

- Clean

## src/admin/lib/queries/common.ts

- Clean

## src/admin/lib/queries/dashboard.ts

- Clean

## src/admin/lib/queries/invoices.ts

- Clean

## src/admin/lib/queries/issued-orders.ts

- [LOW] (line 73-76) Duplicate `CurrencyAmount` interface — identical copies exist in invoices.ts:4-7 and offers.ts:4-7; should be a single shared definition (e.g. in lib/queries/common.ts).

## src/admin/lib/queries/leave.ts

- [MEDIUM] (line 17-42) Leave entity split across two query-key domains — `["leave-requests", ...]` (line 19) vs `["leave", "pending"/"processed"]` (lines 28, 37) — so no single broad domain key exists; every mutation must invalidate both prefixes (all 4 current call sites do, but any future mutation invalidating only `["leave"]` silently leaves the requests list stale), violating the documented broad-domain-key convention.

## src/admin/lib/queries/mutations.ts

- [LOW] (line 47-52) On a non-JSON error response (e.g. proxy 502/504 HTML page) the thrown 'Invalid JSON response' Error carries no `.status`, losing the HTTP code the `ApiError` contract promises, so callers branching on `err.status` (e.g. 409 handling) get undefined.

## src/admin/lib/queries/offers.ts

- [LOW] (line 95) `paginatedJsonQuery` is called without a row type (the only list helper in these files that omits it), returning `PaginatedResult<unknown>`; the consuming page papers over it with a `usePaginatedQuery<Quotation>` cast in Offers.tsx — pass the row interface here like invoiceListOptions/issuedOrderListOptions do.
- [LOW] (line 4-7) Duplicate `CurrencyAmount` interface — third identical copy (also in invoices.ts:4-7 and issued-orders.ts:73-76); consolidate into a shared module.

## src/admin/lib/queries/orders.ts

- Clean

## src/admin/lib/queries/plan.ts

- [LOW] (line 2) Dead code: `apiFetch` is imported but never used anywhere in the file (only jsonQuery is used).

## src/admin/lib/queries/projects.ts

- [LOW] (line 97-99) projectFilesOptions silently maps a 404 to a fabricated empty listing, so a missing project / unmounted NAS folder is indistinguishable from a genuinely empty folder (swallowed error with no signal to the user).
- [LOW] (line 20) Type-safety hole: `responsible_user_id: string` but the server stores/returns a number|null (schemas/projects.schema.ts uses nullableIntIdFromForm); ProjectDetail only works by accident via MUI Select's String() comparison and Zod coercion — should be `number | null`.

## src/admin/lib/queries/settings.ts

- [LOW] (line 66-88) Same settings entity is cached under two unrelated top-level domains (['company-settings'] vs ['settings','system-info']/['settings','2fa'] — require_2fa even lives in both), so every settings mutation must remember to invalidate both keys, contrary to the broad-domain invalidation convention (Settings.tsx:341 indeed has to list both).

## src/admin/lib/queries/trips.ts

- [HIGH] (line 52-63) tripListOptions consumes the paginated /api/admin/trips endpoint via plain jsonQuery, discarding the pagination meta: Trips.tsx calls it with {} (server default limit 25) and TripsAdmin with perPage:100 (server hard cap), so both lists silently truncate rows with no way to render pagination controls. _(verified)_
- [HIGH] (line 95-103) tripHistoryOptions sends no page/per*page, so the server returns only the default 25 rows and the dropped pagination meta hides it — TripsHistory.tsx (lines 105-128) computes monthly km/business totals from the truncated rows, silently understating totals for any month with >25 trips. *(verified)\_

## src/admin/lib/queries/users.ts

- [LOW] (line 24-28) userListOptions hardcodes limit=100 against the paginated /users endpoint and drops the pagination meta via jsonQuery, so user pickers would silently omit users past the first 100 (server maxLimit is 100, so no higher limit is possible).

## src/admin/lib/queries/vehicles.ts

- [LOW] (line 7) vehicleListOptions types rows as Record<string, unknown>[] — an effectively untyped result that forces consumers into `as` casts, unlike every other query module which defines a proper interface.

## src/admin/lib/queries/warehouse.ts

- [LOW] (line 247-258) warehouseLocationListOptions cache key isn't normalized: undefined, {}, and {active:'true'} all produce the same request but three distinct queryKeys (`filters ?? {active:'true'}` only normalizes the undefined case), causing duplicate cache entries/fetches.

## src/admin/lib/queryClient.ts

- Clean

## src/admin/pages/Attendance.tsx

- [MEDIUM] (line 360-380) Double-submit race in handlePunch: the 6s safety timeout calls submitPunch(action, {}), but a late getCurrentPosition success callback (the exact 'browser hangs' case the timeout exists for) clears the already-fired timer and submits the same punch a second time; the late error callback additionally reopens the GPS dialog for a third submit — no flag prevents re-submission after the safety timeout fires.
- [MEDIUM] (line 345-349) Effect syncs server notes into local state on every statusQuery.data identity change, wiping an unsaved notes draft whenever the status payload changes — e.g. switching the active project (same card) invalidates ['attendance'], the refetched project_logs change the data reference, and the user's typed note resets to the last-saved server value.
- [MEDIUM] (line 679) Convention violation: `${theme.palette.success.main}33` string-concatenated alpha instead of the mandated theme.vars channel token (rgba(${theme.vars.palette.success.mainChannel} / .2)); theme.palette resolves to the light-scheme value under cssVariables, so the dark scheme (success #22c55e) keeps a light-scheme #15803d glow while the adjacent dot (bgcolor 'success.main') adapts.
- [LOW] (line 271-304) Mutation TOut generics claim { message?: string }, but useApiMutation returns the envelope's `data` field while the attendance routes put `message` at the envelope level (e.g. attendance.ts:391 success(reply,{id},status,message)) — `result?.message` is always undefined, so server messages are never shown and toasts always fall back to the hardcoded text.
- [LOW] (line 810-815) Duplicated code: `projects.find(p => String(p.id) === String(activeProjectId))` is evaluated three times inline for the same value; hoist to a local const.

## src/admin/pages/AttendanceAdmin.tsx

- [HIGH] (line 407-442) Create/edit modal submits are broken: handleCreateSubmit/handleEditSubmit (useAttendanceAdmin) send combined 'YYYY-MM-DDTHH:MM:00' datetimes for arrival/departure/break, but since commit 519edce (2026-06-09) CreateAttendanceSchema/UpdateAttendanceSchema validate them with timeString (HH:MM only) — every create/edit containing a time is rejected 400 'Čas musí být ve formátu HH:MM' (and the service itself expects full datetimes via new Date(...), so the schema contradicts both client and service); additionally break*start/break_end are absent from CreateAttendanceSchema, so breaks are silently stripped on create. *(verified)\_
- [LOW] (line 433) Edit-mode ShiftFormModal is wired to onShiftDateChange={handleCreateShiftDateChange}, which mutates the CREATE form's state; harmless only because ShiftFormModal never calls onShiftDateChange in edit mode (it uses updateField directly) — misleading dead wiring that corrupts the create form if edit mode ever starts calling it.
- [LOW] (line 28-49) Local UserTotalData interface duplicates UserTotal from useAttendanceAdmin and is applied via an 'as' cast at line 235 — the two shapes can drift silently; import/export the hook's type instead.

## src/admin/pages/AttendanceBalances.tsx

- [LOW] (line 452-453) boxShadow callback uses theme.palette.primary.main instead of theme.vars (repeated at 603-605) — with cssVariables mode this resolves the light-scheme primary (#c73030) even in dark mode (dark primary is #d63031), violating the documented theme.vars convention.

## src/admin/pages/AttendanceCreate.tsx

- [HIGH] (line 80-99) handleSubmit posts the raw form, whose shape the API does not accept: arrival*date/break_start_date/break_start_time/break_end*\*/departure*date are not in CreateAttendanceSchema (silently stripped — breaks and overnight dates never save), times are never combined with dates (unlike useAttendanceAdmin's combineDatetime), so a validated 'HH:MM' reaches createAttendance which does new Date('08:00') → Invalid Date → 500 on work records; and since commit 519edce the always-sent empty-string time fields (arrival_time: "") fail timeString validation, so EVERY submit from this page — including leave records — returns 400. *(verified)\_
- [LOW] (line 53) Manual 'submitting' useState duplicates createMutation.isPending — state that should be derived from the mutation.

## src/admin/pages/AttendanceHistory.tsx

- [MEDIUM] (line 84-149) Local getEasterSunday/getCzechHolidays/getBusinessDaysInMonth duplicate the shared holiday util (holidaysInMonth / src/utils/czech-holidays) which this file also imports — the same computed block mixes both sources (line 245 local vs 247 shared), a two-sources-of-truth divergence risk that directly enables the fund bug above.
- [LOW] (line 245-249) Monthly fund formula (businessDays + holidayCount) _ 8 counts holidays that fall on Sat/Sun as +8h fund (holidaysInMonth returns all holidays; getBusinessDaysInMonth already excludes weekday holidays), so in months with a weekend holiday (5.7.2026 = Sunday, 26.12.2026 = Saturday — verified) the page shows a fund 8h higher than the admin/print computation (rawBizDays _ 8 in useAttendanceAdmin), producing wrong 'Zbývá/Přesčas' numbers for the user. _(severity adjusted by verification: The mechanism is real but the claimed harm is not: at AttendanceHistory.tsx:249 a weekend holiday does add +8h to `fund`, but the SAME +8h flows into `fondUsed` via `calculateFreeHolidayHours` (attendanceHelpers.ts:341-343 credits 8h for EVERY unworked holiday, weekend included; line 269-271 adds it), so `delta = fondUsed - fund` is invariant and the displayed Zbývá/Přesčas (lines 634-638 use `delta` directly; `monthlyFund.remaining/overtime` are never rendered) are NOT wrong — for July 2026 a full-time user gets fund 192, fondUsed 192, "Fond splněn", exactly as the admin model (184/184/0) concludes. What remains is a cosmetic cross-page inconsistency: the "Fond: X/Y" label (line 574) shows both numbers 8h higher than the admin/print fund (`rawBizDays*8`, useAttendanceAdmin.ts:292/520) in the ~1-2 months/year with a weekend holiday (2026-07-05 Sun, 2026-12-26 Sat verified), with the progress bar unaffected since both sides inflate equally — a LOW display-consistency cleanup, not a HIGH wrong-balance bug.)_
- [LOW] (line 204) queryClient from useQueryClient() is declared but never used — dead code (plus the unused import).
- [LOW] (line 258-261) records as unknown as Parameters<typeof calculateFreeHolidayHours>[0] — double-cast type hole that bypasses checking between the page's AttendanceRecord shape and the helper's expected record shape.

## src/admin/pages/AttendanceLocation.tsx

- Clean

## src/admin/pages/AuditLog.tsx

- [MEDIUM] (line 131-180) Query errors are never surfaced: queryFn throws on failure but the component has no isError handling (and queryClient.ts defines no global query error handler), so a 500/network failure renders the misleading EmptyState 'Žádné záznamy k zobrazení' (line 368) instead of an error — a silently swallowed error on a main read path.
- [LOW] (line 210-214) The client auto-attaches the backend's literal DELETE_ALL_AUDIT confirmation token whenever days===0, reducing the server's extra-confirmation safeguard for wiping the entire audit log to a single dropdown selection + Smazat click.

## src/admin/pages/CompanySettings.tsx

- [MEDIUM] (line 488-494) Save payload filters empty rows out of custom_fields but builds supplier_field_order from the UNFILTERED list (getFullFieldOrder over customFields indices), so saved custom_N keys misalign with the saved array — after reload the order filter (idx < customFields.length, line 271-277) drops/mismaps custom fields and the PDF supplier-block order is corrupted whenever an empty custom-field row existed at save time.
- [MEDIUM] (line 345-412) The populate effect resets the entire form/customFields/fieldOrder whenever settingsData changes; with refetchOnWindowFocus:true (queryClient.ts) and the logo-upload invalidation of ['company-settings'] (line 529), a refetch that returns changed data (first logo upload flips has_logo; concurrent edit by another admin) silently clobbers the user's unsaved in-progress edits.
- [MEDIUM] (line 1369-1383) VAT-rates CSV parsing maps empty segments through Number(''.trim())===0 which passes the !isNaN filter, so typing a trailing comma in 'Dostupné sazby DPH' immediately injects a spurious 0% rate into the controlled value (the adjacent currencies field correctly filters empties with .filter(Boolean); this one doesn't).
- [LOW] (line 302-328) URL.createObjectURL/URL.revokeObjectURL side effects live INSIDE the setLogoUrl/setLogoUrlDark updater functions; state updaters must be pure — under React StrictMode double-invocation this creates a second, leaked blob URL per fetch (and double-revokes the previous one).
- [LOW] (line 883) In standalone (non-embedded) mode the bank-accounts management card renders via (canManageBanking || !embedded) even when the user lacks settings.banking, exposing add/edit/delete UI whose requests will only fail server-side — inconsistent with the embedded-mode gating and with the page's own canManageBanking flag (computed at 169 but effectively unused standalone).

## src/admin/pages/Dashboard.tsx

- [HIGH] (line 89-91) totpEnabled is derived from require2FAOptions(), but that query returns the COMPANY-WIDE require*2fa policy flag (verified: src/routes/admin/totp.ts:200-211 reads company_settings.require_2fa), not the user's enrollment — once loaded, the ?? fallback to user.totpEnabled never applies, so DashProfile shows '2FA Aktivní' plus a Deaktivovat button to un-enrolled users whenever the policy is on (blocking enrollment from the profile card) and 'Neaktivní' plus an enable button to enrolled users when it's off; the per-user totp_enabled endpoint (totp.ts:190-197) exists but is never queried. *(verified)\_
- [LOW] (line 86-88) dashboardOptions() types the payload as Record<string, unknown> and the page force-casts it with 'as DashData | undefined' — an unvalidated cast that hides shape drift; the DashData type belongs on the queryOptions in lib/queries/dashboard.ts.
- [LOW] (line 86-87, 309-313) A failed dashboard query is silently swallowed: on error dashLoading goes false and every widget renders with undefined/empty data, with no isError check, alert, or retry affordance anywhere on the page.

## src/admin/pages/InvoiceDetail.tsx

- [HIGH] (line 728) Edit-mode hydration overwrites each item's persisted per-line VAT rate with the invoice-level rate (`vat_rate: Number(inv.vat_rate) || 21`) even though invoice*items.vat_rate is a real DB column returned per-item by the detail endpoint — re-saving a mixed-rate invoice silently corrupts per-line VAT rates in the DB, and the edit form plus the paid view's totals (1544-1587, via createTotals over the hydrated items) display wrong VAT; the `|| 21` also turns a legitimate 0% rate into 21% (same falsy-zero at line 689). *(verified)\_
- [HIGH] (line 1891-1902) Editing 'Text fakturace (na PDF)' on an existing invoice/draft is silently lost: the payload includes billing*text (line 985-998) but UpdateInvoiceSchema (src/schemas/invoices.schema.ts:46-67, plain z.object) has no billing_text field, so Zod strips it before updateInvoice (which supports it via strFields) — the user sees a success toast while the change is dropped. *(verified)\_
- [MEDIUM] (line 951-953) Post-save PDF regeneration error is silently swallowed (`void apiFetch(...).catch(() => {})`) with no log or user feedback — the stored PDF stays stale/missing after an edit and the later 'Zobrazit fakturu' fails confusingly; violates the repo's never-silently-swallow rule (the adjacent comment does not justify the silence).
- [MEDIUM] (line 1102) `if (isEdit && !invoice) return null;` renders a blank page while the detail query loads and PERMANENTLY blank when it fails (404/network) — invoiceQuery.isError is never handled, no error message or retry path.
- [MEDIUM] (line 994) Submit coerces an empty quantity to 0 (`Number(item.quantity) || 0`) and the number inputs explicitly allow min "0" (lines 323, 425), but the server schema uses positiveNumberFromForm (> 0), so a 0-quantity line 400s the entire save with a raw Zod message; client-side validation (970-980) never checks quantities, and the comment claims the coercion makes the payload safe.
- [LOW] (line 1055) Dead parameter: handleViewPdf(\_lang) ignores the language argument that both call sites pass (invoice.language || "cs") — the endpoint serves the stored file regardless.
- [LOW] (line 624) Duplicated state: `notes` mirrors form.notes/invoice.notes (set once at line 703) and is used only in the paid read-only view (1595-1597) — should be derived rather than separate state.
- [LOW] (line 1814-1823) Dead code: the 'cancelled'/'stornovana' variant/color branches can never trigger — VALID_TRANSITIONS in invoices.service.ts only yields ["issued"]/["paid"], and the button label falls back to the raw status string (line 1829) instead of statusLabel like the confirm dialog does.
- [LOW] (line 1012) Fragile route-state coupling: /invoices/new and /invoices/:id are sibling routes rendering the same un-keyed component (AdminApp.tsx:156-157), so React preserves the instance across navigate() after create; the one-shot dataReady-gated hydration effects (658-659, 753-754) never re-run and are compensated only by the invoice-number resync effect (645-653) — any future direct navigation between invoice ids or detail→new will show stale form state.

## src/admin/pages/Invoices.tsx

- [MEDIUM] (line 215, 276-297, 736-740) Search input is not debounced (sibling IssuedOrders.tsx uses useDebounce 300ms): every keystroke re-keys BOTH the paginated list query and the totals query, firing two network requests per character typed.
- [MEDIUM] (line 334-354, 552-557) Mark-as-paid StatusChip is clickable for any user with only invoices.view, but PUT /invoices/:id requires invoices.edit (invoices.ts:181) — view-only users get a guaranteed-403 control; the irreversible-from-the-list mark-paid also fires with no confirmation, unlike delete which is both permission-gated and confirmed.
- [LOW] (line 804) Delete-confirm message interpolates deleteConfirm.invoice?.invoice_number raw — the column is nullable (drafts, schema.prisma:218; the list at line 530 uses documentNumberLabel for exactly this) so deleting a draft shows 'smazat fakturu "null"'; the Invoice type in lib/queries/invoices.ts also wrongly declares invoice_number: string (non-null), hiding this.
- [LOW] (line 611) hasPermission("invoices.view") guard around the PDF IconButton is dead code — the whole page already returned <Forbidden/> at line 305 when that permission is missing, so the check is always true.

## src/admin/pages/IssuedOrderDetail.tsx

- [HIGH] (line 606-611) Hydration coerces with falsy-fallbacks on legitimate zeros: quantity: Number(it.quantity) || 1 turns a saved quantity of 0 into 1, and vat*rate: Number(it.vat_rate) || Number(d.vat_rate) || 21 turns a saved 0% VAT line (0 is a valid VAT_OPTIONS choice, e.g. reverse charge) into 21%/order default — the wrong value is displayed immediately and silently persisted on the next save, changing the order's totals. *(verified)\_
- [HIGH] (line 826-839) handleStatusChange never mirrors the new status into form.status (handleSubmit does at 797-799, and the one-shot hydration won't re-run since dataReady stays true), so after every transition the StatusChip (938), the `editable` flag (651), and the delete-button gate `form.status !== "completed"` (1080) all use the stale status — e.g. a just-completed order still shows an enabled Delete button (and per the comment at 1074-1077 the backend does not reject that delete). _(verified)_
- [MEDIUM] (line 885-890) If the detail query errors (404/network), dataReady never becomes true (hydration bails at !detailQuery.data), so the page renders <LoadingState/> forever with no error message — detailQuery.isError is checked only to skip the first spinner, never to surface the failure.
- [MEDIUM] (line 786-788) Fire-and-forget NAS PDF-archive call swallows all failures with a bare .catch(() => {}) — no console.error, no toast — violating the repo rule that even non-fatal failures must be logged; an order whose PDF never archived to NAS fails completely silently.
- [MEDIUM] (line 651, 1003-1014, 1040-1054) Save paths aren't gated on orders.edit: `editable` ignores permissions and the "Uložit koncept" / "Uložit" buttons render for any user with only orders.view, while PUT /issued-orders/:id requires orders.edit (issued-orders.ts:118) — view-only users get a fully editable form whose save always 403s, inconsistent with the gated "Odeslat" (1015) and transition buttons (1056).

## src/admin/pages/IssuedOrders.tsx

- [LOW] (line 281) hasPermission("orders.view") guard around the PDF IconButton is dead code — the page already returned <Forbidden/> at line 168 without that permission, so the check is always true.
- [LOW] (line 376) User-facing empty-state text has an unbalanced Czech quote: 'tlačítkem „Vytvořit objednávku.' opens with „ but never closes (compare the paired „…" at line 418).

## src/admin/pages/LeaveApproval.tsx

- [MEDIUM] (line 134-146) Query errors are silently swallowed: no isError handling on either useQuery and the app's QueryClient (src/admin/lib/queryClient.ts) has no global QueryCache onError, so a 403/500/network failure renders 'Žádné čekající žádosti' as if nothing awaits approval instead of surfacing an error.
- [LOW] (line 141-146) Unchecked `as RawLeaveRequest[]` casts over queries that lib/queries/leave.ts types as Record<string, unknown>[] — the payload type (incl. Prisma relation names) should live on leavePendingOptions/leaveProcessedOptions via jsonQuery<RawLeaveRequest[]> instead of a page-level assertion that hides server payload drift.
- [LOW] (line 137-140) The lazily-enabled processed query has no loading state: switching to the 'Vyřízené' tab shows the 'Zatím žádné vyřízené žádosti' empty state (lines 429-437) while the fetch is still in flight, because undefined data falls back to [] and only the pending query's isPending gates LoadingState.

## src/admin/pages/LeaveRequests.tsx

- [MEDIUM] (line 76-78) Query errors are silently swallowed: no isError handling and no global QueryCache onError, so a failed fetch renders the 'Zatím nemáte žádné žádosti' empty state instead of an error — the user believes they have no leave requests.

## src/admin/pages/Login.tsx

- [MEDIUM] (line 160, 219-220) `minHeight/maxHeight: ["100vh", "100dvh"]` looks like a CSS fallback but in MUI sx an array is responsive breakpoints — xs (phones) gets 100vh and only sm+ gets 100dvh, giving mobile (where dvh matters) the exact phantom-scroll/mis-centering behavior commit 78cd7cf just fixed in the shell; should be a plain "100dvh" (or a real @supports fallback).
- [LOW] (line 256) Hardcoded decorative orb color `rgba(120, 119, 198, 0.13)` bypasses the theme.vars convention (the sibling orb at line 241 correctly uses `rgba(var(--mui-palette-primary-mainChannel) / 0.16)`), so it doesn't adapt across color schemes.

## src/admin/pages/NotFound.tsx

- Clean

## src/admin/pages/Odin.tsx

- Clean

## src/admin/pages/OfferDetail.tsx

- [MEDIUM] (line 125-136) emptyForm is a module-level constant, so created_at: todayLocalStr() is evaluated once at first lazy-load of the page; in a long-lived SPA tab, every new offer created on a later day (e.g. after midnight) defaults to the stale date — the helper must be called at state-init time, not module scope.
- [MEDIUM] (line 741-743) Create-mode dirty snapshot is captured during the first render, before the async initializers run (next-number effect at 698-704 sets quotation_number, company-settings effect at 580-594 sets currency/vat_rate), so an untouched 'Nová nabídka' page becomes permanently isDirty and fires a spurious beforeunload 'unsaved changes' prompt; also a ref write during render (side effect in render phase).
- [MEDIUM] (line 679-681) Lock acquisition result is never inspected and errors are swallowed with a bare .catch(() => {}): a 423 'locked by another user' response (race where two users open the offer within the lock window) is silently ignored, the page stays editable, and the server PUT (quotations.ts:285-318) enforces no lock ownership — silent last-write-wins overwrite.
- [MEDIUM] (line 711-725) Heartbeat interval (10 s) exactly equals the server's LOCK_TIMEOUT_MS (10 s, quotations.ts:25) with zero safety margin; browser background-tab timer throttling (or laptop sleep) lets the lock expire mid-edit, after which heartbeats become owner-scoped no-ops and the user keeps editing unaware that another user can acquire the lock and be overwritten on save.
- [MEDIUM] (line 847-851) Post-save PDF generation/NAS archival (offers-pdf?save=1) failure is silently swallowed by an uncommented bare .catch(() => {}) — no log, no toast — while the user is shown a success message; violates the 'never silently swallow, log at minimum' rule and leaves the offer without its archived PDF ('PDF soubor nenalezen' surfaces only much later).
- [MEDIUM] (line 823) Client coerces an emptied quantity input to 0 (Number(x) || 0) but the server schema requires > 0 (QuotationItemSchema.quantity = positiveNumberFromForm, offers.schema.ts:14), and handleSave's pre-validation (805-811) never checks item quantities — saving with a cleared/zero quantity 400s the whole form with only a generic toast and no field-level error.
- [LOW] (line 817) const payload: any defeats type-checking of the request body and leaks internal fields (\_key, existing item id) to the API — harmless today only because the server schema is a non-strict z.object that strips unknown keys.
- [LOW] (line 1733) Reorderable/removable scope sections are keyed by array index (key={idx}) while the items list correctly uses stable \_key; swap/remove re-associates DOM state (focus, Quill editor instance) with the wrong section — content stays correct only because all inputs are controlled.
- [LOW] (line 1023-1028) Permission gate requires offers.edit just to open an active offer's detail (offers.view only suffices for invalidated/completed), even though the page already has a complete readOnly rendering mode (used for lock-by-other) — a view-only user is inconsistently Forbidden from viewing active offers.

## src/admin/pages/Offers.tsx

- [LOW] (line 568) isFiltered omits customerFilter, so when only the customer filter is active an empty result shows the "Zatím nejsou žádné nabídky" create-CTA empty state instead of the "nothing matches the filter" message.
- [LOW] (line 449-450) handleCreateOrder silently returns when the required customer order number is empty — clicking the modal's "Vytvořit" button does nothing with no feedback (other handlers alert.error on validation failure).
- [LOW] (line 717) hasPermission("offers.view") guard inside the actions column is always true (the page already returned <Forbidden/> at line 436 without it) — dead check.
- [LOW] (line 398-434) Mutations type TOut as { message?, error? } but useApiMutation resolves result.data (e.g. {id}) — the server's top-level success message is never surfaced, so data?.message in onSuccess is always undefined and the fallback string always shows; the typing misrepresents the actual payload.

## src/admin/pages/OffersCustomers.tsx

- [MEDIUM] (line 325-335) Submit payload filters out empty custom fields but customer_field_order (getFullFieldOrder, lines 224-244) is built from UNFILTERED customFields indices — if an empty row precedes filled ones, the saved custom_N keys point at shifted indices, producing wrong/phantom field ordering in the PDF address block and on re-edit.
- [LOW] (line 185-222) Same envelope-typing issue as Offers.tsx: TOut typed { message?, error? } but useApiMutation returns the data payload, so data?.message is always undefined and server success messages are never shown.

## src/admin/pages/OffersTemplates.tsx

- [MEDIUM] (line 392-397) default_price: parseFloat(e.target.value) yields NaN when the price field is cleared (no guard/fallback); the controlled input gets value={NaN} (React warning, field blanks) and JSON.stringify serializes NaN as null, which the server's numberFromForm rejects → save fails with an opaque 400. This is the client-side variant of the banned NaN-coercion bug class.
- [MEDIUM] (line 490-527) ScopeTemplatesTab openEdit silently does nothing when the detail response is success:false (404 deleted template, server error returning JSON, etc.) — only thrown/network errors reach the catch+alert; the failure path is swallowed and the edit click appears to do nothing.
- [LOW] (line 189-213, 445-472) Same envelope-typing issue: mutation TOut typed { message?, error? } but useApiMutation resolves result.data ({id} or null), so data?.message is always undefined and server messages like "Položka byla uložena" are never displayed.

## src/admin/pages/OrderDetail.tsx

- [MEDIUM] (line 163-182) statusMutation and orderDeleteMutation invalidate only ["orders","invoices"], but the server propagates status 'dokoncena' to the linked project (orders.service.ts ORDER_TO_PROJECT_STATUS) and deleteOrder deletes linked projects — ["projects"] cache stays stale after both actions, violating the invalidate-every-touched-domain convention (the page's own dialogs at 790/806 even announce the project side-effects).
- [MEDIUM] (line 132-135) isDirty useMemo reads initialNotesRef with deps [notes]: (a) the falsy guard `!initialNotesRef.current` means orders whose saved notes are "" can never become dirty, so the beforeunload guard never arms on the common empty-notes case; (b) after handleSaveNotes updates the ref (line 206) the memo is not recomputed, so the unsaved-changes prompt incorrectly persists after a successful save.
- [LOW] (line 437) hasPermission("orders.view") is always true here — the page already returned <Forbidden/> at line 184 for users without it; dead guard.

## src/admin/pages/Orders.tsx

- [LOW] (line 9-11) Lazy-loading is inverted: the DEFAULT tab component (IssuedOrders, rendered for tab=vydane) is React.lazy with a Suspense fallback flash on every cold visit, while the secondary-tab ReceivedOrders is eagerly bundled into this chunk even when never shown.

## src/admin/pages/PlanWork.tsx

- [MEDIUM] (line 261-263) goToToday passes a raw local-time `new Date()` as the anchor, but usePlanWork derives the visible range with UTC getters (startOfWeek/getUTCMonth) — between local midnight and 01:00/02:00 Prague the UTC date is the previous day, so 'Dnes' shows the previous week (on Mondays) or previous month (on the 1st), disagreeing with the local-time todayIsoLocal() highlight/past-date gate; same banned UTC-today class as toISOString date-slicing.
- [MEDIUM] (line 185) any-typed mutation payloads/response defeat usePlanWork's typed contracts (PlanEntryBody/PlanOverrideBody etc.): `const data: any` here plus `body: any` in saveEntry (394), updateEntryFn (411), saveOverride (475) and updateOverrideFn (490) — field accesses like body.user_id/body.date_from/body.shift_date and summary.skipped_days compile with no checking, so a renamed field silently breaks pulse targeting/rollback.
- [LOW] (line 27) Stale comment claims 'plan.css is imported once globally in AdminApp.tsx' — no .css files exist anywhere under src/admin (plan.css was removed in the MUI migration; PlanGrid.tsx:18 documents the replacement).
- [LOW] (line 137) Project selector source is hard-capped at projectListOptions({ perPage: 200 }) with no pagination/search — once the company exceeds 200 projects (one is auto-created per order) older projects silently disappear from the plan modal's picker.

## src/admin/pages/ProjectDetail.tsx

- [MEDIUM] (line 139-141) Type lie breaks the inactive-assignee retention: ProjectData.responsible_user_id is declared string but the API returns the Prisma Int (projects.service.ts spreads the row; schema.prisma `responsible_user_id Int?`), so `String(u.id) === assignedUserId` is never true — a project assigned to a now-inactive user drops them from the select (out-of-range/empty value) despite the comment's stated intent, and form.responsible_user_id holds a number behind a string type.
- [MEDIUM] (line 600-601) Linked order's status is rendered via the local project STATUS_LABELS map (keys aktivni/dokonceny/zruseny), but order statuses are prijata/v_realizaci/dokoncena/stornovana — no key ever matches, so the raw DB value (e.g. 'v_realizaci') is always shown; should use ORDER_STATUS/statusLabel from lib/documentStatus, the documented single source of truth this duplication regresses.
- [MEDIUM] (line 177-190) projectSaveMutation invalidates only ["projects","warehouse"], but orders embed project data (OrderDetail renders order.project.project_number — name) — renaming or restatusing a project leaves linked order queries stale, violating the 'entity B's CRUD invalidates embedding domain A' convention (should also invalidate ["orders"]).

## src/admin/pages/Projects.tsx

- [HIGH] (line 153-154) Create modal submits createForm verbatim (line 207) with start*date/end_date initialized to "" — server CreateProjectSchema uses isoDateString.nullish(), which rejects "" (verified: safeParse fails with 'Datum musí být ve formátu YYYY-MM-DD'), so creating a project without filling BOTH dates (the default path; only 'name' is validated client-side) always 400s; the fields must be sent as null/omitted when empty. *(verified)\_
- [LOW] (line 158) Local `creating` state duplicates `createMutation.isPending` (state-that-should-be-derived); the delete dialog at line 439 already uses `deleteMutation.isPending` correctly.

## src/admin/pages/ReceivedInvoices.tsx

- [MEDIUM] (line 249) Search input is not debounced (sibling pages Projects/ReceivedOrders use useDebounce(300)); raw `search` keys both the list query (266-274) and the totals query (285-291), so every keystroke fires two fresh network requests.
- [MEDIUM] (line 389-395) removeUploadFile deletes uploadErrors[idx] but does not reindex higher-keyed entries while uploadFiles/uploadMeta shift down — after removing a file, remaining validation errors render on the wrong file cards (or vanish).
- [LOW] (line 656-662) The unpaid StatusChip's toggle-to-paid onClick is not gated by hasPermission("invoices.edit"), unlike the edit/delete buttons (707, 717) — users without edit permission get a clickable chip that fails with a server-error alert.
- [LOW] (line 200-205) Local formatMultiCurrency duplicates the identical shared export in src/admin/utils/formatters.ts (which ReceivedOrders.tsx imports) — violates the single-source convention.
- [LOW] (line 253-254) `saving`/`deleting` flags duplicate editMutation.isPending/deleteMutation.isPending (state-that-should-be-derived), and `saving` is shared between the upload and edit modals.

## src/admin/pages/ReceivedOrders.tsx

- Clean

## src/admin/pages/Settings.tsx

- [MEDIUM] (line 190-211) Roles/permissions query has no isError handling and no global QueryCache onError exists, so a fetch failure silently renders an empty roles DataTable as if zero roles exist (error swallowed, user sees wrong data).
- [MEDIUM] (line 1172-1173) Admin-role info banner uses solid bgcolor "info.light" + color "info.dark" — violates the documented MUI convention (channel-alpha washes via theme.vars, never solid .light fills; info.light stays a light fill in dark mode and clashes with the dark scheme).
- [LOW] (line 193-198) Redundant double-await: apiFetch(...).then(r => r.json()) is already resolved by Promise.all, so `const rolesResult = await rolesRes` / `await permsRes` are no-op awaits on plain values (confusing dead code).
- [LOW] (line 917-923) Number(e.target.value) coercion (also at 928-938, 974-1010, 1111-1121) silently turns a cleared numeric field into 0; server numberInRange(1,…) then 400s the entire settings save for max_login_attempts/lockout_minutes/max_requests_per_minute, while break_threshold_hours=0 is silently accepted — no empty/NaN guard client-side.
- [LOW] (line 680) `systemInfo as Record<string, any>` any-cast discards all typing for the system-info rendering (si.memory?._, si.database?._, si.nas?.\*) — a renamed backend field would silently render undefined instead of failing typecheck.

## src/admin/pages/Trips.tsx

- [HIGH] (line 324-336) Stat-card totals (Počet jízd / Celkem naježděno / Služební / Soukromé, lines 458-482) are computed from tripListOptions({}) whose backend response is paginated at the default 25 rows — once a user has >25 trips the totals silently undercount (count caps at 25); the header (435-439) additionally labels these stats with the current month although the query has no month filter. _(verified)_
- [MEDIUM] (line 161-166) No isError handling for the trips query (and no global QueryCache onError): on fetch failure tripsLoading ends, trips=[] and the page shows zeroed stats plus the misleading empty state "Zatím nemáte žádné záznamy jízd" (509-518) — error silently swallowed and presented as 'no data'.
- [LOW] (line 186) Write-only state `const [, setLastKm] = useState(0)` — the value is never read; the setLastKm calls (220, 227, 236, 251, 268) only trigger pointless re-renders. Dead state to remove.
- [LOW] (line 282-283) Falsy-zero validation: `if (!form.start_km)` rejects a legitimate 0 starting odometer (the last-km prefill at 229 sets start_km to numeric 0 for a vehicle with no trips, then validation blocks submit with 'Zadejte počáteční km').

## src/admin/pages/TripsAdmin.tsx

- [HIGH] (line 295, 665-676) Edit modal offers a vehicle Select and sends vehicle*id in the PUT body, but UpdateTripSchema (src/schemas/trips.schema.ts:23-31) has no vehicle_id and the PUT /trips/:id handler never applies it — changing the vehicle is silently dropped while the UI reports 'Upraveno' success. *(verified)\_
- [MEDIUM] (line 220-229) List fetched with perPage: 100 (server also clamps maxLimit=100) and the page has no pagination UI — months with >100 trips silently truncate the table, the client-computed stat totals, and the printed 'Kniha jízd' report (wrong totals with no indication; pagination meta is discarded by jsonQuery).
- [LOW] (line 285-289) Client km validation is vacuous when a field is empty: parseInt('') yields NaN and NaN comparisons are false, so the end>start guard passes and the server rejects with the misleading message 'Číslo nesmí být záporné' (nonNegativeNumberFromForm NaN refine).

## src/admin/pages/TripsHistory.tsx

- [HIGH] (line 98-104, 121-128) tripHistoryOptions sends no per*page, so the server default limit of 25 applies (src/utils/pagination.ts defaultLimit=25); months with >25 trips silently show only the newest 25 and the stat cards (Počet jízd / Celkem naježděno / Služební km) are computed from the truncated list — wrong monthly totals with no pagination UI or indicator. *(verified)\_

## src/admin/pages/UiKit.tsx

- Clean

## src/admin/pages/Users.tsx

- [MEDIUM] (line 243-248) toggleUser.mutate() is fired with no onError and there is no global MutationCache error handler (src/admin/lib/queryClient.ts), so a failed activate/deactivate is silently swallowed — no alert, the status chip just stays stale, violating the 'never silently swallow errors' rule.
- [LOW] (line 105, 221-231) saving state duplicates saveUser.isPending (state that should be derived — the Modal could take loading={saveUser.isPending} directly).
- [LOW] (line 173, 339, 300-323) Page is gated only on users.view but unconditionally renders create/edit/delete/toggle controls; the server requires users.create/users.edit/users.delete, so view-only users get 403 error alerts instead of hidden buttons (other pages, e.g. Warehouse, gate actions per permission).

## src/admin/pages/Vehicles.tsx

- [MEDIUM] (line 218-223) toggleVehicle.mutate() is fired with no onError and no global mutation error handler exists, so a failed activate/deactivate is silently swallowed (no alert, chip stays stale).
- [LOW] (line 98-101) select: (data) => data as unknown as Vehicle[] — unchecked double-cast through unknown over the untyped vehicleListOptions (Record<string, unknown>[]); any server shape drift (e.g. current_km/trip_count renames) would be hidden from the type-checker — the lib query should be typed like trips.ts BackendTrip instead.
- [LOW] (line 417-429) Delete ConfirmDialog is missing loading={deleteVehicle.isPending} (Users.tsx passes it), so the confirm button is not locked during the in-flight DELETE and a double-click fires a second request.
- [LOW] (line 119, 199-206) saving state duplicates saveVehicle.isPending (state that should be derived).

## src/admin/pages/Warehouse.tsx

- [LOW] (line 98-102) Dead 'as' casts: stockItems/belowMinItems are already typed WarehouseItem[] | undefined by warehouseStockStatusOptions/warehouseBelowMinimumOptions, and reservationsData is already PaginatedResult<WarehouseReservation>; the redundant casts would mask future type drift in the query lib.

## src/admin/pages/WarehouseCategories.tsx

- Clean

## src/admin/pages/WarehouseInventory.tsx

- [LOW] (line 126-131) Inline intersection cast `(s as WarehouseInventorySession & { _count?: { items: number } })` works around `_count` missing from the WarehouseInventorySession interface in lib/queries/warehouse.ts — WarehouseIssue/WarehouseReceipt declare it on the type; add it there instead of casting at the call site.

## src/admin/pages/WarehouseInventoryDetail.tsx

- Clean

## src/admin/pages/WarehouseInventoryForm.tsx

- Clean

## src/admin/pages/WarehouseIssueDetail.tsx

- Clean

## src/admin/pages/WarehouseIssueForm.tsx

- [MEDIUM] (line 149-163) BatchSelect's "-- auto FIFO --" option is a no-op once a batch is chosen: selecting it yields `Number("") === 0`, `batches.find` returns undefined, and `onChange` (whose signature can't even pass null) never fires — the user cannot revert a row to auto-FIFO and stays pinned to a manually picked batch, with stale unit*price, unless they delete and re-add the row. *(severity adjusted by verification: Mechanics confirmed: Select (src/admin/ui/Select.tsx:31) emits "" for the auto-FIFO option, and WarehouseIssueForm.tsx:151-154 does Number("")===0 → find→undefined → guarded onChange (typed (number, number), line 124) never fires, so the row stays pinned to the picked batch with no other reset path (ItemPicker at line 527 doesn't clear batch*id). However, severity is overstated: the stale unit_price is display-only (buildPayload at lines 342-354 never submits it; the server prices from the batch), the pinned batch is valid user-chosen data that the server validates (warehouse.service.ts:612-628), and delete/re-add-row is an immediate workaround — a real UX/functional gap, not likely practical damage, so MEDIUM.)*
- [MEDIUM] (line 524-536) Changing a row's item via ItemPicker does not reset batch_id/reservation_id/unit_price — the stale batch from the previous item is kept (BatchSelect renders an out-of-range value against the new item's batch list) and submitted, which the server rejects at confirm with a confusing 400 "Šarže ID X nepatří k položce ID Y" (verified in warehouse.service.ts:614).
- [MEDIUM] (line 233-234) Project dropdown loads only `projectListOptions({ perPage: 100 })` (page 1, no search) — with more than 100 projects, the missing ones cannot be selected, making it impossible to create an issue for them.

## src/admin/pages/WarehouseIssues.tsx

- [LOW] (line 76-77) Project filter dropdown is capped at the first 100 projects (`projectListOptions({ perPage: 100 })`, no search) — issues belonging to projects beyond that page cannot be filtered for.

## src/admin/pages/WarehouseItemDetail.tsx

- [HIGH] (line 451-462) "Jednotka" is a free-text TextField, but the server requires UnitEnum ("ks","m","kg","bal","sada","m2","m3","l") in CreateItemSchema/UpdateItemSchema (warehouse.schema.ts:80,90, enforced via parseBody at warehouse.ts:729/787) — any other value (e.g. mobile-autocapitalized "Ks", "litr") makes create/update fail with a 400; should be a Select over the enum values. _(verified)_
- [LOW] (line 148-153) Any itemQuery error — including a transient network failure or 500 — is reported as "Položka nenalezena" and force-navigates away from the page; the effect also suppresses exhaustive-deps via eslint-disable instead of listing alert/navigate/isNew.

## src/admin/pages/WarehouseItems.tsx

- Clean

## src/admin/pages/WarehouseLocations.tsx

- [LOW] (line 104-153) Update/toggle/delete mutations include `id` inside the JSON body, contradicting the comment at 104-106 that TIn must match UpdateLocationSchema exactly; this only works because the schema is non-strict z.object (stripping unknown keys) — it would 400 if the schemas are migrated to the repo-convention z.strictObject.

## src/admin/pages/WarehouseReceiptDetail.tsx

- [HIGH] (line 251-259) Attachment download link targets GET /api/admin/warehouse/receipts/:id/attachments/:attachmentId, a route that does not exist (warehouse.ts registers only POST upload at 1299 and DELETE at 1374 — no GET/download handler), and even if it existed a plain <a href> sends no Authorization header (middleware/auth.ts requireAuth is Bearer-header-only, no cookie fallback) — every attachment click fails (404/401), so attachments can never be opened from the UI. _(verified)_
- [LOW] (line 279-290) Attachment delete is a single-click irreversible action (deletes NAS file + DB row) with no ConfirmDialog, inconsistent with the confirm-guarded cancel action in the same page.

## src/admin/pages/WarehouseReceiptForm.tsx

- [MEDIUM] (line 52-56) parseDecimal strips e/E/+/- then parses, silently mangling instead of rejecting input used for quantity and unit_price (544-570): "1e3" (valid content in a type=number input) becomes 13, "-5" becomes 5, and unparseable pastes like "1 234,50" become 0 — wrong quantities/prices flow into FIFO stock valuation with no validation error (unit_price has no client check at all; server allows 0).
- [LOW] (line 306-336) Rows where the user filled quantity/price but didn't pick an item are silently dropped from the payload (filter at 328) and skipped by validate(), so the receipt saves without that line and without any warning.
- [LOW] (line 229-252) handleFileUpload always ends with the blanket success toast "Soubory byly nahrány" even when individual file uploads failed (per-file error alerts fire first, then a contradictory success message).
- [LOW] (line 179-181) delivery_note_date round-trip hand-rolls receipt.delivery_note_date.split("T")[0] instead of the documented normalizeDateStr client helper (functionally safe here since the server serializes local time, but a convention deviation duplicating date-string parsing).

## src/admin/pages/WarehouseReceipts.tsx

- [LOW] (line 113-116) Hand-rolled Czech plural ternary duplicates the czechPlural formatter already used for the same purpose in WarehouseItems.tsx:83 — should call czechPlural(total, "doklad", "doklady", "dokladů").

## src/admin/pages/WarehouseReports.tsx

- [LOW] (line 57-61) Dead code: MOVEMENT_COLOR contains an 'inventory' key, but the /reports/movement-log endpoint (src/routes/admin/warehouse.ts:2423) only returns receipt+issue rows and TYPE_LABEL (line 413) has no inventory label.
- [LOW] (line 248-254) Project filter dropdown fetches only projectListOptions({ perPage: 100 }) page 1 (server parsePagination caps per_page at 100), so with >100 projects the consumption filter silently omits the rest.

## src/admin/pages/WarehouseReservations.tsx

- [MEDIUM] (line 139-145) Project dropdowns (filter + create-reservation modal) load only the first 100 projects (projectListOptions({ perPage: 100 }), server caps per_page at 100) — once >100 projects exist, a reservation cannot be created for, or filtered by, any omitted project.
- [LOW] (line 126, 137, 202-214, 219-226) saving/cancelling useState mirrors createMutation.isPending/cancelMutation.isPending — state that should be derived (sibling WarehouseSuppliers correctly uses submitMutation.isPending for the same purpose).

## src/admin/pages/WarehouseSuppliers.tsx

- [MEDIUM] (line 147-155, 500-511) Misleading delete: for an inactive supplier the dialog says 'Smazat… Tato akce je nevratná' and the toast says 'Dodavatel byl smazán', but DELETE /suppliers/:id (warehouse.ts:382-412) is always a soft-deactivate — nothing is deleted; the fallback toast always fires because performMutation returns result.data (null here), so the delete/toggle mutations' TOut typing { message?: string } never matches the actual payload.
- [LOW] (line 246-253) Re-implements Czech pluralization inline ('dodavatel/dodavatelé/dodavatelů') instead of using the existing czechPlural helper from utils/formatters (used by the sibling WarehouseReservations page).

## src/admin/theme.test.ts

- Clean

## src/admin/theme.ts

- Clean

## src/admin/ui/Alert.tsx

- Clean

## src/admin/ui/AppShell.tsx

- Clean

## src/admin/ui/Button.tsx

- Clean

## src/admin/ui/Card.tsx

- Clean

## src/admin/ui/Checkbox.tsx

- Clean

## src/admin/ui/ConfirmDialog.tsx

- Clean

## src/admin/ui/CustomerPicker.tsx

- Clean

## src/admin/ui/DataTable.tsx

- Clean

## src/admin/ui/DateField.tsx

- [LOW] (line 9-23) DateField silently drops the id/aria-describedby/aria-invalid props that <Field> injects via cloneElement (unlike kit TextField/Select which spread rest props, and CustomerPicker which forwards id), and has no error prop — so in every <Field error><DateField/></Field> usage (e.g. InvoiceDetail.tsx:1911, DashQuickActions.tsx:394) the label htmlFor points at a non-existent id (broken click-to-focus/SR association) and the input never shows the error border despite the Field-level error.

## src/admin/ui/EmptyState.tsx

- Clean

## src/admin/ui/Field.tsx

- Clean

## src/admin/ui/FileUpload.tsx

- Clean

## src/admin/ui/FilterBar.tsx

- Clean

## src/admin/ui/LoadingState.tsx

- Clean

## src/admin/ui/Modal.tsx

- Clean

## src/admin/ui/MonthField.tsx

- Clean

## src/admin/ui/MuiProvider.tsx

- Clean

## src/admin/ui/PageEnter.tsx

- Clean

## src/admin/ui/PageHeader.tsx

- Clean

## src/admin/ui/Pagination.tsx

- [LOW] (line 18) Math.min/Math.max clamps only the _displayed_ page without notifying the parent via onChange, so when page > pageCount (e.g. last-page items deleted) the parent keeps fetching the stale out-of-range page showing an empty list while the pager highlights pageCount as selected — and clicking that highlighted button fires no onChange, so it can't self-correct.

## src/admin/ui/ProgressBar.tsx

- Clean

## src/admin/ui/RichText.tsx

- Clean

## src/admin/ui/Select.tsx

- Clean

## src/admin/ui/SidebarNav.tsx

- Clean

## src/admin/ui/StatCard.tsx

- Clean

## src/admin/ui/StatusChip.tsx

- Clean

## src/admin/ui/Tabs.tsx

- Clean

## src/admin/ui/TextField.tsx

- Clean

## src/admin/ui/ThemeToggle.tsx

- Clean

## src/admin/ui/TimeField.tsx

- Clean

## src/admin/ui/index.ts

- Clean

## src/admin/ui/navData.tsx

- [LOW] (line 127-144, 227-243, 496-513, 344-356, 539-551, 562-574) Identical inline SVG icon literals are copy-pasted across menu items (sliders icon x3, users icon x3+, clock-history x2, document x3, bar-chart x2, package x2, grid x2) — extract each to a shared constant to avoid drift when an icon is tweaked.

## src/admin/ui/useDialogScrollLock.ts

- Clean

## src/admin/utils/api.ts

- [MEDIUM] (line 102-111) Type-safety hole: `options.headers as Record<string, string>` — RequestInit.headers may be a Headers instance (spreads to {}, silently dropping all caller headers incl. Content-Type) or a [string,string][] (spreads to numeric keys); also the case-sensitive `!headers["Content-Type"]` check adds a duplicate header if a caller used `content-type` lowercase. Normalize HeadersInit instead of casting.
- [LOW] (line 143-153) If the 401-triggered refresh reports success but getTokenFn() returns null, the retry re-sends the original stale `Authorization` header (set at line 115), guaranteeing a second 401 that is returned to the caller without setSessionExpired() being flagged.

## src/admin/utils/attendanceHelpers.ts

- [LOW] (line 142-154) calcFormWorkMinutes guards only the time fields; with an empty date (useAttendanceAdmin.ts:787 initializes arrival_date:"") it builds `"T08:00"` → Invalid Date → NaN, and `Math.max(0, Math.floor(NaN))` is NaN — rendered as "NaN:NaN" via formatMinutes and silently skipping the `totalWork > 0` project-hours validation in useAttendanceAdmin.validateProjectLogs. Should return 0 when any needed date/time is missing or the result is NaN.
- [LOW] (line 251) calculateNightMinutes treats equality as cross-midnight: `if (endMs <= startMs) endMs += 24h`, so a record with arrival_time === departure_time (zero-length shift; calculateWorkMinutes returns 0 for it) is counted as a full 24h shift and yields 480 night minutes on the payroll print. Should be `<` with the `===` case returning 0.
- [LOW] (line 226, 231) Dead code: redundant `as string` casts on `.toFixed(...)` results (toFixed already returns string) in formatHoursDecimal and formatHoursDecimal1.

## src/admin/utils/dashboardHelpers.ts

- Clean

## src/admin/utils/formatters.ts

- Clean

## src/config/database.ts

- [LOW] (line 10-18) Manual URL parse passes only host/port/user/password/database to PrismaMariaDb, silently dropping any DATABASE_URL query options (e.g. ssl, connection_limit, timeouts) — connection-string tuning has no effect with no warning.
- [LOW] (line 21) `process.env.DATABASE_URL as string` with no import of ./config/env (or required()) makes the module depend on every entry point loading dotenv first; if one forgets, the failure is a cryptic `new URL(undefined)` "Invalid URL" TypeError instead of the clear missing-env error (all current entry points — server.ts, seed.ts, scripts — happen to do it right, verified).

## src/config/env.ts

- [LOW] (line 55) `(process.env.TOTP_ALGORITHM || "SHA1") as "SHA1"` is an unvalidated literal-type cast: any env value (e.g. "SHA256" or a typo like "SHA-1") flows into TOTP generation while the type claims "SHA1" — unlike every other security-critical env var in this file, which is validated with a thrown error.

## src/context/ThemeContext.tsx

- Clean

## src/main.tsx

- Clean

## src/middleware/auth.ts

- Clean

## src/middleware/security.ts

- Clean

## src/routes/admin/ai.ts

- [MEDIUM] (line 121) `await part.toBuffer()` is outside the per-file try/catch: an oversized file (RequestFileTooLargeError from the 50MB fileSize limit) or a 21st file (FilesLimitError) throws out of the loop and aborts the whole request, discarding results of extractions already completed — and already billed to the AI usage ledger — for earlier files in the batch.
- [LOW] (line 132-137) `truncated` is set true when the budget trips after processing the LAST file too (break with nothing remaining), so the client can show a false "trailing files were not processed" warning.
- [LOW] (line 155-235) Conversation create / rename / delete endpoints call no logAudit (convention: audit every create/update/delete — the budget PUT in this same file does log; deleteConversation destroys data with no audit trail).

## src/routes/admin/attendance.ts

- [MEDIUM] (line 338-441) POST / punch, leave, and standard-create paths are guarded only by requireAuth — any authenticated user without the attendance.record permission can punch in/out and create their own attendance/leave records via the API (only cross-user writes check attendance.manage; the frontend gates the UI on attendance.record but the backend never does), affecting leave balances/payroll data.
- [MEDIUM] (line 181-184) action=projects branch has no permission check at all (every other action in this GET handler is explicitly guarded) — any logged-in user can list all active projects (id, name, project_number), violating the 'GET reads need a permission guard, not bare requireAuth' convention.
- [LOW] (line 263-368) request.body is cast to Record<string, unknown> without a null guard; a POST with no body/content-type leaves rawBody undefined and `rawBody.punch_action` (line 368) throws a TypeError → 500 instead of a 400.
- [LOW] (line 466-474) Dead re-coercion: Number(body.project_id)/Number(body.leave_hours) plus `as number | null | undefined` casts on values already coerced by nullableIntIdFromForm/numberFromForm in UpdateAttendanceSchema (same redundant Number() at line 85).

## src/routes/admin/audit-log.ts

- [MEDIUM] (line 61-67) Paginated list uses orderBy { created_at: order } with no { id } tiebreak — created_at is second-precision, so same-second rows (common for bulk operations in audit logs) make page boundaries non-deterministic (rows duplicated/skipped across pages); violates the documented 'always tiebreak a timestamp sort with id' rule.
- [LOW] (line 15-18) AuditCleanupSchema uses bare z.number() for `days` instead of the mandatory numberFromForm coercion helper from src/schemas/common.ts — a form-stringified "30" is rejected with a 400.

## src/routes/admin/auth.ts

- [MEDIUM] (line 116-190) Login-token consumption is not atomic: the token is SELECT … FOR UPDATE'd in one transaction (116-150) but only deleted at line 188 after that transaction committed and after TOTP verification — the lock is released without consuming the token, so concurrent requests can both pass validation with the same single-use login token, and the loser's totp_login_tokens.delete then throws P2025 → unhandled 500.
- [MEDIUM] (line 161-200) A failed TOTP code neither consumes the login token nor increments failed_login_attempts/locked_until (the success path resets them at 193-200 but there is no failure counterpart) — code guessing against a stolen password is bounded only by the per-IP rate limit (default 5/min) for the 5-minute token lifetime.
- [LOW] (line 203-240) Duplicated token-issuance logic: re-implements access-token signing + refresh-token creation inline via dynamic import("jsonwebtoken")/import("../../services/auth") instead of reusing the existing generateAccessToken/refresh-token helpers in services/auth.ts — drift risk between the two issuance paths.
- [LOW] (line 302-315) Logout (refresh-token termination) performs no logAudit, although the conventions require auditing security-relevant session/token termination (login, login_failed and login_totp are all audited).

## src/routes/admin/bank-accounts.ts

- [MEDIUM] (line 33-45, 73-114) is_default is not exclusive: creating or updating an account with is_default=true never clears the flag on the previous default, so multiple defaults can coexist and InvoiceDetail.tsx (`bankAccounts.find(a => a.is_default)`) picks one arbitrarily.

## src/routes/admin/company-settings.ts

- [MEDIUM] (line 145-364) GET / is guarded only by bare requireAuth and returns the FULL settings row — including security configuration (max_login_attempts, lockout_minutes, max_requests_per_minute, require_2fa), SMTP sender, alert/notify emails, and all numbering patterns — to every authenticated user; convention requires permission-guarded reads (writes need settings.company/settings.system, the read needs nothing).
- [LOW] (line 197-313) The 'race-safe' create-on-GET transaction takes no row lock and company_settings has no unique constraint, so under MySQL REPEATABLE READ two concurrent first-GETs can both insert a settings row (the comment overstates the guarantee); it is also an unaudited write triggered by a GET.
- [LOW] (line 317-322) Every settings GET loads both logo BLOB columns (up to 5 MB each) just to compute the has_logo/has_logo_dark booleans — a `where: { logo_data: { not: null } }` existence check would avoid pulling the blobs.
- [LOW] (line 483-511) Dead re-validation: strFields String() coercion and the numFields Number()+isFinite guards re-check values already validated/coerced by UpdateCompanySettingsSchema (nonNegativeNumberFromForm/numberInRange/emailOrEmpty), so the 400 branches at 489-490 and 507-508 are unreachable.

## src/routes/admin/customers.ts

- [LOW] (line 16-54) encodeCustomFields/decodeCustomFields are duplicated nearly verbatim from company-settings.ts (only the field_order key name differs) — should be a shared util.
- [LOW] (line 218-224) PUT custom-fields handling is asymmetric with company-settings: sending custom_fields without customer_field_order silently wipes the stored field order (encodeCustomFields gets undefined → []), and sending customer_field_order alone is ignored entirely (company-settings PUT merges with the existing blob; this one does not). Latent today because OffersCustomers.tsx always sends both.
- [LOW] (line 250-265) FK-reference pre-check (4 counts) runs outside any transaction before the delete — a quotation/order/invoice/project linked between the check and the delete yields an FK-violation 500 (or an orphaned reference if the DB lacks the constraint); TOCTOU race.

## src/routes/admin/dashboard.ts

- [LOW] (line 41, 223, 304) Three timestamp sorts (`orderBy: { created_at: "desc" }` for the ongoing-shift findFirst, latest-5 projects, and recent-8 audit logs) lack the documented `{ id: "desc" }` tiebreak, making same-second results non-deterministic.
- [LOW] (line 53) todayStr inlines a re-implementation of localDateStr (the file already imports localTimeStr from utils/date) — correct local-date logic, but duplicated instead of using the canonical helper the conventions mandate.

## src/routes/admin/invoices-pdf.ts

- [MEDIUM] (line 1076-1077) cleanIssued() deletes the previously archived NAS PDF BEFORE htmlToPdf() runs — if Puppeteer generation throws, the old archive is gone and nothing replaces it (generate first, then clean+save).
- [MEDIUM] (line 1078-1084) saveIssuedPdf() returns null on ensureDir/write failure (only console.errors internally), but the return value is ignored and the route still responds success 'PDF uloženo' — silent archive failure.
- [MEDIUM] (line 418-435) VAT recapitulation hardcodes rates [21, 12, 0]; items with any other vat_rate (e.g. legacy Czech 15%/10% on pre-2024 invoices) are silently omitted from the recap table while still counted in totals/vatDetailHtml, so the tax recap no longer sums to the invoice total.
- [LOW] (line 408-410) QR (SPAYD) generation catch swallows the error without logging — a QR failure is not an expected-condition existence check, so per the repo rule it should at least request.log.warn.
- [LOW] (line 98-99) Silent `catch { parsed = null; }` in buildAddressLines with no log and no expected-condition comment (contrast with the properly logged custom_fields parse at 453-462 in the same file).
- [LOW] (line 1065-1087) ?save=1 with NAS unconfigured or a missing invoice_number silently falls through to returning the HTML print view with 200 instead of telling the caller the archive request did nothing.
- [LOW] (line 5-9) Duplicate import statements from "../../utils/date" (localDateCzStr on line 5, localDateStr on line 9) — merge into one import.

## src/routes/admin/invoices.ts

- [MEDIUM] (line 142-176) POST / with a caller-supplied invoice_number (allowed by CreateInvoiceSchema) hits the unique index idx_invoices_number_unique with no in-transaction uniqueness check and no P2002 handling in route or createInvoice — a duplicate number returns an unhandled 500 instead of a 409 (documented convention: re-check inside the create transaction and catch P2002 → 409).
- [LOW] (line 34-40) The onRequest hook runs before the preHandler auth guards, so unauthenticated GETs to any invoice path trigger the markOverdueInvoices DB writes (hourly-throttled and internally error-logged, but still a pre-auth write trigger).
- [LOW] (line 142-150) CreateInvoiceSchema accepts status as a free string (max 20, no enum), so POST / with e.g. status "foo" consumes an official invoice number (createInvoice numbers anything non-draft) and creates a record outside the VALID_TRANSITIONS state machine (no valid transitions out).

## src/routes/admin/issued-orders-pdf.ts

- [MEDIUM] (line 854-855) cleanIssued() deletes the previously archived NAS PDF BEFORE htmlToPdf() runs — a Puppeteer failure leaves the order with no archived PDF at all (generate first, then clean+save).
- [MEDIUM] (line 856-862) saveIssuedPdf() returns null on write/mkdir failure but the return value is ignored — the route still responds 'PDF uloženo' even when nothing was archived.
- [LOW] (line 41-63) Copied cleanQuillHtml drifted from the invoices-pdf version: line 54 only neutralizes javascript: in href (no data:/vbscript:, no src handling) — DOMPurify still covers notes, but the duplicated defense-in-depth layer is now weaker and inconsistent across the three PDF routes.
- [LOW] (line 14-157) ~150 lines of helpers (formatDate/formatNum/escapeHtml/cleanQuillHtml/buildAddressLines) duplicated verbatim from orders-pdf.ts/invoices-pdf.ts (header even says 'copied from orders-pdf.ts') — already drifting (see cleanQuillHtml); should be a shared module.
- [LOW] (line 88-89) Silent `catch { parsed = null; }` in buildAddressLines with no log and no expected-condition comment.

## src/routes/admin/issued-orders.ts

- [MEDIUM] (line 36) Raw `?status=` query string is passed unvalidated into the service's Prisma enum filter (where.status on issued_orders_status) — an invalid value throws a Prisma validation error and surfaces as a 500 instead of a 400 (same on /stats line 68).
- [LOW] (line 37-39) customer_id/month/year query filters coerced with raw Number() and no NaN guard — a malformed value becomes NaN, which the service's falsy check silently drops, returning the unfiltered list instead of a 400 (same on /stats lines 69-71).
- [LOW] (line 98-164) All three logAudit calls (create/update/delete) omit oldValues/newValues — the repo convention says to pass them so the audit diff is stored (most valuable for update/delete of financial documents).

## src/routes/admin/leave-requests.ts

- [MEDIUM] (line 195-299) Vacation-balance cap check (195-210) runs BEFORE the transaction and the in-transaction leave_balances read-modify-write (270-299) takes no SELECT…FOR UPDATE lock — concurrent approvals for the same user can overdraw the balance or lose an increment; the repo convention mandates row locking for balance mutations (cf. attendance.service lockUserRow).
- [MEDIUM] (line 49) orderBy: { created_at: order } with no { id } tiebreak — created_at is second-precision, so paginated ordering is non-deterministic for same-second rows (documented ALWAYS-tiebreak rule).
- [MEDIUM] (line 39-42) Unvalidated `?status=` values are passed straight into the Prisma enum filter (equals or { in }) on leave_requests_status — an invalid status (or trailing comma yielding "") throws a Prisma validation error → 500 instead of 400.
- [LOW] (line 71-78) Dead code: the VALID_LEAVE_TYPES re-check duplicates CreateLeaveRequestSchema's z.enum(["vacation","sick","unpaid"]) which has already validated body.leave_type (also line 19).
- [LOW] (line 375-381) DELETE checks request status (400) before ownership (403), so any authenticated user can probe by id whether another user's leave request exists and whether it is still pending — check ownership first.
- [LOW] (line 251-260) Duplicate-attendance check runs one tx.attendance.findFirst per business day in a loop (N sequential queries inside the transaction) — a single findFirst with shift_date: { in: [...] } would do.
- [LOW] (line 116-123) logAudit calls throughout the file (create 116, approve 329, reject 351, cancel 387) omit oldValues/newValues, so no diff (e.g. previous status) is stored per the audit convention.

## src/routes/admin/offers-pdf.ts

- [MEDIUM] (line 752-771) save=1 path ignores saveOfferPdf's return value (it returns null on a failed NAS write, only console.error-ing inside the manager) and always replies success 'PDF uloženo'; when NAS is unconfigured or quotation_number is empty it silently falls through to serving the HTML instead of reporting the save was skipped — NAS archival failures are invisible to callers.
- [LOW] (line 31-49) formatCurrency drops the negative sign for USD and GBP (Math.abs(n).toFixed(...) without re-prepending '-' as formatNum does for EUR/CZK), so negative line items (discounts) render as positive amounts in those currencies.
- [LOW] (line 220) GET endpoint with a write side effect (?save=1 writes/overwrites the archived PDF on the NAS) is gated only by offers.view — a view-only user can trigger NAS writes; contrast with orders-pdf.ts which deliberately gates its write-ish path behind orders.edit.

## src/routes/admin/orders-pdf.ts

- [LOW] (line 62-84) This cleanQuillHtml copy drifted from the offers-pdf/invoices copies: it strips only 'javascript:' in href and does not strip 'data:'/'vbscript:' schemes or the src attribute (offers-pdf.ts lines 78-87 does, and its comment claims the copies match). Defense-in-depth only since DOMPurify.sanitize runs first, but the triplicated helper invites exactly this drift — extract it to a shared util.

## src/routes/admin/orders.ts

- [MEDIUM] (line 134-141) The multipart parts() loop only consumes file parts named 'attachment'; any file part with another fieldname is skipped without draining its stream, and per @fastify/multipart 9.4.0 docs an unconsumed file stream means the iterator promise never fulfills — the request hangs indefinitely (Fastify has no default connection timeout). Add an else branch that drains (part.file.resume() or await part.toBuffer()).
- [MEDIUM] (line 342-344) DELETE body bypasses parseBody/Zod (raw 'request.body as Record<string, unknown>') and '!!body?.delete_files' is truthy for the string "false" — convention requires validating every body (booleanFromForm exists exactly for this string-boolean coercion).
- [LOW] (line 245-247) Dead code: 'if (!quotationId || isNaN(quotationId))' can never trigger — CreateOrderFromQuotationSchema's intIdFromForm already guarantees a valid positive integer (verified in src/schemas/orders.schema.ts).

## src/routes/admin/plan.ts

- Clean

## src/routes/admin/profile.ts

- [MEDIUM] (line 70-86) The email-uniqueness 'transaction' does not prevent the race its comment claims: a plain findFirst inside $transaction takes no lock (MySQL REPEATABLE READ won't serialize two concurrent checks), so the real backstop is the @unique index on users.email — but the resulting P2002 is NOT caught (only the 'EMAIL_EXISTS' Error is) and rethrows as a 500. Convention requires catching P2002 → 409.
- [LOW] (line 88-95) logAudit for the profile update passes only a description — no oldValues/newValues diff for the email/first_name/last_name change (convention: pass old/new values so the diff is stored; exclude password_hash).

## src/routes/admin/project-files.ts

- Clean

## src/routes/admin/projects.ts

- [MEDIUM] (line 26-31) DeleteProjectSchema uses the banned raw z.union([z.boolean(),z.string(),z.number()]).transform(...) idiom for a form boolean instead of the mandated booleanFromForm helper from src/schemas/common.ts (which does exactly this).
- [LOW] (line 90, 128, 161) `(result as any).status` any-casts on service results defeat type-checking; the service returns a typed {error,status} shape that could be narrowed instead.

## src/routes/admin/quotations.ts

- [LOW] (line 189-217) POST /:id/lock reads then updates the lock without a transaction/row lock — a TOCTOU race lets two users both pass the freshness check and both acquire the edit lock (advisory lock, low stakes).

## src/routes/admin/received-invoices.ts

- [MEDIUM] (line 142) List orderBy `{ [sortField]: order }` has no `id` tiebreak; created_at is @db.Timestamp(0) (second-precision) and issue_date/due_date are date-only, so paginated results over same-value rows are non-deterministic (skips/dupes across pages).
- [MEDIUM] (line 390-398, 429-431, 489, 617) issue_date/due_date are validated only as plain strings (schema uses z.string().max(255), not isoDateString), so a malformed value flows into `new Date(String(...))` → Invalid Date written to DB, and in the multipart path Invalid Date.getMonth()/getFullYear() yields NaN month/year persisted to Int columns (crash/data-corruption).
- [LOW] (line 296-301) File route serves the uploaded `file_mime` with Content-Disposition: inline and no content-type allowlist (upload accepts any mimetype); an uploaded text/html invoice would render inline. Largely mitigated by global nosniff + prod CSP (script-src 'self' blocks inline JS), and requires a privileged uploader.
- [LOW] (line 382-444) Multipart create writes each NAS file then creates each DB row sequentially with no surrounding transaction; a mid-batch failure leaves partially-created invoices and/or an orphaned NAS file (NAS save precedes the DB create).

## src/routes/admin/roles.ts

- [MEDIUM] (line 83-109) Role-name uniqueness is checked with findFirst BEFORE the create transaction (TOCTOU) and the create has no P2002 catch; roles.name is @unique, so two concurrent creates of the same name surface as an unhandled 500 instead of the intended 409 (violates the in-transaction-uniqueness + P2002 convention).
- [LOW] (line 151-174) The roles.update (display_name/description) runs outside the role_permissions delete+recreate transaction, so a failure between them leaves the name updated but permissions in an inconsistent state.

## src/routes/admin/scope-templates.ts

- [LOW] (line 83-96, 101-108, 142-154, 179-185, 259-278, 298-305) All logAudit calls in this file omit `entityType`, so audit rows for scope/item templates store entity_type = null (inconsistent with every other route and breaks filtering by entity type).

## src/routes/admin/sessions.ts

- [LOW] (line 63) Sessions list orderBy created_at desc has no `id` tiebreak; created_at is @db.Timestamp(0), so same-second sessions order non-deterministically (display-only, small per-user set).

## src/routes/admin/totp.ts

- Clean

## src/routes/admin/trips.ts

- [MEDIUM] (line 243) POST lets any holder of trips.record set an arbitrary body.user_id, creating trips attributed to other users — no self-restriction, inconsistent with the PUT (309-312) and DELETE (367-370) handlers which restrict non-managers to their own trips. Authorization gap allowing trip spoofing for other users.
- [MEDIUM] (line 73) Paginated list orderBy trip_date (date-only @db.Date) has no `id` tiebreak; with many trips sharing a date this makes skip/take pagination non-deterministic (rows skipped/duplicated across pages).
- [LOW] (line 258-268, 329-342, 376-392) vehicle.actual_km recompute (create/put/delete) is a read(MAX end_km)-modify-write that is neither transactional nor row-locked; under concurrency it can be briefly stale (self-heals on next mutation since it always reads MAX).
- [LOW] (line 35-36) query.user_id / query.vehicle_id coerced with raw Number() → NaN on non-numeric input flows into the Prisma where filter.

## src/routes/admin/users.ts

- Clean

## src/routes/admin/vehicles.ts

- [MEDIUM] (line 69-79, 105-129) vehicles.spz is @unique in the DB but POST / and PUT /:id neither pre-check duplicates nor catch P2002, so entering an existing license plate returns a generic 500 instead of 409 (convention: uniqueness check inside the transaction, P2002 -> 409). _(severity adjusted by verification: Real: vehicles.spz is @unique (prisma/schema.prisma:700) and src/routes/admin/vehicles.ts:69-79 (create) / 105-129 (update, spz at :108) have no duplicate check or P2002 catch; the global handler (src/server.ts:102-116) turns the Prisma error into a generic 500, violating the documented P2002->409 convention (followed in users.service.ts:179-182). However, severity is MEDIUM not HIGH: the unique constraint preserves data integrity, the failure is only a wrong status/unhelpful message on the infrequent duplicate-plate path in a rarely-mutated fleet table — a convention/error-reporting gap, not a bug likely to bite in practice.)_
- [MEDIUM] (line 81-88, 130-137, 164-171) All three logAudit calls omit oldValues/newValues, so no diff is stored for vehicle create/update/delete — violates the documented audit convention (warehouse.ts passes them everywhere).
- [LOW] (line 108-127) Redundant String()/Number()/!! re-coercion of body fields already validated and typed by UpdateVehicleSchema (Zod) — dead defensive code that obscures the real types.

## src/routes/admin/warehouse.ts

- [MEDIUM] (line 2296-2317) N+1 in /reports/stock-status: 4 queries per item (getItemTotalStock + getItemAvailableQty x2 + getItemStockValue) with defaultLimit 2000 / maxLimit 5000 -> up to ~8,000-20,000 queries per request; the adjacent below-minimum report was already fixed to one grouped aggregate, this one was not.
- [MEDIUM] (line 659-676) Same per-item N+1 in GET /items list enrichment — ~4 extra queries per row, up to ~400 queries per page at the 100-row limit; should be grouped aggregates over the page's item ids.
- [MEDIUM] (line 1017-1022, 1448-1453, 2349-2358, 2441-2450) date_from/date_to query params go straight into new Date(String(...)) with no validity guard — a junk value yields Invalid Date, Prisma throws, and the GET 500s (numeric filters got numFilter NaN-guards for exactly this reason; dates were missed).
- [MEDIUM] (line 450-456, 503-510) Location `code` uniqueness is pre-checked outside any transaction with no P2002 catch on the create/update — a concurrent duplicate races past the check and surfaces as a 500 (convention: re-check inside the tx, catch P2002 -> 409).
- [MEDIUM] (line 733-741, 806-819) Same TOCTOU for item_number uniqueness on POST/PUT /items — pre-transaction findUnique check, no P2002 handling, race yields a 500.
- [MEDIUM] (line 903-909) DELETE /items/:id runs four deleteMany calls plus the item delete as separate non-transactional statements — a mid-sequence failure leaves the item partially stripped (locations/batches/inventory lines gone, item still present), and the activity check at 886-894 can race with concurrent line creation.
- [MEDIUM] (line 698-701, 962-968) Batch listings order by received_at asc with no { id: 'asc' } tiebreak — received_at is second-precision, so same-second batches display in non-deterministic order that can disagree with the id-tiebroken FIFO order selectFifoBatches actually consumes (convention: always tiebreak timestamp sorts with id).
- [LOW] (line 5) requireAuth is imported but never used — dead import.
- [LOW] (line 1926) Double cast `(result.data as Record<string, unknown>).id as number` to extract the new reservation id for the audit log — type hole; createReservation's return type should expose id.

## src/schemas/ai.schema.ts

- Clean

## src/schemas/attendance.schema.ts

- [MEDIUM] (line 57) ProjectLogSchema.hours uses raw z.coerce.number().min(0) instead of the mandatory shared helper (nonNegativeNumberFromForm) — z.coerce silently turns "", null, false and [] into 0 (and true into 1), the exact silent-coercion bug class the common.ts helpers were introduced to kill.
- [MEDIUM] (line 45-46, 71) Date fields validated as bare strings instead of isoDateString: AttendanceLeaveSchema.date_from/date_to (45-46) and CreateAttendanceSchema.shift_date (71, no constraint at all — even "" passes) let arbitrary strings flow into new Date() downstream (Invalid Date -> Prisma error 500), violating the 'date fields MUST use isoDateString' convention.
- [LOW] (line 51, 88, 100) leave_hours uses numberFromForm, which accepts negative values into leave-balance math; convention says quantities use nonNegativeNumberFromForm/positiveNumberFromForm.

## src/schemas/auth.schema.ts

- Clean

## src/schemas/bank-accounts.schema.ts

- Clean

## src/schemas/common.ts

- [LOW] (line 98-103) isoDateString validates only the digit pattern \d{4}-\d{2}-\d{2}, so impossible calendar dates ("2026-13-45", "2026-02-31") pass validation and become Invalid Date in downstream new Date() calls — a 500 instead of a 400 for crafted input on every schema using the helper.
- [LOW] (line 14-15) parseBody's non-ZodError fallback returns a generic 'Neplatný požadavek' without logging the actual exception — silently swallows the root cause, against the 'every catch logs' rule.

## src/schemas/customers.schema.ts

- [MEDIUM] (line 11-12) custom_fields elements are z.array(z.unknown()) with no shape or size validation (customer_field_order strings also unbounded): a non-object entry such as null is stored verbatim and later crashes the PDF address builder (invoices-pdf.ts:139-141 reads cf.name on each element -> TypeError -> 500 on every PDF for that customer), and arbitrarily large values flow uncapped into the LongText column; should validate {name,value,showLabel} objects with length caps (same gap on Update, lines 23-24).
- [MEDIUM] (line 5-10) Zod max(255) exceeds the real column widths (postal_code VarChar(20), country VarChar(100), company_id/vat_id VarChar(50) per prisma/schema.prisma:185-188), so input the schema accepts dies as a MySQL data-too-long error -> unhandled 500 instead of a Czech 400 (no P2000 mapping in the global handler); same on UpdateCustomerSchema lines 17-22.

## src/schemas/invoices.schema.ts

- [MEDIUM] (line 35-37) issue_date/due_date/tax_date (and paid_date/dates on Update, lines 60-65) are raw z.string().max(255) instead of the mandatory isoDateString helper; invoices.service builds new Date(String(...)) with no Invalid-Date guard (lines 503-505, 578-608), so any non-date string passes validation and throws inside Prisma -> 500 instead of 400.
- [MEDIUM] (line 25) Create-side status is free-form z.string().max(20) while every sibling document schema uses z.enum; createInvoice stores it verbatim (invoices.service:472-491) with no check against draft/issued/paid/overdue — a junk non-'draft' status immediately consumes an official invoice number (line 484) and leaves the document with no valid transitions (VALID_TRANSITIONS[junk] -> []); only the update path is transition-guarded.

## src/schemas/issued-orders.schema.ts

- [LOW] (line 18) position uses raw z.number().int().nonnegative() instead of the mandated nonNegativeNumberFromForm coercion helper that every sibling item schema (invoices/offers/orders) uses — a string position from a form 400s here only; inconsistent with the documented mandatory-helpers rule.
- [LOW] (line 43-44) notes/internal_notes have no .max() (sibling schemas cap at 8000; columns are @db.Text, 64KB) — an over-long value passes Zod and surfaces as a MySQL/Prisma error -> 500 instead of 400.
- [LOW] (line 36) exchange_rate uses nonNegativeNumberFromForm so 0 is accepted and stored as a conversion rate, inconsistent with orders.schema.ts:47 which requires positiveNumberFromForm with default 1.0.

## src/schemas/leave-requests.schema.ts

- [LOW] (line 11-16) ReviewLeaveRequestSchema's enum and default disagree with the endpoint: the route only accepts approved|rejected (leave-requests.ts:20,166-172), so the schema's 'cancelled' value and the 'pending' default (applied when status is omitted) always fall through to a duplicate route-side 400 — dead enum members and validation duplicated between schema and route.

## src/schemas/offers.schema.ts

- [MEDIUM] (line 32-33) created_at/valid_until (also on Update, lines 51-52) are raw z.string().max(255) instead of the mandatory isoDateString helper; offers.service does unguarded new Date(String(...)) (lines 254-258, 340-349), so a garbage string passes validation, becomes Invalid Date, and throws in Prisma -> 500 instead of 400.

## src/schemas/orders.schema.ts

- Clean

## src/schemas/plan.schema.ts

- [MEDIUM] (line 17, 30, 48, 54, 83) project_id is an FK but is validated with nullableNumberFromForm instead of the mandated nullableIntIdFromForm — fractional/negative/zero values pass the schema and reach Prisma unchecked (plan.service createEntryCore:509-518 does no existence/shape check), producing a Prisma Int-validation or FK error -> 500 instead of 400.
- [MEDIUM] (line 79-82) include_weekends hand-rolls z.union([z.boolean(), z.string()]).transform(...) instead of the mandatory booleanFromForm helper from common.ts, and diverges from it: numeric 1 (accepted by booleanFromForm) silently coerces to false here.

## src/schemas/planCategory.schema.ts

- [LOW] (line 20) is_active uses raw z.boolean() instead of the mandated booleanFromForm coercion helper — form-style values '1'/'true' are rejected with a 400, inconsistent with every other boolean form field in the codebase.

## src/schemas/profile.schema.ts

- [LOW] (line 4) email has no .max(255) cap (users.email is VarChar(255) per prisma/schema.prisma:662; the shared emailOrEmpty helper caps at 255) — an over-length address passes Zod's format check and surfaces as a MySQL/Prisma error -> 500 instead of 400.

## src/schemas/projects.schema.ts

- [MEDIUM] (line 15) UpdateProjectSchema.name is z.string().nullish() although create requires min(1): sending name:null/'' nulls the project name (projects.service:113-114 maps falsy -> null) and then renames the NAS project folder with an empty name (projects.service:143-146) — a required business field can be silently cleared.
- [MEDIUM] (line 5, 8, 14-17) name has no .max(255) and status is a free-form string with no enum and no .max (columns are VarChar(255)/VarChar(30) per prisma/schema.prisma:444,449) — over-length or junk values pass Zod and become MySQL data-too-long 500s or unknown status values in the UI, while sibling document schemas enum-constrain status.

## src/schemas/received-invoices.schema.ts

- [MEDIUM] (line 14-15, 28-30) issue_date/due_date/paid_date are free-form z.string().max(255) instead of the mandatory isoDateString helper; the route feeds them to new Date(String(...)) (received-invoices.ts:488-491, 614-626), so a malformed string passes validation, becomes Invalid Date, and Prisma throws a 500 instead of a Czech 400.
- [LOW] (line 21) UpdateReceivedInvoiceSchema.supplier_name lacks min(1) (create requires it); the update route writes String(body.supplier_name) verbatim (received-invoices.ts:587-590), so PUT can blank a required supplier name.

## src/schemas/roles.schema.ts

- Clean

## src/schemas/scope-templates.schema.ts

- Clean

## src/schemas/settings.schema.ts

- [MEDIUM] (line 21-23) require_2fa uses an inline z.preprocess boolean coercion instead of the mandatory booleanFromForm helper from common.ts; unlike the helper it silently maps the string "true" to false.
- [MEDIUM] (line 9-19, 31-44) None of the string settings fields have .max() while the DB columns are bounded (e.g. company_settings.quotation_prefix is @db.VarChar(20), company_name VarChar(255)); an overlong value passes Zod and fails inside Prisma as a 500 instead of a validation 400.
- [LOW] (line 49) available_currencies is the only array field without a length cap (.max) and its items are unbounded strings, unlike the capped sibling arrays on lines 48/50/51.

## src/schemas/trips.schema.ts

- Clean

## src/schemas/users.schema.ts

- [MEDIUM] (line 18) UpdateUserSchema.username is z.string().optional() with no min(1); users.service.ts:262-274 trims and writes it with no emptiness check, so a PUT can set username to "" and lock the account out of login (create requires min(1)).
- [MEDIUM] (line 11-14, 27-29) is_active uses an inline z.preprocess boolean coercion (duplicated twice) instead of the mandatory booleanFromForm helper; unlike the helper it silently maps the string "true" to false.
- [MEDIUM] (line 5-9, 18-25) Length bounds don't match DB columns: username and email have no .max() and first_name/last_name allow max(100), but users.username/first_name/last_name are @db.VarChar(50) (schema.prisma:661-665), so overlong input passes Zod and 500s in Prisma instead of returning a 400.

## src/schemas/vehicles.schema.ts

- [MEDIUM] (line 11-14, 24-26) is_active uses an inline z.preprocess boolean coercion (duplicated twice) instead of the mandatory booleanFromForm helper from common.ts; unlike the helper it silently maps the string "true" to false.
- [LOW] (line 18-19) UpdateVehicleSchema.spz and .name lack min(1) (create requires it) and the route writes String(body.spz)/String(body.name) verbatim (vehicles.ts:108-109), so a vehicle's SPZ/name can be blanked via update.

## src/schemas/warehouse.schema.ts

- [LOW] (line 81, 91) min_quantity uses nullableNumberFromForm, which accepts negative values; the documented convention routes quantities through non-negative coercion — a negative minimum-stock threshold is meaningless and silently disables low-stock alerts.

## src/server.ts

- [LOW] (line 82) Unauthenticated /api/health returns the raw DB error message (err.message) on failure, which can disclose internal DB host/port details; endpoint is also deliberately exempt from rate limiting, so the leak is unthrottled — return a generic string and keep the detail in logs.
- [LOW] (line 267) start() is invoked without .catch() — only the listen() call is try/caught (236-242), so a plugin/route registration failure rejects the promise and crashes via unhandled rejection instead of the logged process.exit(1) path.
- [LOW] (line 189, 211) Not-found handlers hand-roll reply.status(404).send({ success: false, error: 'Not found' }) instead of the mandated error() helper (documented 'never write raw reply.send' convention).

## src/services/ai.service.ts

- Clean

## src/services/attendance.service.ts

- [MEDIUM] (line 207, 389, 418, 446, 1368, 1408, 1483) Seven findFirst calls resolving 'the latest/open shift' use orderBy { created_at: 'desc' } with no { id: 'desc' } tiebreak; created_at is second-precision, so same-second rows make the pick non-deterministic — violates the documented timestamp-sort tiebreak rule.
- [MEDIUM] (line 1050-1056) listAttendance paginates with orderBy { shift_date: order } only; every user's record for a given day shares the same shift_date, so page boundaries are non-deterministic (rows can repeat or vanish across pages) — needs an id secondary sort.
- [MEDIUM] (line 1542-1632) createAttendance runs the duplicate/overlap check and then the attendance create + project_logs createMany with no transaction or lock: concurrent submits both pass the overlap check (TOCTOU — convention requires the re-check inside the transaction), and a failed createMany leaves a half-written shift without its logs.
- [MEDIUM] (line 1702-1719) updateAttendance deletes all project logs (deleteMany) and re-inserts (createMany) outside any transaction — if createMany fails (e.g. FK violation on a stale project_id), the user's existing logs are already permanently deleted.
- [MEDIUM] (line 1289-1316) createLeave read-modify-writes leave_balances (findFirst then update with a JS-computed sum) inside the transaction but without SELECT ... FOR UPDATE or an atomic increment — concurrent leave creations for the same user/year can lose booked hours; violates the documented balance-locking rule (contrast lockUserRow used by punchAction/switchProject).
- [LOW] (line 1163-1176) bulkCreateAttendance loads the existing-records dedup set BEFORE opening the transaction — a punch or concurrent bulk run between the read and the insert loop creates duplicate day records (TOCTOU; low practical risk since it is an admin-only action).
- [LOW] (line 1724-1745) deleteAttendance's optional requesterId/isAdmin ownership check is dead code — the only caller (routes/admin/attendance.ts:504, guarded by attendance.manage) never passes them, so the branch can never execute.
- [LOW] (line 1129, 973-975) Number(vacation_total) || DEFAULT_VACATION_TOTAL coerces an explicit stored/submitted 0 into 160 hours (falsy-zero bug) in handleBalances' create path and in getPrintData's balance display.
- [LOW] (line 578-600) getWorkfund issues up to 12 sequential per-month attendance.findMany queries over all users inside a loop; a single year-ranged query bucketed in memory would do the same work in one round trip.

## src/services/audit.ts

- Clean

## src/services/auth.ts

- [LOW] (line 38-80) loadAuthData runs on EVERY authenticated request (middleware -> verifyAccessToken) and always issues a users.findUnique with a 3-level nested include plus an unconditional company_settings.findFirst for require_2fa (and permissions.findMany for admins) — 2-3 DB queries per request; the company-settings flag is an obvious cache candidate.
- [LOW] (line 99-104) Login user lookup findFirst({ OR: [{username}, {email: username}] }) has no orderBy — if one user's email equals another user's username, which account receives the login attempt (and the failed-attempt increments/lockout) is non-deterministic.
- [LOW] (line 165-172) last_login is set and failed_login_attempts/locked_until reset immediately after the password check but BEFORE the TOTP step — a correct-password/failed-2FA attempt still records a last_login and clears the lockout counter.

## src/services/exchange-rates.ts

- [MEDIUM] (line 27-29) The undated cache key 'today' is not date-scoped: an entry fetched at time T serves for a full 24h TTL across midnight, so date-less toCzk/getRate calls (dashboard revenue, received-invoices stats) return the PREVIOUS day's CNB rates for up to a whole day — key should include the current local date.

## src/services/invoice-alerts.ts

- [MEDIUM] (line 192) a.currency is interpolated into the alert-email HTML without escapeHtml while the neighbouring fields (number, counterparty, title) are escaped; currency is free-text in the schemas (z.string().max(20), not an enum), so any user who can create/edit an invoice can inject HTML into the finance alert email.

## src/services/invoices.service.ts

- [MEDIUM] (line 482-527) createInvoice is non-transactional: generateInvoiceNumber() is called without a tx before the insert and invoice_items.createMany is a separate statement, so a failed create permanently leaks a consumed invoice number and a failed items insert leaves an itemless invoice — createIssuedOrder in the sibling service does this correctly inside $transaction with tx passed to the generator.
- [MEDIUM] (line 479-488) Caller-supplied explicit invoice_number is inserted with no in-transaction uniqueness re-check and no P2002→409 catch (route also has none), so a duplicate number surfaces as an unhandled 500 — violates the documented 'uniqueness checks inside the create transaction catching P2002 → 409' convention.
- [MEDIUM] (line 203) listInvoices orderBy has no `id` tiebreak; issue_date/due_date are date-precision so same-day invoices paginate non-deterministically (rows can duplicate/drop across pages) — issued-orders.service.ts lines 141-144 already added exactly this tiebreak, violating the 'always tiebreak a timestamp sort with id' convention here.
- [LOW] (line 621-646) The finalize transaction (status change + number assignment) and the items delete/recreate run as two separate transactions, so an items failure after finalize commits leaves an issued invoice header with stale items and a 500 to the client.

## src/services/issued-orders.service.ts

- [MEDIUM] (line 243-255) Explicit caller-supplied po_number has no in-transaction uniqueness re-check and no P2002→409 catch, so a duplicate number surfaces as an unhandled 500 — violates the 'uniqueness checks inside the create transaction catching P2002 → 409' convention.
- [LOW] (line 417-422) deleteIssuedOrder releases the number against created_at's year, but deferred numbering allocates the po_number at finalize time — a draft created in December and finalized after New Year releases against the wrong year's sequence (releaseSequence's highest-number pattern won't match) so the number is never freed; deleteInvoice correctly derives the year from the number itself.
- [LOW] (line 369-396) Finalize transaction (status + number) and the items delete/recreate transaction are separate, so an items failure after finalize commits leaves a 'sent' order header with stale items.

## src/services/leave-notification.ts

- [LOW] (line 94) dateFrom/dateTo are interpolated into the email HTML without escapeHtml while formatDate (lines 12-19) deliberately falls back to returning the raw input string on unparseable dates — HTML injection into the approver email is only prevented by upstream Zod date validation; wrap in escapeHtml for defense-in-depth.

## src/services/mailer.ts

- Clean

## src/services/nas-file-manager.ts

- [MEDIUM] (line 561-572) moveItem's rename catch swallows the underlying error for non-EXDEV failures — it returns a generic Czech message but never logs the error, violating the 'every catch logs' convention the NAS managers were specifically called out on (every other catch in this file logs).

## src/services/nas-financials-manager.ts

- [LOW] (line 270-282) uniquePath probes with existsSync then the caller writes with plain writeFileSync (no "wx" flag) — a TOCTOU race between two concurrent saves of the same document name silently overwrites one PDF; nas-file-manager.uploadFile solves exactly this with flag:"wx" and an EEXIST retry loop.

## src/services/nas-offers-manager.ts

- [LOW] (line 34-46) saveOfferPdf lacks the isConfigured()/basePath guard its financials sibling added (nas-financials-manager.ts saveIssuedPdf lines 98-101 with an explanatory comment): with an empty basePath, ensureDir builds a RELATIVE path and writes the PDF into the process CWD, returning a path readOfferPdf can never resolve — currently latent because the sole caller (offers-pdf.ts:757) gates on isConfigured(), but the in-method guard parity should be restored.
- [LOW] (line 84-97) deleteOfferPdf returns false (and logs a delete failure) when the post-unlink empty-folder cleanup (readdirSync/rmdirSync) throws, even though the PDF itself was successfully deleted — misleading failure signal to callers.
- [LOW] (line 141-170) sanitizeFilename and resolveSafePath are duplicated verbatim across all three NAS managers (also nas-financials-manager.ts 255-301 and nas-file-manager.ts 620-634) — extract a shared helper so future sanitization fixes can't diverge.

## src/services/numbering.service.ts

- [MEDIUM] (line 207-253, 269, 314, 364, 413, 455) All is\*NumberTaken uniqueness checks query the global prisma client, never the passed tx — when generators run inside a caller's transaction the check is NOT in-transaction (callers' comments claiming an in-tx TOCTOU re-check are false) and cannot see uncommitted same-tx rows; a race lands on the unique constraint as P2002 → 500 instead of a clean retry/409.
- [LOW] (line 207-213, 240-245) isSharedNumberTaken/isOrderNumberTaken use findFirst on orders with no select, so a hit fetches the entire row including the attachment_data PDF blob just to test existence (projects/quotations checks have the same no-select pattern but no blob column).
- [LOW] (line 291-298, 339-346, 387-394) previewOfferNumber/previewSharedNumber/previewProjectNumber can issue up to 1000 sequential awaited findFirst round-trips per preview call whenever the sequence counter lags real data (e.g. after imports) — preview never consumes the sequence, so the lag and the loop cost recur on every call until a generate catches the counter up.

## src/services/offers.service.ts

- [MEDIUM] (line 338-339) updateOffer maps a schema-legal null customer_id (UpdateQuotationSchema uses nullableIntIdFromForm) through Number(null) → 0, so clearing the customer via the API writes customer_id=0 → FK quotations_ibfk_1 (Restrict) P2003 → unhandled 500; createOffer (line 253) and orders.service updateOrder handle the null case correctly.
- [MEDIUM] (line 111-124) listOffers orderBy has no { id } tiebreak (convention: tiebreak timestamp sorts with id; listOrders does this) — sorting by created_at ties within the same second, and the FE default sort key quotation_number is NULL for every draft, so paginated drafts can duplicate/skip across pages.
- [MEDIUM] (line 223-247, 301-309) createOffer explicit-number path: the comment claims the uniqueness re-check runs 'INSIDE the transaction' but isOfferNumberTaken uses the base prisma client (separate connection), there is no P2002 → 409 catch (a concurrent duplicate surfaces as 500), and an empty string passes the !== undefined/!== null guard so quotation_number:"" creates a ""-numbered offer that breaks the nullable-unique draft model (second one → P2002 → 500).
- [MEDIUM] (line 254-259, 340-351) created_at/valid_until are built via new Date(String(body.x)) from schema fields declared as bare z.string().max(255) instead of the mandatory isoDateString helper — any non-date string yields Invalid Date which Prisma rejects → 500.
- [MEDIUM] (line 428-432) deleteOffer has no guard or P2003 handling for offers linked to an order/project: orders.quotation_id and projects.quotation_id are onDelete:Restrict, so deleting an 'ordered' offer throws P2003 → unhandled 500 instead of a clean 409 with a Czech message.
- [LOW] (line 322-327) Dead guard: UpdateQuotationSchema contains no quotation_number field and z.object strips unknown keys, so body.quotation_number is always undefined and the 'Číslo nabídky nelze změnit' check (and its draft-handling comment) can never fire.
- [LOW] (line 4, 28) previewOfferNumber is imported at line 4 but never used — the line-28 re-export pulls directly from ./numbering.service, leaving the import dead (noUnusedLocals is off, so tsc doesn't catch it).
- [LOW] (line 80-103) enrichQuotation(q: any) hides type errors per the documented any-cast rule; e.g. the q.vat_rate !== "" comparison at line 90 is dead — vat_rate is a Prisma Decimal|null and can never equal an empty string.
- [LOW] (line 161-168) getOfferTotals loads scope_sections (rich-text Text blobs) and customers for the WHOLE filtered set on every stats call, though the per-currency totals only need quotation_items, vat fields and currency.
- [LOW] (line 442-456) Duplicated NAS cleanup: deleteOffer deletes the offer PDF from NAS, and the route handler (routes/admin/quotations.ts:331-346) immediately repeats the identical buildRelativePath+deleteOfferPdf call — the second invocation is always a logged no-op.

## src/services/orders.service.ts

- [HIGH] (line 154-176, 196-216, 228-263, 570, 683) attachment*data (the full PDF blob, Bytes) is fetched on every query with no select/omit and is spread by enrichOrder/getOrder into the API responses — every orders list page and order detail serializes the attachment as a per-byte JSON object (a 1MB PDF becomes many MB of JSON), getOrderTotals loads every blob for the whole filtered set just to sum totals, and updateOrder/deleteOrder pre-reads pull the blob to check status/number; a dedicated /:id/attachment endpoint already exists, so the blob in these payloads is pure waste. *(verified)\_
- [MEDIUM] (line 291-311, 360-363) createOrderFromQuotation TOCTOU: the quotation and its order_id-already-linked guard are read OUTSIDE the transaction with no lock or conditional updateMany({where:{id, order_id:null}}), so two concurrent accepts (double-click/retry) both pass the check and create two orders + two projects, with the second order_id write silently overwriting the first.
- [MEDIUM] (line 34-39, 586-595) Status-value mismatch with orders.schema.ts: VALID_TRANSITIONS/ORDER_TO_PROJECT_STATUS use "stornovana" but the Zod enum only accepts "zrusena" — so PUT status:"stornovana" is rejected by validation and status:"zrusena" never passes the transition check, making it impossible to cancel an order through the API (the FE label maps and tests all use "stornovana"; "zrusena" looks like a schema typo).
- [MEDIUM] (line 433-445, 551-559) createOrder explicit order_number path: empty string passes the !== undefined/!== null guard and creates an order numbered "" while skipping number generation; isOrderNumberTaken runs on the base client (not the tx, so the TOCTOU is not closed) and there is no P2002 → 409 catch, so a concurrent duplicate explicit number surfaces as a 500 (convention requires in-tx re-check + P2002 handling).
- [LOW] (line 576-584) Dead guard: UpdateOrderSchema has no order_number field and z.object strips unknown keys, so body.order_number is always undefined and the 'Číslo objednávky nelze změnit' check can never fire.
- [LOW] (line 690-741, 755) deleteOrder's warehouse/attendance guard counts refs only for projects fetched BEFORE the transaction, while projects.deleteMany targets order_id — a project linked to the order between the pre-tx fetch and the tx bypasses the guard entirely (its restricted FKs then fail as P2003 → 500), despite the comment claiming the in-tx count closes the window.
- [LOW] (line 670-677) In the no-items branch the order status update and syncProjectStatus run as two separate non-transactional calls on the base client (the items branch wraps both in a tx), so a failure between them leaves the order and its linked project's statuses diverged.

## src/services/plan.service.ts

- [MEDIUM] (line 586-606) updateEntry validates date_to >= date_from only when BOTH endpoints are in the payload (the Zod refine in plan.schema.ts:36-41 has the same both-present gap), so a partial update of just date_from (or just date_to) can store an inverted range (date_from > date_to) that then matches no day in resolveCell/resolveGrid — the entry silently disappears from the grid.
- [LOW] (line 681, 747) createOverride and updateOverride return Promise<Result<any>> — the override row type is left as any (entries use the explicit CreatedEntry type), a documented type-safety hole.
- [LOW] (line 377-378) assertNotPastDate re-implements the local YYYY-MM-DD 'today' computation inline instead of importing the canonical localDateStr from src/utils/date.ts (the convention names that helper as the single source for server-side 'today').

## src/services/planCategory.service.ts

- [MEDIUM] (line 34-79) createPlanCategory runs the uniqueKey probe loop and the create outside any transaction with no P2002 catch — TOCTOU on the key unique constraint: two concurrent creates of the same label pass the probe and the loser surfaces as an unhandled 500, violating the documented 'uniqueness check inside the create transaction + catch P2002 -> 409' rule (the sort_order aggregate at 66-69 has the same unguarded race).

## src/services/projects.service.ts

- [MEDIUM] (line 50) listProjects allows sort=created_at (in ALLOWED_SORT_FIELDS) but orderBy has no { id } secondary sort — second-precision timestamps make paginated ordering non-deterministic (rows can repeat/skip across pages), violating the 'always tiebreak a timestamp sort with id' convention.
- [LOW] (line 80) project_notes ordered by created_at desc with no id tiebreak — notes created in the same second render in unstable order (same convention as above, unpaginated so lower impact).
- [LOW] (line 100-135) updateProject takes body as Record<string, any> and re-coerces with raw Number()/new Date(String()) even though the route already validated via UpdateProjectSchema (verified in routes/admin/projects.ts:122-125) — type-safety hole duplicating validation; should accept the inferred UpdateProjectInput type.
- [LOW] (line 104-109) Dead code: the 'Číslo projektu nelze změnit' guard can never fire — UpdateProjectSchema (z.object, strips unknown keys) has no project_number field, so body.project_number is always undefined after parseBody; an attempted change is silently dropped instead of 400ing.
- [LOW] (line 94-96) getProject calls nasFileManager.projectFolderExists() — a synchronous directory scan of the SMB NAS share (findProjectFolder uses sync fs APIs, verified in nas-file-manager.ts) — on every project-detail GET; a slow or unavailable NAS blocks the Node event loop for all requests.

## src/services/system-settings.ts

- Clean

## src/services/users.service.ts

- [MEDIUM] (line 321-329) updateUser's transaction catch only maps custom status errors and rethrows everything else — a concurrent username/email write that beats the in-tx uniqueness check throws P2002 and surfaces as a 500; createUser (180-188) catches P2002 -> 409 but updateUser does not, violating the 'catch P2002 -> 409' convention.
- [MEDIUM] (line 334-350) deleteUser has no last-active-admin guard: updateUser blocks demoting (228-234) and deactivating (248-253) the last active admin, but hard-deleting the sole admin account is allowed (only self-delete is blocked), so any non-admin user with users.delete can leave the system with zero administrators.
- [LOW] (line 203-256) The admin-role and active-admin-count guards run before/outside the update transaction — check-then-act race: two concurrent demote/deactivate requests for the two remaining admins can both pass count<=1 checks and remove all admins.
- [LOW] (line 64) listUsers builds the Prisma filter as `const where: any` — untyped filter object hides field-name/shape mistakes from the compiler (projects.service uses Record<string, unknown> for the same pattern).

## src/services/warehouse.service.ts

- [MEDIUM] (line 952) validateIssueReservationRules calls getItemTotalStock(itemId), which queries the GLOBAL prisma client, while the surrounding code uses the passed tx `client` (953) — a non-transactional, unlocked stock read inside confirmIssue's locked transaction (runs on a separate pooled connection, misses in-tx state), exactly the discipline violation getItemAvailableQtyTx's own comment (157-160) warns about.
- [MEDIUM] (line 352-387) confirmReceipt mutates sklad_item_locations without first locking the affected sklad_items rows (every other stock path — confirmIssue 589-598, cancelIssue 775-784, confirmInventorySession 1132-1136 — does): a concurrent issue-confirm's unlocked read-then-delete of the same location row (683-695) can discard the receipt's just-committed increment (lost update / location drift), and two concurrent first-creates of the same (item, location) row race to P2002 -> 500 (Prisma upsert is not native on MySQL).
- [MEDIUM] (line 445-532) cancelReceipt's CONFIRMED->CANCELLED path validates batch is_consumed/quantity and reverses item_locations on plain snapshot reads with no lockRowsForUpdate on items/batches (unlike confirmIssue/cancelIssue) — the consumed-check is racy against a concurrent draft-issue create+confirm inside the cancel window (batch delete then fails on the issue_lines FK as a 500), inconsistent with the file's own locking discipline.
- [LOW] (line 446-450) Redundant per-line query: cancelReceipt re-fetches each batch via tx.sklad_batches.findUnique although item.batch was already loaded by the include at 424 in the same transaction (N+1 duplication).

## src/types/fastify.d.ts

- Clean

## src/types/index.ts

- [LOW] (line 88-94) PaginationMeta requires both limit and per_page, which are always set to the same value (utils/pagination.ts:59 fills per_page: limit) — duplicated field every paginated route must populate twice.
- [LOW] (line 81-86) ApiResponse lacks the message field that success() actually sends, forcing the ApiResponse<T> & { message?: string } intersection workaround in utils/response.ts:10 — the declared response type understates the real wire shape.

## src/utils/czech-holidays.ts

- [LOW] (line 30-100) Holiday/business-day logic is fully duplicated client-side in src/admin/pages/AttendanceHistory.tsx (getCzechHolidays/getBusinessDaysInMonth reimplemented, lines ~100-145 there) — two sources of truth that can drift if a holiday rule changes.

## src/utils/date.ts

- Clean

## src/utils/encryption.ts

- Clean

## src/utils/html-to-pdf.ts

- [MEDIUM] (line 47) Bare `catch {}` swallows the puppeteer launch error without logging before falling back to puppeteer-core — when puppeteer IS installed but launch fails (e.g. missing shared libs, sandbox issue), the real cause is hidden and only the misleading puppeteer-core/CHROMIUM_PATH error surfaces; violates the 'never silently swallow errors, log at minimum' rule (the comment covers only the expected module-absent case).
- [LOW] (line 99-104) closeBrowser: if `browser.close()` rejects, `browser = null` is never executed (stale dead reference kept) and the rejection propagates out of htmlToPdf's finally block (line 93), masking the original page-close/render error.

## src/utils/pagination.ts

- Clean

## src/utils/planAuditDescription.ts

- Clean

## src/utils/response.ts

- Clean

## src/utils/totp.ts

- [MEDIUM] (line 22-25) Replay-protection gap: `counterDelta = Math.min(delta, 0)` records a future-window code (delta=+1, accepted via window:1) as the CURRENT counter, so one period later the same code re-validates at delta=0 with counter = currentCounter+1 > stored and passes auth.ts's `counter <= lastCounter` replay check (auth.ts:167-185) — an intercepted code from a clock-ahead device can be used twice; should record `currentCounter + delta` for the matched window.

## src/vite-env.d.ts

- Clean

## tsconfig.app.json

- [LOW] (line 19-22) Dead/footgun config: the `@/*` -> `src/*` paths alias is used by zero source files and has no matching `resolve.alias` in vite.config.ts — any future `@/` import would typecheck under tsc but fail the Vite build/dev server.

## tsconfig.json

- Clean

## tsconfig.server.json

- [LOW] (line 16-19) Dead '@/\*' path alias: zero server files import via '@/', and tsc does not rewrite aliases at emit, so any future '@/' import would typecheck and build yet crash node dist/server.js at runtime (no tsc-alias/runtime resolver is installed).
- [LOW] (line 21-30) src/vite-env.d.ts ('/// <reference types="vite/client" />') is matched by include and not excluded, leaking Vite's frontend ambient types (\*.css module declarations, ImportMeta.env) into the server program so bogus asset imports would typecheck.
- [LOW] (line 29) src/**tests** is excluded from the build but stale pre-exclusion output (9 compiled test files in dist/**tests**/) remains — build:server is plain tsc with no clean step and the release tars dist wholesale, so dead test JS (importing dev-only vitest) ships to production.

## tsconfig.test.json

- [LOW] (line 14-17) Dead '@/\*' path alias duplicated here: no test uses it, and vitest.config.ts has no tsconfig-paths/alias wiring, so an '@/' import in a test would typecheck under tsc -b but fail to resolve at runtime under Vitest.

## vite.config.ts

- [MEDIUM] (line 18-25) manualChunks uses substring match id.includes("node_modules/react") which captures every react\* package (react-quill-new, react-is, react-transition-group), not just react/react-dom/react-router — verified in dist-client: the eagerly-loaded 435 KB vendor-react chunk contains the full Quill editor (ql-editor/react-quill markers), defeating lazy-loading of the rich editor and bloating every initial page load; match 'node_modules/react/' etc. with trailing slash instead.
- [LOW] (line 8-10) server: { port: 3000 } backs the standalone 'npm run dev:client' flow, but there is no server.proxy and the SPA fetches relative '/api/...' URLs, so that documented workflow 404s on every API call; the actual dev flow is Vite middleware-mode inside Fastify (src/server.ts:166), leaving this block dead/misleading.

## vitest.config.ts

- [LOW] (line 12) Specifying exclude replaces Vitest's '**/'-anchored defaults with root-only patterns ('dist/**', 'node_modules/\*\*' don't match nested dirs, and dist-client/cypress/.git defaults are dropped) — currently latent (verified no stray test files outside root node_modules/.claude), but should spread configDefaults.exclude to keep the safety net.

---

## Appendix — findings refuted by adversarial verification (not real issues)

None — every CRITICAL/HIGH candidate survived verification (5 were severity-adjusted, noted inline above).
