Compare commits

...

458 Commits

Author SHA1 Message Date
BOHA
638264fc7c chore(release): v2.4.1 - dashboard staleness fix, VAT-less order PDFs, release-process docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:08:52 +02:00
BOHA
f68a4dafc4 feat(orders-pdf): without 'Uplatnit DPH' hide VAT columns, total reads 'Celkem bez DPH'
Applies to both the issued-order (PO) PDF and the order-confirmation PDF: when apply_vat is off the %DPH and DPH columns are dropped entirely (widths redistributed) instead of printing 0% / 0,00, the redundant Mezisoucet row is omitted, and the grand total is labeled 'Celkem bez DPH' / 'Total excl. VAT'. The per-line DPH cell already contained only the line VAT (never netto+VAT) - now pinned by a regression test (2x100 @ 21%: DPH cell 42,00, Celkem cell 242,00).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:04:07 +02:00
BOHA
700fb47bbc docs(release): prisma generate + prisma.config.ts are mandatory deploy steps
npm install skips client regeneration when deps are unchanged - a schema-changing release then serves a stale client (P2022 500s; bit v2.4.0). prisma.config.ts carries the Prisma 7 datasource and must ship in the tarball.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:52:02 +02:00
BOHA
c2746d78c9 fix(dashboard): stale punch button/KPIs after attendance changes on other pages
Verified flow: clock in on dashboard -> delete the record in /attendance/admin
-> navigate back: 'Zaznamenat odchod', the Pritomni KPI and the presence card
still showed the pre-delete state until F5. Root cause: every attendance
mutation outside the dashboard invalidated only ["attendance"], never
["dashboard"], so within the dashboard query's 60s staleTime the remount was
served from cache.

- useAttendanceAdmin create/bulk/edit/delete, /attendance punch +
  leave-request, AttendanceCreate, LeaveApproval approve/reject now also
  invalidate ["dashboard"] (CLAUDE.md rule: mutations invalidate every domain
  that embeds their data)
- systemic backstop: dashboardOptions uses refetchOnMount "always" - the
  dashboard aggregates attendance/offers/invoices/orders/projects/leave, and
  domain pages can't all be expected to invalidate it; returning to the
  dashboard must never show pre-mutation data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:40:06 +02:00
BOHA
4e534471d9 chore(release): v2.4.0 - dashboard today-window + cross-login cache fix, issued orders -> suppliers (migration), @db.Date filter sweep
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:34:35 +02:00
BOHA
975a555af5 fix(dates): @db.Date filters built from local midnight queried the wrong day
Prisma truncates a JS Date used in a WHERE filter on a @db.Date column to
its UTC date part. Under TZ=Europe/Prague a local-midnight boundary
(new Date(y,m,d) = 22:00/23:00Z of the previous day) therefore filtered as
the PREVIOUS calendar date. Same class as the dashboard fix; full sweep of
every @db.Date filter in the codebase. New shared helper
utcMidnightOfLocalDay() in src/utils/date.ts.

Fixed (all previously off by one day):
- attendance.service: getStatus today+month windows; getWorkfund (last day
  of each month was double-counted across months); getPrintData (monthly
  print included prev month's last day); listAttendance (admin month view
  included prev month's last day AND dropped the selected month's last
  day); createAttendance duplicate/overlap validation (checked only the
  PREVIOUS day - same-day duplicates were never caught, neighbors falsely
  rejected)
- invoice-alerts: the 'splatnost za 3 dny' advance alert had NEVER fired
  (due==today+3 was outside the fetched window); window computation
  extracted as computeAlertWindow() + pure tests
- invoices.service: month list/totals filter dropped invoices issued on the
  month's last day; getInvoiceStats month/year bounds; markOverdueInvoices
  boundary; auto paid_date could store yesterday during 00:00-02:00
- received-invoices auto paid_date, issued-orders default order_date: same
  night-window write hazard, now via the helper
- attendance.schema: shift_date hardened to isoDateString (bare YYYY-MM-DD)

+9 regression tests (month boundaries, same-day duplicate, last-day-of-month
invoice in list+totals, alert window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:29:28 +02:00
BOHA
6a22195c7d feat(issued-orders): counterparty is now a supplier (dodavatel), not a customer
Issued orders are purchase orders WE send - they must pick from suppliers
(sklad_suppliers), not from customers. Per user decision customer_id was
REPLACED (not kept alongside): migration drops issued_orders.customer_id and
adds supplier_id FK -> sklad_suppliers (existing rows lose their counterparty
- the feature is days old; re-point them in the UI).

- service: input/filters/search (suppliers.name + ico)/enrichment/detail all
  supplier-based; create validates the supplier inside the transaction and
  update before write -> Czech 400 'Dodavatel nenalezen' instead of P2003 500;
  detail returns a minimal supplier field set (no internal notes leak)
- routes: supplier_id on list + stats; new GET /issued-orders/suppliers
  lookup (orders.view/create/edit guard - orders users lack warehouse.manage
  which guards the warehouse suppliers CRUD), active suppliers only,
  name+id ordering
- PDF: Dodavatel block now renders the supplier (name, newline-split address,
  IC/DIC), layout and both language label sets unchanged
- frontend: new SupplierPicker kit component (CustomerPicker untouched),
  IssuedOrderDetail/IssuedOrders switched to supplier_id/supplier_name; the
  picker keeps a fallback option for orders whose supplier was later
  deactivated; WarehouseSuppliers CRUD now also invalidates issued-orders so
  the picker can't go stale
- tests: issued-orders suite switched to supplier fixtures + new coverage
  (lookup shape + 403, nonexistent supplier 400, PDF supplier block)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:29:06 +02:00
BOHA
74ce24e3fa fix(dashboard,auth): today-window on @db.Date + query-cache cleared across logins
Two dashboard bugs reported after clock-in:

1. 'Dochazka dnes' / 'Pritomni dnes' showed everyone absent even after
   refresh: attendance.shift_date is @db.Date and Prisma truncates filter
   Dates to their UTC date part, so the local-midnight boundaries
   (new Date(y,m,d) = 22:00/23:00Z of the previous day) queried
   [yesterday, today) and matched zero of today's punches. Boundaries are
   now UTC-midnight instants of the local (Prague) calendar day.
   Reproduced empirically against real rows; route-level regression test
   added (dashboard.test.ts).

2. After logout + login as a different user, cached dashboards/buttons from
   the previous user were served: query keys are user-agnostic and the
   React Query cache outlives the session. logout(), login() and the 2FA
   verify path now clear the whole cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:28:45 +02:00
BOHA
8ee5a443ef chore(release): v2.3.0 — audit fix pass: attendance/2FA/invoice/trips/warehouse fixes, attachment-blob removal, dep cleanup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:22:49 +02:00
BOHA
b3e2abf9b2 fix(attendance): admin month view no longer truncates at 100 records
The KPI cards and on-screen summary totals (computeUserTotals) are computed
client-side from one month fetch. The hook requested limit=1000 but
parsePagination silently clamped it to 100, so once a month exceeded 100
attendance rows (~5 employees x 21 shifts) the newest-first list dropped the
EARLIEST days of the month — screen totals undercounted while the print path
(complete server fetch) stayed correct. This is the data-volume cliff that
made the summary counting look like it changed without any formula change:
git archaeology confirms every counting formula is bit-identical from v1.9.1
through HEAD.

- routes/admin/attendance.ts: list endpoint passes maxLimit 2000 to
  parsePagination (complete-month view; 2000 ~ 64 employees x 31 days)
- useAttendanceAdmin: month fetch requests limit=2000 with the constraint
  documented
- regression test: a 120-row month returns all 120 records

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 10:20:40 +02:00
BOHA
1826fc7976 fix: audit fix pass #1 — all 19 verified HIGH findings + critical dep cleanup
Fixes every CRITICAL/HIGH finding from the 2026-06-09 full-codebase audit
(REVIEW_FINDINGS.md); each fix went through independent spec + code-quality
review. Plan and per-task log: docs/superpowers/plans/2026-06-10-audit-high-fix-pass.md

- attendance: schemas accept the combined local datetimes the forms/service
  use (new dateTimeString helpers in schemas/common.ts), breaks persist on
  create, AttendanceCreate submit rebuilt — every submit 400'd since 519edce
- 2fa: backup codes wired to /totp/backup-verify (+ remember-me parity),
  enrollment QR generated locally via qrcode (CSP-blocked external service
  also leaked the secret), dashboard shows per-user enrollment, not policy
- invoices/orders: per-line VAT survives re-saves (numberOr 0-respecting
  coercion in formatters.ts), billing_text persists on update, issued-order
  status transitions update UI gates
- trips: real pagination on all 3 pages, GET /trips/stats server aggregate
  (shared buildTripsWhere + legacy distance coalesce), vehicle_id applies on
  PUT with both-vehicle odometer recompute, print rebuilt (sync window.open,
  escaped template, server totals)
- orders api: attachment_data PDF blob excluded from all non-binary reads
- warehouse: unit field is a Select over UnitEnum, receipt attachments
  downloadable via new authenticated GET route
- downloads: shared RFC 5987 contentDisposition helper — Czech filenames no
  longer 500 (warehouse, received-invoices, orders endpoints)
- misc: block-env hook actually blocks (exit 2 + stderr), project create
  works with empty dates, NaN filter guards on trips endpoints
- deps: remove unused concurrently (clears both critical advisories), pin
  @hono/node-server >=1.19.13 via overrides (clears the 3 moderates without
  the Prisma 6 downgrade), drop deprecated @types stubs

Gates: tsc -b clean - vitest 30 files / 342 tests (31 new) - eslint 0 errors
- build OK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:59:47 +02:00
BOHA
d08e55a41a chore(release): v2.2.0 — issued orders, DB drafts, project numbering, NAS split, per-currency totals, perms cleanup
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:40:12 +02:00
BOHA
97101c4db7 feat(offers,invoices): per-currency 'Celkem' totals beneath the lists (consistent with orders)
Mirrors the orders totals exactly: /stats (offers) + /list-totals (issued +
received invoices) endpoints sum each doc's total per currency across the active
filter (reusing extracted where-builders so they stay in sync with the lists),
rendered as the identical 'Celkem: …' block via the shared formatMultiCurrency.
Existing invoice/received KPI stats untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:12:24 +02:00
BOHA
1e4dd1fbcc revert(orders-pdf): remove the E-mail line from the issued-order footer
Keeps the 'Vystavil: <name>' line (logged-in user) and the removed Schválil
signature row; drops the e-mail line + its param field/translation keys per
request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:56:54 +02:00
BOHA
5462fcf944 feat(orders): PDF footer = issuer name+email, 'Zobrazit objednávku' button, per-currency list totals
- Issued-order PDF: drop the Vystavil/Schválil signature row; footer now shows
  'Vystavil: <name>' + 'E-mail: <email>' from the logged-in user (authData).
- IssuedOrderDetail PDF button 'Export PDF' -> 'Zobrazit objednávku' (already
  opens inline like invoices' 'Zobrazit fakturu').
- New /orders/stats + /issued-orders/stats endpoints sum order total (incl VAT)
  per currency across the active filter; rendered as 'Celkem: …' beneath both
  the received and issued lists. formatMultiCurrency lifted to a shared util.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:50:05 +02:00
BOHA
78cd7cf2d8 fix(shell): use 100dvh instead of 100vh so mobile has no phantom scroll
AppShell sized the shell with minHeight:100vh while #root (GlobalStyles) and the
Odin pane (OdinChat) use 100dvh. On mobile 100vh = the large viewport (address
bar hidden), a chrome-bar taller than the visible 100dvh, so the shell floor
pushed the document a few px over and html{overflow-x:hidden} (the page scroller)
turned it into a small downward scroll. Most visible on /odin (a fixed 100dvh
pane that otherwise never scrolls the page). Fixes every page on mobile; desktop
unchanged (100vh==100dvh there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:27:10 +02:00
BOHA
cb82349c86 refactor(orders): rename OrdersReceived to ReceivedOrders; add warehouse module label
Renames the received-orders page for naming consistency, updates the Orders tab
import and the issued-order create button label, and adds the Sklad module
label to role management.
2026-06-09 21:20:50 +02:00
BOHA
d445384408 feat(perms): drop dead document export permissions; seed the 12 survivors via migration
offers.export/orders.export/invoices.export gated nothing on the backend (PDF
routes enforce *.view); they only toggled frontend buttons and showed dead
role checkboxes. Migration deletes them (+ role grants) and idempotently seeds
the 12 enforced doc perms (offers/orders/invoices x view/create/edit/delete) +
admin grant, so a fresh prod DB has them (closes the seed-only drift gap).
Route reverts to orders.view; the 8 frontend *.export checks now use *.view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:19:38 +02:00
BOHA
3268cf2100 fix(nas): order PDF archive uses orders.view (parity w/ invoices) + document NAS_INVOICES/NAS_ORDERS
Review follow-ups: the ?save=1 archive route required orders.export while the
save that triggers it only needs orders.edit, so an editor's fire-and-forget
archive 403'd silently. Use requireAnyPermission(orders.view, orders.export) to
match invoices-pdf (invoices.view). Also refresh .env.example for the split vars.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:59:06 +02:00
BOHA
841ab676ec feat(nas): archive issued-order PDFs to NAS_ORDERS; split NAS_FINANCIALS_PATH into NAS_INVOICES + NAS_ORDERS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:52:06 +02:00
BOHA
313d9218c1 fix(offers,invoices): per-button save loading state (only the clicked button spins)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:41:25 +02:00
BOHA
c270cb22e9 fix(issued-orders): correct status display after create-redirect + per-button loading state
Bug 1: creating an issued order as 'sent' redirected to the detail which showed
'Koncept' until a refresh — the component is reused across create→edit, so the
one-shot hydration (gated by dataReady) never reloaded the real status. Now the
just-saved status is reflected into local form state on every save with a target.

Bug 2: both edit-draft buttons shared one 'saving' flag, so pressing 'Uložit
koncept' made 'Odeslat' show 'Ukládání...'. Track which action is in flight so
only the clicked button spins (all stay disabled to block double-submit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:36:55 +02:00
BOHA
113edd9a0d fix(numbering): release project number on order delete + harden split tests + accurate settings preview
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:44:54 +02:00
BOHA
753c16423c fix(settings): bank accounts saved blank — flatten payload + harden schema to strictObject
The bank form posted {id, bank:{...}} (nested) but the route/schema read fields
at the top level; lenient z.object silently stripped the wrapper, so every field
saved as null while returning success. Flatten the payload to top level and switch
the create/update schemas to strictObject so a future nesting 400s loudly instead
of saving a blank record. Affects both create and edit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:39:43 +02:00
BOHA
11b71501ee feat(numbering): add project_number_pattern + project_type_code to company_settings
Migration backing the dedicated project number sequence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:32:20 +02:00
BOHA
5c03570f07 feat(settings): separate numbering config for received orders, issued orders, and projects
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:31:36 +02:00
BOHA
ed9ea52a44 feat(numbering): projects get their own dedicated number sequence (decoupled from orders)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:24:51 +02:00
BOHA
9e9aacdf0b fix(drafts): exclude drafts from VAT/revenue/created aggregations + koncept audit labels
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:04:10 +02:00
BOHA
52ff0a6ffa feat(drafts): show Koncept for null numbers in lists + fix filtered empty-state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:52:34 +02:00
BOHA
a15fa68c3b fix(drafts): resync invoice number field after in-place draft→issued finalize
Mirrors the issued-order po_number resync so all three detail pages
surface the freshly-assigned number after finalize without a reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:50:18 +02:00
BOHA
e86c2e9a80 feat(drafts): two-button create (Vytvořit / Uložit koncept) + draft finalize UX across offers/invoices/issued-orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:44:10 +02:00
BOHA
ce636215dc feat(drafts): remove localStorage draft banner/hook; add Koncept status + filter (drafts are DB rows)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:25:24 +02:00
BOHA
473f7c4a8c feat(drafts): DB drafts with deferred numbering — create-as-draft (no number), assign number on finalize (offers/invoices/issued-orders)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 18:04:11 +02:00
BOHA
28186397a0 fix(ui): line-item number inputs — allow clearing the 0 (store raw string, coerce at totals/save), unify value alignment, widen Množství column
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:38:28 +02:00
BOHA
a3948c7da1 fix(ui): hide invoice list delete button for paid invoices (consistent with detail guard)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:12:44 +02:00
BOHA
53c8359acc feat(ui): unify VAT rate+toggle layout (offers/invoices/orders); move offers secondary row actions into an overflow menu
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:04:44 +02:00
BOHA
c2d7a96718 feat(ui): invoice notes use RichEditor (sanitized render + PDF), matching offers/orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:59:43 +02:00
BOHA
61673247bf feat(ui): list parity — row tinting, received-orders status filter, more sortable cols, search-aware empties, skeleton suppression
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:55:50 +02:00
BOHA
9fe5113c5b fix(ui): cross-module mechanical unifications (line-item labels/align, back-tab targets, status-button colors, apply_vat checkbox, currency-from-settings, read-only issued_by, Celkem header, centered offer tabs)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:49:31 +02:00
BOHA
e868ecf769 refactor(ui): shared searchable CustomerPicker across offers/invoices/issued-orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:42:24 +02:00
BOHA
20177e8f87 fix(ui): guard delete on paid invoices / ordered-or-locked offers; add guarded delete to issued orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:36:32 +02:00
BOHA
39565767ef fix(ui): wire invoice draft restore (read-back) so the banner actually restores and autosave can't destroy the draft
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:32:20 +02:00
BOHA
c4668357aa feat(ui): shared record-scoped useDocumentDraft hook; fix half-wired invoice draft banner; dedupe draft keys
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:24:22 +02:00
BOHA
b4f1b6ce2b refactor(ui): OrderDetail uses shared ORDER_STATUS (removes the last duplicate status map)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:17:52 +02:00
BOHA
66235ff567 refactor(ui): single shared documentStatus map across offers/invoices/orders (fix issued color drift, add offers status chip)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:12:44 +02:00
BOHA
d341db2e51 docs: cross-module UI + state/draft consistency audit (Offers/Invoices/Orders)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:05:00 +02:00
BOHA
430ec3e76b fix(ui): make item description a drag-resizable textarea (rows + resize:vertical) across invoice/order/offer
Replaces MUI autosize (no handle, mis-wraps in table cells) with a fixed 2-row textarea + resize:vertical so the box wraps and the user can drag it taller. Restores the dead '& input' sx as '& textarea'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:37:34 +02:00
BOHA
f295af4a14 fix(ui): auto-grow item detail-description box + offer money columns to 8rem (consistency across invoice/order/offer)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:28:17 +02:00
BOHA
db11999c85 fix(ui): widen 'Jedn. cena' column in invoice + issued-order line-item tables (5.5rem -> 8rem)
The editable unit-price number input was cramped (5.5rem, same as qty/unit); widened to 8rem to match the Celkem column and the read-only detail view, so real prices fit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:23:16 +02:00
BOHA
afef0e6759 docs(orders): sync spec/plan/CLAUDE.md to as-built (Vydané-first tabs, month filter, shared PDF template, Prisma 7 / React 19)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:50:41 +02:00
BOHA
62f50d31af Merge feat/issued-orders: issued purchase orders (objednávky vydané)
New issued-purchase-order document type (schema, numbering, service, routes, PDF, frontend Vydané tab) plus the orders/invoices consistency pass (Vydané-first tabs, shared month filter, shared red-accent PDF template). Dependency upgrades (React 19, Prisma 7, file-type 22) were landed separately on master first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:41:46 +02:00
BOHA
3d3df6db85 feat(orders): invoices-style landing — month selector + Vydane-first tabs, content-only tab bodies
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:33:36 +02:00
BOHA
162f9b9fdf feat(orders): issued-order PDF on the shared invoice/confirmation template
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:27:12 +02:00
BOHA
3b48bd0523 feat(orders): month/year filter on both order lists + fix received-orders search
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:22:39 +02:00
BOHA
2b7b480144 fix(orders): back button links to Vydané tab, not the nonexistent /orders/issued
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:53:23 +02:00
BOHA
e26fce092a fix(orders): authenticated PDF open in issued-orders list
The list opened the PDF route via raw window.open, an unauthenticated top-level navigation that 401s on the token-guarded endpoint. Now uses the apiFetch -> blob -> window pattern from InvoiceDetail/IssuedOrderDetail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:46:50 +02:00
BOHA
d7e0ba933d feat(orders): issued-order create/edit form + PDF export button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:44:02 +02:00
BOHA
8b90cfed1f feat(orders): Orders page Přijaté|Vydané tabs + issued list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:37:03 +02:00
BOHA
8476ffebd6 feat(orders): issued-order query options + types
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:32:51 +02:00
BOHA
63ccf8fda7 feat(orders): issued-order PDF export route
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:31:05 +02:00
BOHA
9f4b8a897a feat(orders): issued-orders CRUD routes + audit entity type
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:26:32 +02:00
BOHA
2a3df15565 fix(orders): release PO number against creation year; tighten sort-dir + enum typing
Addresses review of the issued-orders service: deleteIssuedOrder now derives the release year from created_at (cross-year deletes reclaim the right sequence); list sort direction is normalized to asc/desc; status cast uses $Enums instead of as-never. Adds a cross-year delete test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:23:45 +02:00
BOHA
b1f48df30c feat(orders): issued-orders service (CRUD + totals + transitions)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:14:48 +02:00
BOHA
f00fcf95fd feat(orders): issued-order Zod schemas
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:09:50 +02:00
BOHA
4093664936 feat(orders): issued-order number sequence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:04:48 +02:00
BOHA
ecb83d4654 feat(orders): issued_orders schema + migration
Pre-existing quotations.project_code + ai_chat_messages FK drift is captured in its own reconcile_quotations_aichat_fk migration; the issued_orders migration is purely additive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:52:42 +02:00
BOHA
a06ea2e1cd docs(orders): plan fix — no manual migration SQL edits (code-level numbering defaults)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:37:40 +02:00
BOHA
15a2da38cc docs(orders): implementation plan for issued purchase orders
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:25:36 +02:00
BOHA
4072197f4a docs(orders): design spec for issued purchase orders (objednávky vydané)
Brainstormed design for a new 'objednávky vydané' document type — purchase
orders you author with line items and export to PDF, alongside the existing
customer orders (which become the 'přijaté' tab).

Key decisions: dedicated issued_orders/issued_order_items models mirroring
invoices; customers reused as supplier/partner; NET+VAT-on-top; dedicated
numbering sequence; reuse of orders.* permissions; standalone v1 (no
warehouse/received-invoice links).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:35:02 +02:00
BOHA
1914fddb4d chore(deps): upgrade file-type 16.5.4 -> 22.0.1 (clears the ASF-parser DoS)
file-type 16 -> 22 fixes GHSA-5v7r-6r5c-r473 (infinite loop in the ASF parser on
malformed input). v22 is ESM-only with a strict `exports` map, so the CommonJS
server can no longer `require()` it (and tsc would down-level a normal dynamic
import() back to require()). Converted the single call site in
nas-file-manager.ts to a cached runtime dynamic ESM import via the
Function('return import("file-type")') indirection (the same pattern server.ts
already uses for Vite), and updated the API to the v22 named export
`fileTypeFromBuffer` (was the v16 default `FileType.fromBuffer`).

Verified at runtime that the dynamic import loads v22 from CJS and detects
PNG/JPEG/PDF correctly. `npm audit` no longer lists file-type (6 -> 5 vulns; the
rest are quill [Phase 4] and Prisma 7's dev-tooling @hono/node-server).

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:12:23 +02:00
BOHA
63192dc781 chore(deps): upgrade Prisma 6.19 -> 7.8.0 (Rust-free client + mariadb adapter)
prisma / @prisma/client 6.19.2 -> 7.8.0; added @prisma/adapter-mariadb 7.8.0 and
mariadb 3.5.2 (the runtime driver).

Prisma 7 breaking changes handled (verified via Prisma's own tooling — the
official upgrade-guide summary had hallucinated package names, so I confirmed
everything against `prisma validate`/`generate` and the installed type defs):

- The Rust query engine is removed; the runtime connection is now made through a
  driver adapter. src/config/database.ts wires PrismaMariaDb, parsing
  DATABASE_URL into an explicit mariadb pool config so the literal `@` in our
  password (which a naive connection-string parser mishandles) is correct.
- datasource `url` is no longer allowed in schema.prisma -> moved to a new
  prisma.config.ts (schema + datasource.url + migrations.seed). Prisma 7 no
  longer auto-loads .env, so the config does `import "dotenv/config"`.
- The deprecated package.json#prisma seed config was removed (now in the config).
- seed.ts and the two maintenance scripts now use the shared adapter-wired client.

The legacy `prisma-client-js` generator STILL works under v7 (generates to
@prisma/client as before), so NO import-path changes were needed across the 16
@prisma/client import sites — switching to the new prisma-client/output generator
is not required for us. No prisma.$use() middleware exists. Node 24 / TS 5.9
exceed the v7 minimums (20.19 / 5.4).

No migrations were run (your DBs are untouched); removing datasource.url is a
config change, not DDL, so this phase needs no new migration.

Gates: tsc 0 | build 0 | vitest 247/247 against app_test (proves the mariadb
adapter works at runtime, incl. raw SELECT...FOR UPDATE locking and Decimal/VAT)
| eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:06:02 +02:00
BOHA
6147131aad chore(deps): upgrade React 18.3 -> 19.2.7
react / react-dom 18.3.1 -> 19.2.7; @types/react 18.3.31 -> 19.2.17;
@types/react-dom 18.3.7 -> 19.2.3 (latest stable 19.2.x).

Only breaking change hitting our code: React 19 removed the no-arg useRef<T>()
overload. AppShell's logoutTimer -> useRef<T | undefined>(undefined) — the same
transform the official `types-react-codemod preset-19` (useRef-required-initial)
applies. No other preset-19 transforms were relevant (already on createRoot; no
ReactDOM.render, string refs, propTypes/defaultProps on function components).

No Next.js / RSC in this project (it's a Vite SPA served by Fastify), so those
React 19 concerns don't apply here. MUI v7, framer-motion 12, TanStack Query 5,
react-router-dom 6 and react-quill-new all installed cleanly against React 19
(no peer-dependency conflicts).

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:51:43 +02:00
BOHA
b4f859ea6e docs: codify audit rules in CLAUDE.md + README + audit records
CLAUDE.md: new "2026-06-09 audit" enforced-rules section (mandatory coercion
helpers, deterministic id-tiebreak ordering, $queryRaw FOR-UPDATE locking
discipline, uniqueness-in-transaction, guard reads, Rules-of-Hooks, seed prod
guard), lint/typecheck/format commands, and the app_test/.env.test testing
section with the hard-throw guard.

README rewritten from the placeholder stub (setup/run/build/test/gates/migrations).

REVIEW.md / REVIEW_FINDINGS.md / REVIEW_FIXES.md: the full audit record — 277
files reviewed, ~362 findings, every one fixed and verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:37 +02:00
BOHA
519edce373 fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:26 +02:00
BOHA
c454d1a3fc feat(db): audit schema hardening (onDelete, unique doc-numbers, indexes)
From the 2026-06-09 full-codebase audit. Warehouse line->header relations
CASCADE on delete; line->master-data and the financial FKs are explicit
RESTRICT; UNIQUE on receipt/issue/session numbers; new indexes on hot FK
columns (sklad_issues/receipts, bank_accounts); dropped a duplicate
audit_logs created_at index. Applied to dev `app` and `app_test`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:04 +02:00
BOHA
b1f21a1f48 docs(claude): document Odin AI assistant + correct stale counts
- Add "AI Assistant — Odin" section (v2.1.5): scope (invoice-only guard),
  SDK/model, backend/data/frontend layout, gross-VAT received-invoices,
  Phase 2 pointer.
- Add AI row to the tech stack.
- Correct counts: 49→52 Prisma models, ~14→17 test files / 152→195 tests,
  ~105→114 admin .tsx; note odin/ components.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:36:58 +02:00
BOHA
b6cd5046d5 chore(release): v2.1.5 — Odin multi-conversation chat (sidebar, invoice import)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:28:43 +02:00
BOHA
25c8c39ba6 feat(odin): scope to invoice processing only (guard general chat)
Odin currently advertises general Q&A via the /chat path, which burns API
credits on open-ended questions. Guard it: a text-only message makes NO AI
call — it gets a canned reply telling the user to attach an invoice. The
invoice path (attachments → extract) is unchanged. Removing the guard
re-enables the general /chat path for a later phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:26:54 +02:00
BOHA
4b2770a000 fix(odin): show conversation auto-title immediately (no refresh needed)
ensureActive() created the conversation and invalidated the list before the
server-side auto-title was applied (that happens on the message append), so
the sidebar showed "Nová konverzace" until a refresh. Move the
["ai","conversations"] invalidation to persist()'s success path — after the
append (and thus the auto-title) lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:22:31 +02:00
BOHA
434a12b4a8 feat(odin): mobile composer + responsive sidebar + message-entrance animation
- Composer rebuilt claude-style: a rounded pill with a multiline auto-growing
  textarea and attach/send ICON buttons (vertically centered), so there's room
  to type on mobile (was squeezed between two text buttons).
- Conversation list collapses into a slide-in Drawer below md, with a menu
  button in the chat header; chat is full-width on mobile.
- Each new message bubble animates in (fade + slide-up; fade-only under
  prefers-reduced-motion) via framer-motion, so sent messages and Odin's
  replies appear smoothly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:19:54 +02:00
BOHA
28fb399217 feat(odin): refine hero — Newsreader serif, red name, staggered entrance
- Swap Fraunces → Newsreader (literary serif, full Czech, optical sizing).
- Split the greeting so the user's first name renders in brand-red italic.
- Add a subtle staggered fade-in (framer-motion), skipped under
  prefers-reduced-motion.
- New description that names Odin and covers chat + invoice import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:03:16 +02:00
BOHA
c0ff7b4549 feat(odin): editorial serif hero — Fraunces greeting + italic subtitle
Drop the gradient "Odin" wordmark from the new-conversation hero and make the
personalized greeting the centerpiece in Fraunces (optical-sized serif), with
an italic Fraunces description — a refined, literary, claude-like treatment.
Loads Fraunces (upright + italic) via the existing Google Fonts link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:57:15 +02:00
BOHA
1e21c12f33 feat(odin): personalized greeting hero on the new-conversation screen
Time-based Czech greeting + the user's first name (e.g. "Dobrý večer, Admin")
above the big gradient "Odin" wordmark, with a stylish description line. Drop
the redundant mark from the hero (it lives on the nav item + thinking state).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:51:00 +02:00
BOHA
ab12f9d791 feat(odin): sidebar brand is a big gradient "Odin" wordmark (drop the icon)
The mark already lives on the nav menu item; the conversation sidebar's brand
no longer duplicates it — just a larger gradient "Odin" wordmark above the new
chat button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:47:39 +02:00
BOHA
cf442b9a99 feat(odin): big claude.ai-style hero on the new-conversation screen
Replace the small empty-state with a large centred hero — the animated Odin
mark + a big gradient "Odin" wordmark (responsive clamp font) and the helper
line beneath.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:44:38 +02:00
BOHA
2e7caea5a6 feat(odin): use the Odin mark as the sidebar nav icon
Replace the chat-bubble glyph on the "Odin" nav item with the OdinMark, and
make the mark's SVG sizing !important so the nav's `& svg { width:18 }` rule
can't shrink it (its `size` prop stays authoritative everywhere).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:41:20 +02:00
BOHA
702a4984ee feat(odin): animated Odin brand mark (AI core orbiting business data)
A self-contained animated SVG mark in BOHA red: a 4-point AI spark (Odin) that
slow-rotates and breathes, a data node orbiting a faint ring (the assistant at
the centre of the business system). An "idle" and a "thinking" state (faster
orbit + red glow); honours prefers-reduced-motion. Used as the sidebar brand,
the empty-state hero, and the busy/thinking indicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:31:48 +02:00
BOHA
769767c33d feat(odin): left sidebar + lazy conversation creation (claude.ai-style)
- Replace top tabs with a left sidebar (New chat + scrollable conversation
  list, per-row rename/delete menu, active highlight), chat on the right.
- Fix duplicate-empty-conversations bug: "Nová konverzace" now just opens an
  empty composer; a DB row is created only on the first successful message
  (and a failed AI call no longer leaves an orphan conversation). Repeated
  clicks never spawn empties, and nothing empty survives a refresh.
- Land on a fresh new chat on mount; deleting the open conversation returns
  to new chat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:07:07 +02:00
BOHA
f18e2e0f2c docs(odin): forward-looking notes for a Phase 2 general assistant
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:52:30 +02:00
BOHA
d62abe81fd feat(odin): use the full page (full width + taller shell)
Drop the 1100px max-width cap and size the chat shell to fill the content
area (100dvh - 100px = top bar + main bottom padding), so Odin uses the whole
page now that it owns the route.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:51:23 +02:00
BOHA
ac7c220bb1 fix(odin): gate Uložit on invoices.create + correct invoice invalidation key
Final review caught two minor issues:
- saveInvoice invalidated ["received-invoices"], which matches no query (the
  received list/stats are keyed ["invoices","received",…]); use ["invoices"]
  so the list/stats actually refresh after an import.
- The Save button hit /received-invoices (requires invoices.create) while Odin
  is gated only on ai.use; disable Uložit (with a hint) when the user lacks
  invoices.create so the UI never offers a 403 action. Dormant today (ai.use is
  admin-only and admin bypasses checks) but correct going forward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:46:49 +02:00
BOHA
b7ebf04001 feat(odin): OdinChat orchestrator; render on /odin; remove DashAssistant
Wire OdinTabs/OdinThread/InvoiceReviewCard/OdinComposer into a single
OdinChat orchestrator; mount it on /odin (maxWidth 1100); delete the
old DashAssistant and its aiHistoryOptions query helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:39:15 +02:00
BOHA
441f46a471 feat(odin): tabs, thread, review-card, composer components
Add four presentational components + shared types under
src/admin/components/odin/ as props-driven building blocks for the
multi-conversation Odin page (Task 6 wires state/queries).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:34:22 +02:00
BOHA
f011a67ff3 feat(odin): conversation query options 2026-06-08 19:28:04 +02:00
BOHA
92594f89c3 test(odin): conversation CRUD, isolation, auto-title, cascade
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:27:31 +02:00
BOHA
6664e3bc4d feat(odin): conversation-scoped service + routes (replace /history)
Remove single-thread getChatHistory/appendChatMessages/clearChatHistory and the
three /history routes; add listConversations, createConversation, getConversation-
Messages, appendConversationMessages, renameConversation, deleteConversation with
ownership checks and auto-title; wire six /conversations[/:id] routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:21:13 +02:00
BOHA
c5cfd855ba feat(odin): ai_conversations table + conversation_id on messages 2026-06-08 19:14:01 +02:00
BOHA
2565e0de21 docs(odin): implementation plan for multi-conversation chat
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:11:54 +02:00
BOHA
817f0b2f00 docs(odin): design spec for multi-conversation chat
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:01:50 +02:00
BOHA
e4ec08a5ef feat(ai): move the assistant to its own "Odin" sidebar page
Promote the AI chat from a dashboard widget to a dedicated page:
- new sidebar item "Odin" under the Přehled section, gated on ai.use, → /odin
- new /odin route + Odin page (reuses the assistant component, with a light
  ai.use guard)
- remove the widget from the dashboard
- rebrand the assistant header "Asistent" → "Odin" and give the thread more
  height now that it owns the page

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:38:35 +02:00
BOHA
049ee492c0 chore(release): v2.1.0 — AI assistant (chat, PDF invoice import, budget)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:33:19 +02:00
BOHA
f4bc038f8b fix(received-invoices): treat amount as gross (VAT-inclusive), back-calc VAT
The amount on a received invoice is the GROSS total the user pays (VAT
included), and VAT is the portion contained within it — not a net base with
VAT added on top. Switch the formula from amount*rate/100 to the
VAT-inclusive amount*rate/(100+rate) via a shared, tested vatFromGross()
helper used by both the create (manual upload + AI import share this
endpoint) and edit paths. Rate 0 → no VAT.

- AI extraction now asks Claude for the total INCLUDING VAT (was "základ bez
  DPH"), reinforced in the structured-output schema description.
- Label the amount field "Částka s DPH" in the AI review card and the manual
  upload/edit forms so the gross convention is explicit.
- Add unit tests with the user-confirmed figures (22 542,91 @ 21% → 3 912,41,
  base 18 630,50) plus other rates and the no-VAT case.

Existing rows keep their stored (gross) amounts; their vat_amount recomputes
on next edit. A one-shot backfill can correct historical vat_amount if wanted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:27:56 +02:00
BOHA
58e5fd2b1d feat(ai): server-side chat history + assistant UX fixes
Server-side, per-user chat history (the thread now survives reload):
- new ai_chat_messages table + migration; getChatHistory/appendChatMessages/
  clearChatHistory; GET/POST/DELETE /api/admin/ai/history (ai.use-guarded);
  service + HTTP tests (isolation, ordering, clear, perm, validation).
- DashAssistant loads the thread on mount and persists each turn; "Vymazat"
  clears it. gcTime:0 so each mount re-reads the DB (no stale-cache resurrection);
  composer gated until history settles + seed flag flipped on submit (no
  seed/submit race clobbering a freshly-sent turn).

UX + correctness fixes from review/feedback:
- review cards moved OUT of the scrollable thread into a "Faktury k potvrzení"
  section so Uložit is always reachable; chat auto-scroll no longer fires on
  field edits.
- chat bubble text colour set on the Typography (GlobalStyles pins `p` to
  text.secondary, which was overriding the inherited bubble colour → unreadable).
- never clear input/staged PDFs on a failed submit (rollback + keep), so a
  budget error can't wipe staged invoices; roll back the optimistic user turn.
- stable attachment ids (no crypto.randomUUID — undefined over plain HTTP).
- editable Datum splatnosti + Popis fields; client-side extraction summary.

Security: DashProfile clipboard copy now uses an execCommand fallback (works
over plain HTTP) and only shows the success toast when the copy actually
succeeded — previously it falsely claimed to copy 2FA codes / the TOTP secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:02:26 +02:00
BOHA
0226cdd52b fix(ai): cap extract-invoices batch at 20 files
Final review flagged that the multipart limits set only fileSize, so a
single /extract-invoices request could carry unbounded PDF parts. The
per-batch budget re-check already bounds total spend, but this bounds the
work (one vision call per file) of any single request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:05:16 +02:00
BOHA
b665ea1d9b feat(ai): dashboard chat widget with invoice import + budget indicator 2026-06-08 15:01:52 +02:00
BOHA
ec74ded953 feat(ai): chat / extract-invoices / usage / budget routes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:50:23 +02:00
BOHA
bcf63d00d1 feat(ai): spend tracker + AI service (chat, extractInvoice)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:41:03 +02:00
BOHA
096784768f feat(ai): add @anthropic-ai/sdk, ai_usage table, ai.use permission
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:23:06 +02:00
BOHA
3623b441ce docs(ai): implementation plan for AI assistant Phase 1 (v2.1.0)
Five tasks: (1) foundation — SDK dep, env, ai_usage table + ai.use
permission migration; (2) spend tracker + ai.service (chat,
extractInvoice) with cost/budget tests; (3) /ai routes (usage, budget,
chat, extract) with guard tests; (4) DashAssistant dashboard widget;
(5) v2.1.0 release. Anthropic call stubbed in tests; migration apply is
controller-coordinated (dev server stopped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:19:06 +02:00
BOHA
4bf7bf4ed6 docs(ai): spec for AI assistant Phase 1 (chat + invoice import + budget)
Admin-only Claude (Sonnet 4.6) chat embedded on the dashboard under the
welcome text. Attach received-invoice PDFs → extract (vision + structured
output) → confirm/edit → save via the existing received-invoices endpoint.
$50/month admin-editable budget enforced via an ai_usage table + pre-call
guard. New @anthropic-ai/sdk dep, ANTHROPIC_API_KEY, ai.use permission;
one migration. No system-data tools (Phase 2). Ships as v2.1.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:09:47 +02:00
BOHA
92252382ac chore(release): v2.0.8 — show all employees in dashboard attendance
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:07:59 +02:00
BOHA
8bed920de1 feat(dashboard): show every active employee in "Docházka dnes"
The attendance card listed only employees with a record today (present /
break / clocked-out / on-leave). Now every active employee appears;
those with no record today show as "absent" (Nepřítomen), so the card
reflects the whole team. Loads active users (was a bare count) and
appends the unrecorded ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 13:00:09 +02:00
BOHA
9d2992b722 chore(release): v2.0.7 — always show notes in multi-record plan cells
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:54:47 +02:00
BOHA
1ddc0feccd fix(plan): always show notes for every record in a multi-record cell
Previously notes were hidden when a cell held 2-3 records (a density
choice). Show each record's note regardless of count — drop the
now-unused showNote prop on PlanRangeChips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:53:45 +02:00
BOHA
cce2c9cfaa chore(release): v2.0.6 — bulk plan creation
Assign one project/category to many employees over a date range in one
action. "Hromadné přiřazení" on /plan-work: employee checklist +
include-weekends toggle, mirrors the single-create form. Creates range
entries grouped by contiguous eligible days per employee (weekends-off →
per-work-week chunks); per-day cap skips days already at 3; 92-day range
cap. Reuses createEntry; summary audit + result counts. No migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:48:09 +02:00
BOHA
2610301258 harden(plan): 92-day range cap + dedup user_ids on bulk create 2026-06-08 12:17:51 +02:00
BOHA
13d1fc2be3 feat(plan): bulk assignment modal on /plan-work
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:11:20 +02:00
BOHA
95ca258718 feat(plan): bulk entry creation endpoint + service 2026-06-08 12:03:47 +02:00
BOHA
6db87bf4ae docs(plan): implementation plan for bulk plan creation (v2.0.6)
Three tasks: (1) backend bulk endpoint + bulkCreateEntries service
(reuses createEntry; weekday filter + contiguous-run segmentation +
per-day cap skip) with TDD tests, (2) BulkPlanModal + PlanWork wiring
+ usePlanWork bulkCreate mutation, (3) v2.0.6 release. No migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:54:44 +02:00
BOHA
c6a146d57a docs(plan): spec for bulk plan creation (Feature B)
Assign one project/category to many employees over a date range in one
action. Modal mirrors single-create + employee checklist + "include
weekends" toggle. Stores range entries grouped by contiguous eligible
days per employee (weekends-off → per-work-week chunks); per-day cap
skips days already at 3. POST /plan/entries/bulk + bulkCreateEntries
(reuses createEntry); summary audit + result counts. No migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:49:55 +02:00
BOHA
1a262508d4 chore(release): v2.0.5 — multi-record plan cells
A plan cell now holds up to 3 records: additive assignments + day
exceptions, via the layered entries/overrides model (no migration).
resolveCell/resolveGrid return arrays; cap enforced per layer on create;
grid renders up to 3 stacked; cell editor is a day panel (list/add/edit,
layer-aware add); dashboard "today" card shows up to 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:37:40 +02:00
BOHA
23ac924472 fix(plan): edit/add the clicked record + layer-aware add in exception mode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:29:52 +02:00
BOHA
9370423fb0 feat(plan): day panel — list/add/edit up to 3 records per cell
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:16:52 +02:00
BOHA
a146fc26a3 feat(plan): grid renders up to 3 stacked records per cell
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:08:48 +02:00
BOHA
72888bf9cd feat(plan): resolveCell/resolveGrid return arrays; dashboard shows up to 3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:53:26 +02:00
BOHA
80dc8a5c69 feat(plan): per-cell record cap (3) + additive overrides
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:46:40 +02:00
BOHA
2cfa28dc47 docs(plan): implementation plan for multi-record plan cells (v2.0.5)
Five tasks: (1) backend cap + additive overrides, (2) resolve->arrays +
coordinated frontend shape change + dashboard, (3) grid stacked render,
(4) day-panel cell editor, (5) v2.0.5 release. Backend tasks are TDD
against the real test DB; frontend tasks gate on tsc/build/Chrome (no
component-test harness).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:33:40 +02:00
BOHA
628cd54a81 docs(plan): spec for multi-record plan cells (max 3 per cell)
Feature A of 2: lets a plan cell hold up to 3 records (additive
assignments + day exceptions) via the layered entries/overrides model,
no migration. resolveCell/resolveGrid return arrays; cap enforced per
layer on create; grid shows up to 3 stacked; cell editor becomes a
day panel; dashboard today-card shows up to 3. Feature B (bulk create)
decisions captured as deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:22:15 +02:00
BOHA
9ae69e09a3 fix(orders): delete NAS folder when order delete modal's checkbox is ticked
The order delete modal sends { delete_files }, but the route ignored the
body and deleteOrder never touched the NAS — so the project folder of an
order-spawned project always stayed on the share (the only way to delete
such a project is via its order, since deleteProject blocks order-linked
projects). Project delete (standalone projects) already worked.

- orders route: read delete_files from the body, pass to deleteOrder.
- orders.service deleteOrder(id, deleteFiles): select project_number and,
  after the DB transaction commits, best-effort deleteProjectFolder for
  each linked project when the flag is set. Non-fatal; idempotent.
- tests: NasFileManager.deleteProjectFolder coverage — removes by number
  prefix, no-op when absent, false when not mounted (158 -> 161 tests).

Bump version to 2.0.4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:51:57 +02:00
BOHA
df83eb2091 feat(projects): auto-create NAS folder for order-spawned projects
Manual project creation already creates the 02_PROJEKTY/<number>_<name>
folder (projects.service.createProject). Projects created from an order —
both from an accepted offer (createOrderFromQuotation) and a manual
offer-less order (createOrder) — did not, so they had no NAS folder until
a user opened the project and clicked "create folder" or uploaded a file.

- orders.service: best-effort, non-fatal createProjectFolder after each
  project-creating transaction commits (mirrors createProject; gated on
  isConfigured(), logs on failure, never rolls back the order).
- projects.service: log when the existing auto-create returns false
  (was silently discarded — CLAUDE.md known-issue #4).
- nas-file-manager: optional constructor basePath seam for tests.
- tests: NasFileManager.createProjectFolder coverage — naming, idempotency,
  Czech diacritics, empty name, not-mounted (152 -> 158 tests).

Bump version to 2.0.3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:43:59 +02:00
BOHA
9c862034ca chore(release): v2.0.2 — dashboard "today's assignment" card
- New "Vaše dnešní zařazení" dashboard card: shows the logged-in user's
  work-plan entry for today (category + project/site + note, multi-day
  range badge), with a muted empty state.

No DB migrations. tsc -b --noEmit, npm run build, vitest 152/152 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:26:02 +02:00
BOHA
67d62a6df0 fix(dashboard): rename today-plan card to "Vaše dnešní zařazení"
Clearer, formal (matches the app's Vy/Vaše tone) and category-agnostic
(reads fine for work and absence days) so the employee plainly sees what
they're assigned to. Was "Můj plán na dnešek".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:25:35 +02:00
BOHA
512f1fd92b feat(dashboard): show the user's work plan for today
Adds a "Můj plán na dnešek" card near the clock-in: for the logged-in
user it shows today's work-plan entry — category (coloured dot + label),
the project/site (the "where"), the note, and a "součást rozsahu …" badge
only for genuinely multi-day ranges. A muted "Pro dnešek nemáte
naplánováno." empty state when nothing is scheduled.

Server: GET /api/admin/dashboard now returns today_plan, reusing
plan.service resolveCell (override-beats-range precedence) for the current
user + today's local (Prague) date, enriched with the category label +
colour so the widget needs no extra query. Gated by attendance.record /
attendance.manage (the plan permission); omitted otherwise. No migration.

Verified in Chrome: renders today's entry (Práce · OBJ-2026-006 — koko)
with the category colour. tsc -b --noEmit, npm run build, vitest 152/152.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:22:07 +02:00
BOHA
941caf9d85 chore(release): v2.0.1 — button/icon consistency + mobile header actions
UI consistency pass on top of v2.0.0:
- Colored button text unified to white on all filled controls (both
  themes); darker dark-mode fills so white stays legible.
- Icon badges unified via a shared iconBadgeSx (solid tile + white glyph).
- Detail-page header action buttons stack one-per-row on mobile.
- Dropped the duplicate "Zrušit" in the plan view-cell modal.

No DB migrations. tsc -b --noEmit, npm run build, vitest 152/152 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:55:20 +02:00
BOHA
b00d18d0b3 fix(plan): drop duplicate 'Zrušit' in the view-mode cell modal
The read-only plan-cell detail modal (view mode) already has a "Zavřít"
submit button, but the kit Modal also rendered its built-in "Zrušit"
cancel — two buttons that do the same thing. Pass hideCancel so only
"Zavřít" remains (same fix as the category-management modal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:54:42 +02:00
BOHA
decadd895e fix(ui): stack detail-page header action buttons one-per-row on mobile
Detail-page headers put their action buttons in a flex row with
flexWrap:"wrap", so on a phone the 2-4 buttons (Potvrzení / Zahájit
realizaci / Smazat, etc.) wrapped into a ragged grid.

New shared `headerActionsSx` (ui/PageHeader): on xs it's a full-width
column (each button its own full-width row); from sm up it's the usual
right-aligned wrapping row — desktop unchanged. A StatusChip kept in the
same group stays its natural size (alignSelf) instead of stretching.

Applied to PageHeader's own actions slot and every detail-page header
action group: Order/Offer/Invoice (both views)/Project + the Warehouse
Receipt/Issue/Item/Inventory detail headers.

Verified in Chrome at 430px on OrderDetail: the group is flexDirection
column with all three buttons at the same x, full-width, stacked. tsc -b
--noEmit, npm run build, vitest 152/152 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:44:22 +02:00
BOHA
54f3c414f5 fix(ui): unify icon badges — solid tile + white glyph via shared helper
The labelled icon-badge tiles were inconsistent: some used a same-hue
light tile + colored glyph (e.g. the Settings 2FA row: success.light tile
+ success.main glyph — low contrast, the reported "badly visible" icon),
and the solid-tile ones used the bright .main fill in dark mode without
darkening, so a white glyph on bright green/amber was low-contrast too
(DashProfile's success badge was ~1.9:1).

Add a single `iconBadgeSx(color)` helper (theme.ts): solid `<color>.main`
fill + white glyph, darkened in dark mode (FILLED_DARK_BG) so white stays
legible; `default` = neutral grey. Applied to every badge so they can't
drift: StatCard, DashActivityFeed, DashProfile (2FA badge), Dashboard
(2FA banner icon), AttendanceHistory (month tile), and the Settings 2FA
row tile (now solid green when required / grey when optional + white lock,
instead of the low-contrast light-green tile). Also switched DashProfile's
backup-codes warning callout from warning.light to a subtle warning wash
so its amber text stays readable in dark mode.

tsc -b --noEmit, npm run build, vitest 152/152 all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:34:06 +02:00
BOHA
ca092c6166 fix(ui): unify colored-control text — white on all filled, both themes
The text color on filled colored controls was inconsistent: contained
primary resolved to white in both schemes, but error/success/warning/info
used a per-scheme contrastText (#fff light, #1a1a1a dark). So in DARK mode
a primary (red) button showed white text while an error (red) button —
e.g. every ConfirmDialog delete confirm, the contained "Smazat", and the
dashboard quick-action tiles — showed near-black text. "Black text on one
red button, white on another."

Unify on a single rule: every FILLED colored surface gets WHITE text/glyph
in BOTH themes. The palette .main stays bright in dark mode (it drives text,
outlined buttons and row tints), but a bright fill makes white illegible, so
filled surfaces drop to a darker fill in dark mode (new FILLED_DARK_BG, ≈ the
light-scheme shades; all ≥4.5:1 with white):
- theme MuiButton: contained error/success/warning/info -> white text +
  darker dark-mode fill (disabled state preserved).
- theme MuiChip: filled error/success/warning/info -> white label + darker
  dark-mode fill (covers StatusChip + status pills).
- StatCard icon badges: white glyph + darker dark-mode tile.

Plus two consistency cleanups:
- Detail-page "Smazat" unified to variant=outlined color=error (Offer +
  Invoice now match Order/Project/Warehouse; the contained->confirm pattern
  stays: subtle trigger, strong contained confirm dialog).
- "Zrušit filtry" reset buttons -> color=inherit (neutral), matching the
  rest of the secondary-button family (were default outlined-primary).

Neutral (color=inherit) text/outlined buttons were already correct (label =
ambient text color) and are untouched. tsc -b --noEmit, npm run build and
vitest 152/152 all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:41:50 +02:00
BOHA
c7f9d9aa36 docs(claude): update for completed MUI migration + v2.0.0
Bring CLAUDE.md in line with the current codebase:

- Tech Stack: add Material UI v7 (Emotion) to the frontend row.
- Project Structure: ~105 .tsx (was 57), 49 models (was 32); add the
  ui/ kit, lib/ (React Query), theme.ts and GlobalStyles.tsx; fix
  contexts/ -> context/; correct the hooks list (drop the nonexistent
  useApiCall/useListData); refresh the components/ + __tests__ notes.
- Testing: drop "coverage is minimal: only auth and numbering" — the
  suite is ~14 files / 152 tests across many areas.
- Frontend Conventions: styling is MUI/Emotion (no .css files, use
  theme.vars), data layer is React Query, token responses carry
  expires_in.
- "MUI Migration (in progress)" -> "Styling & MUI (complete, v2.0.0)":
  zero custom CSS; where styling lives (theme/GlobalStyles/kit); and the
  conventions learned (channel-alpha row tints not solid .light, solid
  tile + white-glyph icon badges, single mui-mode theme key, dialog
  scroll-lock). Removed the stale "data-theme drives MUI and legacy CSS".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:12:08 +02:00
BOHA
5aaac88b56 chore(release): v2.0.0 — full MUI frontend, zero custom CSS
Major release. The admin frontend is now entirely on Material UI v7:
every page and shared component migrated off ~7,100 lines of hand-written
CSS, and all five custom stylesheets (base/variables/plan/attendance/
offers) eliminated — global styles live in a theme-aware MUI <GlobalStyles>,
widgets (Quill, Leaflet, planner grid) in scoped styled() wrappers, and the
only remaining CSS imports are the two third-party libraries.

Also includes the post-migration polish: consistent page-entrance motion,
unified filters, mobile card layouts, readable status row tints, white-glyph
icon badges, single-key theme persistence, and the offer-page fixes
(heartbeat 401 handling, expires_in token lifetime, duplicate-key + motion
deprecation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:57:25 +02:00
BOHA
671323b9ac fix(auth): return expires_in on token responses so the client refreshes early
The login / TOTP / refresh endpoints returned { access_token, user } with no
expires_in. The client (AuthContext.setAccessTokenFn) therefore fell back to
ttl=900s while the dev access token actually lives 60s (ACCESS_TOKEN_EXPIRY).
So the client believed every token was valid for 15 min, kept handing it out
(getAccessTokenFn only nulls 30s before the *believed* expiry) and scheduled
its proactive refresh 14 min out. The token silently died at 60s, the next
request (e.g. the offer lock heartbeat) hit a real 401, and apiFetch refreshed
reactively — producing the steady "…heartbeats → 401 → refresh → …" cycle.

Now every access-token response includes expires_in = accessTokenExpiry, so the
client tracks the true lifetime and refreshes BEFORE expiry (proactive up-front
refresh via the 30s-early null window). No more reactive 401s. In production
(900s) behaviour is unchanged; it just makes the client honour the real TTL.

Verified in Chrome: /api/admin/refresh now returns expires_in:60, and 11
consecutive heartbeats across >1 token cycle were all 200 with one proactive
refresh and zero 401s. tsc -b --noEmit, npm run build, vitest 152/152 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:54:27 +02:00
BOHA
2e4fe2a8fe fix(offers): stop heartbeat 401 spam on dead session + de-dup item keys
Three console issues observed while idle on an offer:

1. Heartbeat flooded the console with 401s. The lock-keepalive interval
   fired apiFetch(...).catch(() => {}) every 10s; apiFetch RESOLVES with a
   401 (it doesn't throw), so .catch never saw it and the interval never
   stopped. Once the refresh token had also expired, every tick 401'd
   forever. Now we inspect the response and clearInterval on a 401 (which
   only occurs after a failed refresh = session truly gone); the healthy
   path still returns 200 and keeps ticking (verified in Chrome).

2. Duplicate React key "item-1" in the line-items table. The form/items/
   sections useState initializers restored the create-mode draft
   UNCONDITIONALLY — even in edit mode — and reused the draft's stale
   item _key values while itemKeyCounter restarts at 0 each load, so an
   added row collided. Now the draft is only restored when !isEdit, and
   restored items are re-keyed with fresh unique _keys.

3. framer-motion "motion() is deprecated" warning — PageEnter used the
   deprecated motion(Box) factory; switched to motion.create(Box).

tsc -b --noEmit, npm run build, vitest 152/152 clean. Console verified
clean in Chrome (only benign dev notices remain).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:28:49 +02:00
BOHA
27c690285a fix(theme): white icon glyphs on solid tiles + single theme storage key
Two issues:

1. Icon badges read as a coloured glyph on a same-hue LIGHT tile
   (bgcolor X.light + color X.main) — low contrast, "badly visible".
   The AttendanceHistory month tile and the Dashboard 2FA-banner icon now
   use a SOLID tile (X.main) + white glyph, matching the StatCard badge
   convention. Readable in both schemes (verified: info.main #1d4ed8 light
   / #3b82f6 dark, error.main, white glyph).

2. Theme reverted to light on F5 while the toggle still showed dark.
   Cause: two independent stores — our ThemeContext key (boha-theme) and
   MUI's own cssVariables mode key (mui-mode). The toggle only wrote
   boha-theme, so on mount MUI restored its stale mui-mode and overrode
   data-theme. Collapsed to a SINGLE source of truth: ThemeContext now
   reads/writes MUI's `mui-mode` key directly (with a one-time migration
   from the legacy boha-theme, which is then removed). Verified: toggle
   dark -> F5 -> stays dark; only `mui-mode` remains in localStorage.

tsc -b --noEmit, npm run build, vitest 152/152 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:12:20 +02:00
BOHA
8e7ab9158c fix(ui): readable status row tints — subtle washes, not solid .light fills
The per-status row backgrounds used MUI's solid `.light` palette shades
(error.light/success.light/warning.light). Those are LIGHT colors in both
schemes, so in dark mode the row's white text sat on a light fill and was
effectively invisible; in light mode they read as garish saturated blocks.

Replaced with low-alpha washes via the palette channel vars
(rgba(var(--mui-palette-X-mainChannel) / 0.12), 0.18 on hover), which keep
the row's normal theme text colour fully readable in BOTH schemes:

- Offers: completed = subtle green wash; invalidated = faded/muted
  (opacity + secondary text, the original "voided" look) instead of a
  solid red block; expired = subtle amber wash.
- DataTable rowDanger (kit-wide — Warehouse, WarehouseReports): subtle
  error wash for both the desktop row and the mobile card (card keeps its
  error.main border as the danger cue).
- Invoices overdue rows: subtle amber wash.
- Dashboard 2FA banner Card: subtle error wash (keeps the error.main
  border) so the banner copy stays readable in dark mode.

Verified live in Chrome on Offers (completed + invalidated, light + dark):
faint tint + normal readable text, channel switches per scheme. tsc -b
--noEmit, npm run build, vitest 152/152 all clean. (Left untouched: small
icon tiles / callout boxes that pair a .light bg with an explicit dark or
coloured text/glyph — those are readable in both schemes by construction.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:57:58 +02:00
BOHA
03285c6ff6 refactor(css): migrate Login + file icons off legacy vars, delete variables.css
Phase 4 (final) of the "fully MUI" cleanup — no custom stylesheets remain
in src/admin (only the third-party quill.snow.css + leaflet.css imports).

- Login.tsx: every var(--glass-*/bg-*/text-*/accent-*/orb-*/border-*/
  font-*/transition) in its sx/inline styles is now an MUI token
  (bgcolor/color/borderColor: "background.paper" / "text.secondary" /
  "divider" / "primary.main"), a channel-alpha (rgba(var(--mui-palette-
  primary-mainChannel) / a)) for tints, or theme.applyStyles("dark", …)
  for the glass-card shadow. The card sx is now a (theme)=>({}) callback.
- Restores the orb drift animation via the MUI keyframes() helper — the
  @keyframes float had been dropped in Phase 1 (the dead-CSS scan missed
  its use inside an sx animation string); recovered verbatim from git.
- ProjectFileManager: the fallback file-icon color uses
  var(--mui-palette-text-secondary).
- variables.css deleted; its import removed from AdminApp (last one).

tsc -b --noEmit + npm run build clean. applyStyles dark-scheme output and
the channel-alpha pattern were both verified live in Chrome via the Quill
editor, which uses the identical constructs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:45:36 +02:00
BOHA
d424ef8c15 refactor(css): scope Quill editor into styled() and delete offers.css
Phase 3 of the "fully MUI" cleanup.

- New src/admin/ui/RichText.tsx exports two theme-aware styled() wrappers:
  RichEditorRoot (the .admin-rich-editor Quill overrides) and RichTextView
  (the .admin-rich-text-view read-only output). All colors resolve via
  theme.vars; picker/tooltip shadows use theme.applyStyles for the
  light/dark difference; the active-button tint uses the primary channel.
- Dropped the ~170 dead font/size picker rules from the old stylesheet —
  the editor toolbar exposes neither picker. Kept the .ql-font-* content
  classes (shared by editor + view) so legacy stored HTML still renders.
- RichEditor uses <RichEditorRoot> (min-height still via --re-min-height);
  InvoiceDetail + OrderDetail use <RichTextView> for the sanitized output.
- offers.css deleted; its import removed from AdminApp.

tsc -b --noEmit and npm run build clean. Verified the editor in Chrome
(light + dark): toolbar, editor surface, text, icon strokes and min-height
all correct and theme-reactive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:37:11 +02:00
BOHA
2a228ea659 refactor(css): migrate base.css to MUI GlobalStyles and Leaflet to styled()
Phase 2 of the "fully MUI" cleanup — eliminates two more stylesheets.

- base.css -> src/admin/GlobalStyles.tsx: the reset, typography,
  scrollbar/::selection, theme cross-fade timing and all utility
  classes now live in a single theme-aware MUI <GlobalStyles>, rendered
  inside MuiProvider so every rule resolves against theme.vars (verified
  reactive in both schemes). base.css deleted.
- attendance.css -> a styled("div") LocationMap in AttendanceLocation;
  attendance.css deleted.
- App.tsx bootstrap loader is now fully self-contained (inline spinner
  + keyframes, theme-aware background read from the data-theme attr),
  since it renders before MuiProvider/GlobalStyles mount — it no longer
  depends on the removed .admin-spinner / var(--bg-primary).

tsc -b --noEmit and npm run build clean; verified in Chrome (light +
dark): body bg, text, fonts, .text-warning/.link-accent all correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:29:16 +02:00
BOHA
96e35df535 refactor(css): delete dead pre-MUI CSS and eliminate plan.css
Phase 1 of the "fully MUI" CSS cleanup.

- base.css 429->317: drop dead .admin-sidebar/header/card/modal,
  .admin-drag-handle/-kbd/-mono/-error-stack/-loading and the unused
  float/pulse keyframes. Keep reset, typography, scrollbar,
  ::view-transition cross-fade, the live .admin-spinner (App.tsx
  pre-React loader) and all utility classes.
- attendance.css 456->24: keep only the Leaflet .attendance-location-map
  rules; the old hand-rolled Attendance page is MUI now.
- offers.css 642->417: keep only the Quill (.admin-rich-editor/.ql-*)
  and .admin-rich-text-view rules; drop the dead offers table / filter /
  lang-badge / row-state rules.
- plan.css deleted: the grid already lives in PlanGrid's styled()
  wrapper; the last consumer (.plan-cat-color) is now an sx-styled
  Box input in PlanCategoriesModal.

tsc -b --noEmit and npm run build both clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:20:17 +02:00
BOHA
fa90bcc93b fix(plan): drop the redundant 'Zrušit' button in the category-management modal
The Správa kategorií modal footer had two buttons that both just close it (Zrušit + Zavřít). Added an opt-in hideCancel prop to the kit Modal (default false → every other modal unchanged) and set it on PlanCategoriesModal, leaving only 'Zavřít'. Verified: footer shows only Zavřít; close/Escape/backdrop intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:09:59 +02:00
BOHA
3117e80027 feat(plan): migrate the planner grid to MUI (theme-driven, off plan.css)
PlanGrid's bespoke plan.css surface is replaced by a styled(Box) wrapper that scopes all the grid + chip rules and pulls every color from the theme palette via theme.vars (auto light/dark — no more [data-theme] CSS-var blocks). Sticky header + date column, marker-tape cells, category color via the per-cell --cat-color var, weekend/today treatment, hover affordances, the '+' empty hint, and the one-shot mutation pulse are all preserved; the paper-grain/ruled-paper texture was dropped for a clean MUI surface. plan.css trimmed from ~1150 lines to just the category-manager modal rows (the grid is MUI now; the toolbar/banner/day-choice modal had already moved to MUI components). Verified on /plan-work in both light and dark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:05:27 +02:00
BOHA
2f7ca0f5a8 fix(theme): uniform contained-button text — white in light, black in dark
success/info/warning/error palettes had no explicit contrastText, so MUI auto-picked per color: in dark mode the brighter green/amber got black text while red/blue got white (inconsistent). Set contrastText explicitly per scheme (#fff in light, #1a1a1a in dark) so all contained color buttons (and filled chips) read uniformly — white on light, dark on dark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:54:06 +02:00
BOHA
2367e8a0ff feat(ui): mobile card layout for the remaining item tables
OrderConfirmationModal (the order→confirmation editable items dialog) and InvoiceDetail's read-only paid-invoice items table now render stacked label:value cards on phones (< sm) instead of a wide horizontally-scrolling table; desktop keeps the table. Same pattern as the Offer/Invoice editors. Gates: tsc=0, build=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:48:47 +02:00
BOHA
b168c68264 feat(invoice-detail): mobile card layout for the editable line-items grid
Same treatment as OfferDetail: on phones SortableInvoiceRow renders a stacked card (labeled Popis/Množství/Jednotka/Jedn.cena inputs + DPH select when apply_vat, Celkem footer, drag+remove header) and the wrapper renders a card stack instead of the table (DndContext/SortableContext kept). Verified at 430px on invoices/new: no inner table, no page horizontal scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:40:47 +02:00
BOHA
aeec0b703c fix(ui): wrap detail-page header action buttons on mobile
OfferDetail/OrderDetail/InvoiceDetail/ProjectDetail headers each have a custom action-button row that was flexShrink:0, so (like the alert/PageHeader cases) it stayed at content width and its buttons overflowed/clipped on phones. Dropped flexShrink:0 (and added flexWrap on ProjectDetail which lacked it) so the action row conforms to the available width and the buttons wrap onto multiple lines. Verified on OfferDetail at 430px: buttons span two rows, no container overflow, no page scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:36:24 +02:00
BOHA
2ff26241d7 feat(offer-detail): mobile card layout for the line-items editor
On phones (< sm) the editable items grid was a wide horizontally-scrolling table of inputs — unusable. SortableItemRow now renders each item as a stacked card (labeled Popis / Množství / Jednotka / Cena/ks inputs, V ceně checkbox + Celkem, drag handle + remove in the header), and the wrapper renders a card stack instead of TableContainer/Table on mobile (DndContext/SortableContext preserved so drag still works via TouchSensor). Desktop keeps the table. Verified: no inner table on mobile, no page horizontal scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:32:08 +02:00
BOHA
f0b5fb8a21 feat(ui): DataTable renders a card layout on phones instead of a scrolling table
On screens below the sm breakpoint, a wide multi-column table forced horizontal scrolling and truncated every cell (e.g. 'NA-202…'). DataTable now renders each row as a stacked label:value card on phones: every column becomes a 'HEADER: value' line with full, wrapping content; the actions column (key 'actions') drops to a right-aligned button row at the bottom; rowDanger/rowInactive/rowSx/onRowClick are all honored (action buttons stopPropagation so they don't trigger the row click). Tablets/desktop (>= sm) keep the normal table. Applies to every list table app-wide. Verified on Offers (actions) and Warehouse items (onRowClick): no truncation, no page horizontal scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:20:08 +02:00
BOHA
1e155f6ffb fix(ui): wrap action-button groups so they don't clip on mobile
Since kit buttons now keep their natural width (flexShrink:0 + nowrap), a row of them can't shrink — so button groups that were flexShrink:0 with no wrap got clipped on phones (the page has overflow-x:hidden). The draft-concept Alert actions (Offers + Invoices) and the shared PageHeader actions now use flexWrap:wrap and drop flexShrink:0, so the group conforms to the available width and its buttons wrap onto stacked lines. Verified at 430px: the Offers draft alert no longer overflows (buttons stack), page has no horizontal scroll.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 16:05:32 +02:00
BOHA
8cee828212 fix(attendance): stop the page + leave modal overflowing (grid minmax + minWidth:0)
Two overflow fixes on Attendance.tsx: (1) the leave-request modal's Od/Do date row used a bare gridTemplateColumns '1fr 1fr', whose minmax(auto,1fr) tracks couldn't shrink below the two DatePickers' min-content, overflowing the narrow modal (horizontal scroll, 'Do' clipped) — now minmax(0,1fr). (2) On phones the whole page overflowed: the main two-column grid used { xs: '1fr' }, and the left column's 5-col 'Dnešní dokončené směny' table forced a 477px min-content (the TableContainer's overflow-x:auto never engaged because the grid item had min-width:auto). Now the grid uses minmax(0,1fr) and both columns get minWidth:0, so the table scrolls inside its container and the page fits the viewport. Verified at 390px width via DOM measurement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:48:39 +02:00
BOHA
b23d15453d fix(audit-log): stop replaying the page entrance animation on every filter
AuditLog uses a raw useQuery with the filters in the queryKey and a page-level early return (if isPending && no rows → <LoadingState/>), which sits ABOVE <PageEnter>. On any filter change the new queryKey had no cached data, so isPending flipped true with empty rows, the early return unmounted the whole PageEnter subtree, and it remounted (replaying the staggered entrance) when data arrived. Add placeholderData: keepPreviousData so rows persist through the refetch (isPending stays false, subtree never unmounts), and dim the table via isFetching instead of isPending. Verified by DOM node-identity: the PageEnter root survives a filter (no remount). The paginated list pages were already immune (usePaginatedQuery bakes in keepPreviousData).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:34:06 +02:00
BOHA
c4e71475ce fix(ui): unify checkbox size — drop size="small" on raw checkboxes
The kit CheckboxField (used on most pages) renders MUI's default medium checkbox, but four raw <Checkbox> usages forced size="small": OfferDetail's 'V ceně' table cell and 'Účtovat DPH' form checkbox, and the Settings permission-matrix module + per-permission checkboxes. They looked undersized next to the rest of the page. Removing size="small" makes every checkbox the same standard size app-wide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:26:41 +02:00
BOHA
bf5d38df14 fix(ui): stop kit buttons from shrinking below their label (flexShrink: 0)
Follow-up to the nowrap fix: MUI's default flexShrink:1 was still squeezing buttons in flex rows below their content width, so with nowrap the label overflowed its padding and touched the button edges (measured: '+ Přidat položku' was 96px wide for 98px of content). Adding flexShrink:0 keeps each button at its natural width (now 131px, full padding restored). Verified via DOM measurement + visual on the offer detail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:10:31 +02:00
BOHA
9278d48a6d fix(ui): keep Button labels on one line (whiteSpace: nowrap)
In flex rows (e.g. the add-item/add-section buttons next to a 'Ze šablony…' select on OfferDetail/OrderConfirmationModal/InvoiceDetail), flexbox shrank a button below its text width and wrapped the label onto two lines, making the button tall. The kit Button now defaults to whiteSpace: nowrap (merged with any caller sx, still overridable), so labels stay single-line app-wide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:08:36 +02:00
BOHA
4062029939 fix(projects): red links in the projects table (number + order columns)
The project-number column was plain text and the Objednávka link was colored text.secondary (grey/black). Make the number a RouterLink to /projects/:id and recolor the order link to primary.main, so both match the red table-link convention used on Offers/Orders/Invoices.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:54:13 +02:00
BOHA
f017867d5f fix(ui): unify list filters across all pages (bare controls, standard sizes)
Every list-page FilterBar now follows one spec: bare controls (no Field label rows — which broke flex-end alignment), standardized widths (search 1 1 320px grows; small selects 0 0 160px; entity selects 0 0 220px; MonthField 0 0 180px; dates 0 0 150px), consistent 'Všechny/Všichni/Všechna <entity>' defaults, and a placeholder on every search. TripsAdmin's separate month-Select + year-Select collapse into one MonthField (yyyy-MM, split at the query boundary) — matching Attendance/TripsHistory. The 4 labeled attendance/trips pages move off the Field wrapper; Projects & ReceivedInvoices move their inline search into a FilterBar. Chrome-verified all ~18 filter pages in light + the existing theme.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:49:28 +02:00
BOHA
f1c533488a fix(offers): align customer filter with the search field
The customer Select was wrapped in <Field label> while the search was bare, so in the flex-end FilterBar the Field's label + mb:2 pushed the select ~16px above the search input. Drop the Field wrapper (matching the bare list-page filter pattern used on AuditLog/Warehouse/Orders) and make the default option self-describing ("Všichni zákazníci"). Both controls now sit on the same line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:34:19 +02:00
BOHA
f9bb655f6a fix(attendance): restore two-column grid on the Docházka landing page
The PageEnter sweep had replaced the left grid column's wrapper with a React Fragment, so its two cards (clock + completed shifts) leaked out as separate grid items and the layout broke (shifts table jumped to the right cell, stat box wrapped to row 2). Wrap the left column back in a <Box> so the 2-col grid has exactly two column children again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:34:19 +02:00
BOHA
c1bfabb033 fix(ui): Select dropdown caps menu height + shows empty-value option label
The kit Select now passes displayEmpty:true (so a selected value="" option like an "All" filter renders its label instead of a blank control) and MenuProps.PaperProps maxHeight:320 (so long option lists scroll inside the popover instead of listing every record). Both apply via slotProps.select and are overridable per call site. Verified app-wide: no call site regresses (audit of all 74 usages).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:34:19 +02:00
BOHA
ff3df907f4 feat(mui): make PageEnter entrance clearly perceptible (16px rise, 0.45s, 0.06s stagger)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:17:41 +02:00
BOHA
39fe84ce99 feat(mui): consistent staggered page entrance across all 43 route pages
Adopt the PageEnter wrapper as every page's outermost render element and remove the ad-hoc per-page entrance motion.div wrappers. Every page now enters the same way — all top-level sections rise+fade in, staggered — so nothing appears instantly and the motion is identical app-wide. Presentation-only: no data/logic/hooks/invalidate/permissions touched. Embedded sub-tabs (CompanySettings, ReceivedInvoices), Login (auth shell) and the dev UiKit are intentionally excluded. Gates: tsc -b --noEmit=0, build=0, vitest 152/152.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:13:06 +02:00
BOHA
b273614128 feat(mui): PageEnter kit primitive (consistent staggered page entrance) + adopt in WarehouseSuppliers
Each top-level child rises+fades in, staggered (0.4s, ease-out), so every page enters the same way and nothing appears instantly; honors prefers-reduced-motion. Replaces ad-hoc per-page motion.div entrance wrappers. One component → the look is tunable in one place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:53:41 +02:00
BOHA
1a0f57a247 fix(numbering): preview the next FREE offer number, not a stale one
previewOfferNumber returned applyPattern(previewNextSequence) directly; when the number_sequences 'offer' counter lags behind real quotations, that raw next number is already taken, so the offer editor showed an already-used number (e.g. NA-2026-0001 when 0001/0002 exist). Mirror generateOfferNumber's collision loop in the preview (without consuming the sequence). +2 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:22:44 +02:00
BOHA
c67dff4725 chore(mui): delete dead admin-*.css stylesheets (forms, buttons, components, tables, datepicker, filemanager, pagination, responsive, dashboard, settings, invoices, warehouse)
The page+component MUI migration has dropped all usages of these
stylesheets' classes. Verified by grep over src/**/*.{ts,tsx}: none of
their selectors are referenced as a className token anywhere.

Removed files + their imports from AdminApp.tsx:
forms.css, buttons.css, components.css, tables.css, datepicker.css,
filemanager.css, pagination.css, responsive.css, dashboard.css,
settings.css, invoices.css, warehouse.css

Kept: variables.css, base.css, plan.css (foundation), attendance.css
(live .attendance-location-map Leaflet container) and offers.css (live
.admin-rich-editor / .admin-rich-text-view + .ql-* Quill snow overrides).
The standalone <div className="admin-spinner"> in App.tsx remains styled
by base.css (buttons.css only held .admin-btn-scoped spinner modifiers).

Gates: tsc -b --noEmit (0), npm run build (ok), vitest (150 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:51:32 +02:00
BOHA
58da09cdc0 chore(mui): swap remaining admin-spinner loaders for kit LoadingState
Replace the three remaining hand-written admin-loading/admin-spinner
markup blocks with the kit <LoadingState /> component:
- AdminApp.tsx Suspense fallback
- AppShell.tsx loading branch
- PlanGrid.tsx no-data branch (plan-* classes untouched)

After this the only remaining admin-* classes in TSX are
admin-rich-editor (RichEditor/Quill) and admin-rich-text-view
(sanitized HTML in Order/Invoice detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:50:39 +02:00
BOHA
ccdf0b68ea refactor(mui): remove dead legacy FormModal and ConfirmModal
Both components have zero importers after the phase-25 migrations
(verified: no `./FormModal` or `./ConfirmModal` imports remain; build
passes). Their MUI kit replacements are ui/Modal and ui/ConfirmDialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:43:25 +02:00
BOHA
3b072ceefa refactor(mui): migrate ReservationPicker to kit Select
Replace the raw admin-form-select with the MUI kit Select (string-based
value, converted at the boundary). Same query (warehouseReservationList,
ACTIVE, perPage 100), same option labels, same onChange contract
(reservationId|null, remainingQty).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:43:20 +02:00
BOHA
68a8dae0dc refactor(mui): migrate ProjectFileManager to MUI kit
Replace legacy ConfirmModal with the kit ConfirmDialog (delete-confirm).
Re-skin onto Card + DataTable (file/folder rows), Box toolbar/breadcrumb,
EmptyState, LoadingState, kit Button/TextField/IconButton. The hidden
multipart file input is kept (per-file NAS upload). All queries,
mutations, invalidate keys (["projects", id, "files"]), NAS upload/
download/rename/create-folder/drag-drop logic, permissions, and Czech
text preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:43:16 +02:00
BOHA
e13f977c4d refactor(mui): migrate OrderConfirmationModal to kit Modal
Replace legacy FormModal with the MUI kit Modal. Map the two-step
flow (choose/edit) onto the kit footer: submitText + onSubmit switch
per step, submitDisabled gates "Vygenerovat PDF" when there are no
items. Re-skin lang/VAT toggles (kit Button variants), item editor
(kit TextField + IconButton inside a Box table). All generate/preview
logic, props, and Czech text preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:43:10 +02:00
BOHA
6f428b2297 feat(mui): migrate AlertContainer off admin-* CSS
Replaces framer-motion animation + custom SVG icons + admin-toast markup
with MUI Snackbar + MUI Alert (filled variant). useAlertState() consumption
(alerts array, removeAlert) is preserved verbatim — alert lifecycle
(timing, types, auto-dismiss) is owned by AlertContext and unchanged.
Alerts stack bottom-right with 72px vertical offset per slot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:37:32 +02:00
BOHA
67f98c1f66 feat(mui): migrate ErrorBoundary off admin-* CSS
Class component lifecycle (getDerivedStateFromError, componentDidCatch,
console.error, state shape) preserved verbatim. Only the fallback render
is re-skinned: Box+Typography for layout/text, kit Button for the reload
action. Czech strings unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:37:19 +02:00
BOHA
85d7ffaa14 feat(mui): migrate Forbidden off admin-* CSS
Re-skins the 403 fallback with MUI Box + kit EmptyState + kit Button
(component=Link). Czech text and routing preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:37:09 +02:00
BOHA
088fe39bab chore(mui): delete 8 dead legacy components replaced by the kit
Removed (zero importers, build-verified): FormField, Pagination, AdminDatePicker, SortIcon, warehouse Supplier/Location/Batch selects, WarehouseMovementTable. FormModal + ConfirmModal kept (still used by OrderConfirmationModal + ProjectFileManager — to be migrated before full removal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:34:58 +02:00
BOHA
9b43d4e827 feat(mui): migrate Attendance Location chrome onto MUI kit (Leaflet map kept)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:29:46 +02:00
BOHA
5630beee7c feat(mui): migrate Invoice Detail editor onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:27:07 +02:00
BOHA
90a27b893e feat(mui): migrate Offer Detail editor onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:21:31 +02:00
BOHA
2878041687 feat(mui): migrate OrderDetail onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:15:21 +02:00
BOHA
262f4c6ca5 feat(mui): migrate ProjectDetail onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:15:18 +02:00
BOHA
063ffd15ef feat(mui): migrate NotFound (404) onto MUI kit
Replace legacy admin-empty-state/admin-empty-icon/admin-btn markup and motion
wrapper with a centered MUI Box + Typography (h1 variant for 404 heading,
body1 for message) and a kit Button component={RouterLink}. All text and link
target ('/') preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:09:27 +02:00
BOHA
fa11d3e7e0 feat(mui): migrate Warehouse Inventory Form onto MUI kit
Replace legacy admin-* markup (motion.div header, admin-card, admin-table,
WarehouseMovementTable, LocationSelect, admin-form-input) with Box/Typography/
IconButton from @mui/material and Button/Card/TextField/Select/Field from the
kit. Item rows are inlined (grid) matching the receipt/issue form siblings.
All data logic (useQuery, useApiMutation, validate, handleSubmit, navigate,
hasPermission guard, Czech alerts) preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:09:21 +02:00
BOHA
d46827dfdf feat(mui): migrate WarehouseIssueForm onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:06:28 +02:00
BOHA
601db41a09 feat(mui): migrate WarehouseReceiptForm onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:04:57 +02:00
BOHA
8259c48f9e feat(mui): migrate WarehouseIssueDetail onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:01:20 +02:00
BOHA
1163407440 feat(mui): migrate WarehouseReceiptDetail onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:01:17 +02:00
BOHA
1cefcab697 feat(mui): migrate WarehouseInventoryDetail onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:56:39 +02:00
BOHA
c228d7a8ca feat(mui): migrate WarehouseItemDetail onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:56:36 +02:00
BOHA
c997a22a3c perf(mui): smooth theme cross-fade via View Transitions API (was per-element repaint jank)
The per-element color/box-shadow transition repainted the whole tree every frame (~5fps). Replaced with document.startViewTransition: the browser cross-fades one GPU-composited snapshot of the page — smooth regardless of element count. flushSync + useLayoutEffect ensure the data-theme flip is captured in the transition; instant fallback where unsupported / reduced-motion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:49:08 +02:00
BOHA
4d016cdab5 feat(mui): premium coordinated dark/light cross-fade (theme-transition class, toggle-only)
ThemeContext adds a .theme-transition class to <html> for ~300ms during a real toggle (skipped on initial mount); a scoped base.css rule applies a uniform 0.3s color/bg/border/fill/stroke/shadow transition to the whole tree only while that class is present — so the entire UI fades together on switch, while normal interactions keep their own transitions. Respects prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:45:20 +02:00
BOHA
177d3f1902 fix(mui): stop dark/light oscillation (single data-theme owner) + animated ThemeToggle + white avatars
Removed MuiColorSchemeSync: it called MUI setMode which ALSO wrote data-theme, fighting ThemeContext (the other writer) → theme flip-flop loop. ThemeContext is now the sole owner; MUI's cssVariables CSS (scoped by [data-theme]) applies from the attribute, no JS bridge needed. New animated ThemeToggle kit button (sun/moon crossfade+rotate) replaces the static AppShell toggle. DashAttendanceToday avatars now solid-fill + white initials (matches StatCard/activity badges).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:39:14 +02:00
BOHA
ef0bb9f27e fix(mui): Dashboard icon badges (activity feed + 2FA status) — solid tile + white glyph
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:29:52 +02:00
BOHA
126b567b7b fix(mui): StatCard icon — solid color tile + white glyph (was low-contrast/dark)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 07:26:06 +02:00
BOHA
e4038051e9 feat(mui): migrate Warehouse overview + replace residual admin-loading loaders with LoadingState
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:45:04 +02:00
BOHA
c7e42a923a feat(mui): migrate Settings + CompanySettings onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:38:08 +02:00
BOHA
ebda3ecf23 feat(mui): migrate PlanWork modals + toolbar onto MUI kit (planner grid kept bespoke)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:29:48 +02:00
BOHA
cc4b14f862 feat(mui): migrate Login (auth shell + 2FA) onto MUI kit; drop login.css
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:24:11 +02:00
BOHA
5459d8c325 feat(mui): migrate Dashboard (Přehled) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:19:49 +02:00
BOHA
5ca03bd662 feat(mui): migrate Invoices (Faktury — Vydané + Přijaté) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:09:43 +02:00
BOHA
734e4266e6 feat(mui): migrate Attendance (Docházka záznam) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:02:26 +02:00
BOHA
c690bd9a24 feat(mui): migrate Attendance Admin (Správa docházky) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:57:26 +02:00
BOHA
3eb364fba5 feat(mui): migrate Attendance Create (Vytvořit záznam) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:50:01 +02:00
BOHA
1f85a84e06 feat(mui): migrate Attendance Balances (Salda) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:47:46 +02:00
BOHA
abbff0bfbf feat(mui): migrate Attendance History (Moje historie) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:43:26 +02:00
BOHA
19eda2ffa3 feat(mui): add ProgressBar + TimeField kit primitives
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:40:11 +02:00
BOHA
9e5038c553 feat(mui): migrate Offers (Nabídky) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:37:09 +02:00
BOHA
9ab2bd7ef2 feat(mui): add DataTable rowSx per-row style escape hatch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:33:25 +02:00
BOHA
3d1bd49ad6 feat(mui): migrate Warehouse Reservations (Rezervace) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:28:35 +02:00
BOHA
f51d5fcba4 feat(mui): rewrite warehouse ItemPicker on MUI Autocomplete (same props)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:21:17 +02:00
BOHA
5d919c3c90 feat(mui): migrate Offers Templates (Šablony) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:15:43 +02:00
BOHA
4b94efbb43 feat(mui): migrate Offers Customers (Zákazníci) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:11:20 +02:00
BOHA
c8c40d80d6 feat(mui): migrate Orders (Objednávky) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:05:11 +02:00
BOHA
acc5434aca feat(mui): add FileUpload kit primitive (hidden input + selected-file chips)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 00:02:22 +02:00
BOHA
ff527ca577 feat(mui): migrate Warehouse Reports (Reporty) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:59:18 +02:00
BOHA
b54fa84ac7 feat(mui): migrate Leave Approval (Schvalování) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:56:00 +02:00
BOHA
ca386c9d98 feat(mui): migrate Trips Admin (Správa kniha jízd) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:51:52 +02:00
BOHA
a05f0f1665 feat(mui): migrate Trips History (Moje historie) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:47:59 +02:00
BOHA
1d0793d2bb feat(mui): migrate Trips (Kniha jízd) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:47:56 +02:00
BOHA
74c2f9aa30 feat(mui): add MonthField kit primitive (year+month picker, yyyy-MM string I/O)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:43:55 +02:00
BOHA
1074adee0d feat(mui): migrate Leave Requests (Žádosti) onto MUI kit
Replaces admin-* CSS, ConfirmModal, and legacy markup with PageHeader,
Card, DataTable, ConfirmDialog, StatusChip, EmptyState, LoadingState.
Dual StatusChip badges per row: leave-type (info/error/default) and
status (warning/success/error/default). Note cell preserved with Box
title-tooltip and ellipsis truncation. cancelMutation and invalidate:
["leave-requests", "leave", "attendance"] preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:40:45 +02:00
BOHA
a5103ee738 feat(mui): migrate Audit Log onto MUI kit
Replaces admin-* CSS, FormModal, FormField, AdminDatePicker, and legacy
Pagination with PageHeader, FilterBar, Card, DataTable, Modal, Select,
DateField, TextField, StatusChip, Pagination, EmptyState, LoadingState.
All filter params, useQuery key, cleanupMutation, DELETE_ALL_AUDIT token
logic, and invalidate: ["audit-log"] preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:40:39 +02:00
BOHA
4fb52e9626 feat(mui): migrate Warehouse Issues (Výdeje) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:35:22 +02:00
BOHA
06c6e242e7 feat(mui): migrate Warehouse Receipts (Příjmy) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:33:20 +02:00
BOHA
91686f5999 feat(mui): migrate Warehouse Inventory (Inventura) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:33:20 +02:00
BOHA
03015bbe9d feat(mui): migrate Warehouse Items (Položky) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:33:20 +02:00
BOHA
af07703e7f feat(mui): migrate Warehouse Locations (Lokace) onto MUI kit
Replace legacy admin-* CSS, hand-rolled AnimatePresence modal,
ConfirmModal, FormField, and useModalLock with PageHeader, Card,
DataTable (rowInactive), Modal, ConfirmDialog, Field, TextField,
StatusChip (clickable toggle), EmptyState, LoadingState, and MUI
IconButton. All four mutations + ["warehouse"] invalidation and
is_active toggle preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:28:34 +02:00
BOHA
0dbe78bfe6 feat(mui): migrate Warehouse Categories (Kategorie) onto MUI kit
Replace legacy admin-* CSS, FormModal, ConfirmModal, FormField, and
framer-motion wrappers with PageHeader, Card, DataTable, Modal,
ConfirmDialog, Field, TextField, EmptyState, LoadingState, and MUI
IconButton. All data logic preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:28:30 +02:00
BOHA
b22cd6b4da feat(mui): migrate Warehouse Suppliers (Dodavatelé) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:22:19 +02:00
BOHA
ba18cb07f0 feat(mui): kit primitives — PageHeader, EmptyState, LoadingState, FilterBar, StatCard + DataTable onRowClick/rowDanger
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:18:39 +02:00
BOHA
26f12b01a0 feat(mui): fixed-width DataTable columns + restore Projects next-number hint
DataTable: optional per-column width; when any column sets one, the table uses table-layout:fixed with a colgroup so columns keep a stable size instead of reflowing as rows are filtered (overflow clipped with ellipsis). Tables without widths keep auto layout. Projects: assign column widths; restore the original '(přiděleno automaticky)' suffix on the next-number hint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:02:40 +02:00
BOHA
1b372b93ea fix(numbering): preview the next FREE shared number, not a stale one
previewSharedNumber returned applyPattern(previewNextSequence) directly. When the number_sequences counter lags behind real data (e.g. orders/projects imported or seeded without bumping it), that raw next sequence is already taken, so the create-project modal showed an already-used number (e.g. OBJ-2026-001) even though generateSharedNumber would correctly assign the next free one via its collision-check loop. Mirror that loop in the preview (without consuming the sequence) so the hint matches what will actually be assigned. Adds two numbering tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:02:40 +02:00
BOHA
56bad52de5 docs(claude): add MUI migration section + memory-tracking instruction
Documents the in-progress CSS->MUI migration (spec/plans, page-migration pattern, kit, gates, dialog gotchas) and the convention to keep the agent-memory progress tracker (project_mui_migration.md) updated as phases merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:38:17 +02:00
BOHA
a80905755d fix(mui): Projects review fixes — real order link, revert title/label, drop added status field
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:33:05 +02:00
BOHA
ea6c943a51 feat(mui): migrate Projects (Projekty) page onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:27:20 +02:00
BOHA
c0e1b718e6 feat(mui): ConfirmDialog optional children (extra content below message)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:24:12 +02:00
BOHA
fbcd5a8939 docs(plan): MUI migration Phase 5 (Projects page)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:23:36 +02:00
BOHA
f5ec9fa0f2 feat(mui): ConfirmDialog processing text 'Zpracovávám…' + re-wire Users delete loading
After clicking the confirm button, it shows 'Zpracovávám…' (disabled) while the action is in flight — matching how the form modal shows 'Ukládám…'. The earlier freeze keeps it through the close fade (no flash back to the confirm label).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:21:30 +02:00
BOHA
fa8f0efc65 fix(mui): drop loading state on Users delete dialog (normal close, matches Vozidla)
Per request: the delete ConfirmDialog no longer shows the in-flight 'Pracuji…'/disabled state — button stays 'Smazat' and the dialog closes on success, consistent with the Vozidla delete. The kit ConfirmDialog still supports loading for callers that want it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:15:07 +02:00
BOHA
2efd1c6df4 fix(mui): freeze loading state + button label during dialog close (Modal + ConfirmDialog)
On save/delete success the parent flips loading false a beat before the dialog finishes its fade-out, so the button flashed back to 'Uložit změny'/'Smazat'. Both dialogs now freeze the loading flag (and the label props) while open, so the closing dialog keeps showing 'Ukládám…'/'Pracuji…'. Same derive-state-from-props pattern as the title freeze.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:11:58 +02:00
BOHA
305c3fd97b fix(mui): lock <html> scroll for dialogs + freeze Modal title/labels during close
Scroll: MUI only locks <body>, but base.css 'html{overflow-x:hidden}' makes <html> the vertical scroller (overflow-x:hidden forces overflow-y:auto), so the page scrolled behind modals. Added a ref-counted useDialogScrollLock that locks <html> (with scrollbar-width padding) + disableScrollLock on the kit dialogs. Labels: Modal now freezes title/subtitle/submitText while closing (derive-state-from-props), so editing then saving no longer briefly flashes the 'create' title/button during the fade-out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:59:38 +02:00
BOHA
6c186acc94 fix(mui): restore distinct create/edit submit labels on Users modal
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:48:27 +02:00
BOHA
c055bf8267 fix(mui): restore delete-dialog loading state on Users (parity with original ConfirmModal)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:45:54 +02:00
BOHA
094c190ae1 feat(mui): migrate Users (Uživatelé) page onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:42:08 +02:00
BOHA
55a2de3a04 docs(plan): MUI migration Phase 4 (Users page)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:39:37 +02:00
BOHA
1398d4b4dc fix(mui): review fixes — Select string contract, DataTable aria-sort, sx/page-clamp/dev-warn
Select value/option restricted to string (MUI sets the literal child value; string|number + onChange:string was a type lie). DataTable sortDirection uses ?? 'asc' so aria-sort is never undefined on the active column. StatusChip sx-merge drops undefined; Pagination clamps page into range; DataTable dev-warns on sortKey without onSort; Alert onClose JSDoc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:34:15 +02:00
BOHA
9aa90ff5f4 feat(mui): showcase Phase 3 kit components in /ui-kit (Select, Tabs, StatusChip, Pagination, sortable DataTable, DateField, Checkbox, Alert)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:26:25 +02:00
BOHA
225cf56433 fix(mui): wrap app in LocalizationProvider (date-fns/cs) so DateField works at runtime
DateField (committed in 58a3bca) renders MUI X DatePicker, which requires a LocalizationProvider ancestor — without it, it throws at render time (tsc/build don't catch it). Added to MuiProvider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:24:36 +02:00
BOHA
58a3bcad70 feat(mui): kit — DateField over MUI X (date-fns v4, cs locale) + LocalizationProvider
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:23:55 +02:00
BOHA
ca55529ff6 feat(mui): kit — sortable DataTable headers (TableSortLabel), additive
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:22:25 +02:00
BOHA
b76731d790 feat(mui): kit — Tabs/TabPanel + Pagination
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:21:10 +02:00
BOHA
05f25931c9 feat(mui): kit — Select, StatusChip, CheckboxField, Alert
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:20:30 +02:00
BOHA
5855c94b50 docs(plan): MUI migration Phase 3 (kit expansion)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:20:01 +02:00
BOHA
81adebd63a refactor(mui): ConfirmDialog freezes content via derive-state-from-props (no ref-in-render)
Replaces the useRef-during-render snapshot with React's blessed 'adjust state when a prop changes' pattern (useState + guarded setState during render). Render-pure, same self-contained behavior. Avoids writing to a ref during render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:09:01 +02:00
BOHA
16db585022 fix(mui): ConfirmDialog retains text during close transition (no empty fade-out)
The message was bound to the caller's selected row, which gets nulled on close at the same instant the Dialog begins its fade-out — so it faded out with empty text. Now the dialog caches the last shown title/message and renders that through the exit transition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:02:39 +02:00
BOHA
645d0127ec style(mui): DataTable bold-cell option; restore SPZ/Aktualni-km emphasis + mono Menlo fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:50:38 +02:00
BOHA
c550763f2a feat(mui): migrate Vozidla (Vehicles) page onto MUI kit
DataTable (dense, mono numerics, inactive dimming, Chip status toggle), Modal add/edit form (Field + TextField + SwitchField), ConfirmDialog delete. All data logic, mutations (invalidate vehicles+trips), validation (SPZ/Nazev), and SPZ-uppercasing preserved verbatim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:46:45 +02:00
BOHA
bd658cd00c feat(mui): data/form kit — Modal, ConfirmDialog, DataTable, Field/SwitchField
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:44:16 +02:00
BOHA
efaf49e9df docs(plan): MUI migration Phase 2 (data/form kit + Vozidla pilot)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:43:04 +02:00
BOHA
cc5ffb27b9 feat(mui): adopt AppShell, remove AdminLayout/Sidebar/layout.css
Wires the MUI AppShell into the admin route and deletes the legacy chrome (AdminLayout, Sidebar, layout.css). Spinner CSS (.admin-spinner/.admin-loading) lives in base.css and is unaffected. tsc -b clean, build OK, 148 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:35:49 +02:00
BOHA
9dbb5dbaab feat(mui): AppShell — responsive Drawer + topbar, guards + logout animation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:11:52 +02:00
BOHA
ab7e1eda41 refactor(mui): use ListItemText slotProps.primary (primaryTypographyProps is deprecated in MUI v7)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:10:02 +02:00
BOHA
7a21c85cce feat(mui): SidebarNav — MUI List nav with white-pill active state
Add src/admin/ui/SidebarNav.tsx: drawer content component using MUI
List/ListItemButton with NavLink polymorphism. Active item uses the
`selected` prop for the white "pill" styling (not NavLink active class).
Renders logo header, permission-filtered nav sections, and user/logout
footer. Not yet wired — next task mounts it in a Drawer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:06:31 +02:00
BOHA
a353178c2d refactor(mui): extract sidebar nav data + active/permission helpers to navData
Move MenuItem/MenuSection interfaces and the full menuSections array (6 sections,
~30 items with inline SVG icons) verbatim from Sidebar.tsx into src/admin/ui/navData.tsx.
Add exported isItemActive/hasItemPermission pure helpers (folding in the matchAlso
branch that was previously inline in Sidebar's NavLink className callback).
Sidebar updated to import and use these; rendering and active-state behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:02:13 +02:00
BOHA
eeac0c6b8b docs(plan): MUI migration Phase 1 (AppShell) implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:57:53 +02:00
BOHA
e511e203b9 feat(mui): 200-250ms interaction motion (button press/hover lift, input focus ring, card/chip transitions)
Buttons: 150ms press scale(0.97) + ripple; contained-primary hover lift + glow (gradient can't transition so animate transform/shadow/filter). Outlined inputs: soft brand focus ring fades in 200ms + outline color transition. Cards/chips: shadow transitions. Honors prefers-reduced-motion. Easing matches legacy cubic-bezier(0.4,0,0.2,1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:50:40 +02:00
BOHA
9791166ed6 style(tokens): refresh light canvas to #f4f3f1 (two-layer refresh, token level)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:24:51 +02:00
BOHA
a853d12d9c docs(plan): correct typecheck gate to 'tsc -b --noEmit' (tsconfig.json is a vacuous solution file)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:24:11 +02:00
BOHA
6bc4a22338 fix(mui): type theme.colorSchemes access in theme.test.ts (tsc -b clean)
Define a minimal local interface for colorSchemes light/dark palettes and
assert the imported theme once via `as unknown as ThemeWithColorSchemes`,
replacing the 6 raw accesses that TypeScript rejected under tsc -b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:23:23 +02:00
BOHA
859ae1a458 feat(mui): dev-only /ui-kit showcase proving the theme + data-theme bridge
Adds src/admin/pages/UiKit.tsx and registers a DEV-only <Route path="ui-kit">
sibling to <Route path="login">, outside the AdminLayout shell. The lazy import
uses the ternary `import.meta.env.DEV ? lazy(...) : null` pattern so Vite/Rolldown
evaluates the condition at build time and the UiKit chunk is entirely absent from
production bundles (confirmed: no UiKit-*.js in dist-client/assets after build:client).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:16:42 +02:00
BOHA
090cfd7400 feat(mui): first ui-kit wrappers (Button, Card, TextField) + barrel
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:13:14 +02:00
BOHA
bb7e53f47a feat(mui): mount MUI provider + sync color scheme to data-theme
Wraps AdminApp in MuiProvider (ThemeProvider v7, defaultMode=dark) and
adds MuiColorSchemeSync to mirror ThemeContext's data-theme attribute
into MUI's JS color-scheme state so useColorScheme().mode stays in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:08:35 +02:00
BOHA
d2e368a05e feat(mui): soft-SaaS component defaults (pill buttons, 16px cards, pill chips)
Adds MuiButton, MuiCard, and MuiChip styleOverrides to the theme: pill-shaped
buttons (borderRadius 999) with a red-gradient containedPrimary, 16px soft-
shadow cards, and pill chips. Driven by a new failing test first (TDD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:05:01 +02:00
BOHA
b5df88b62d feat(mui): theme.ts with light/dark palettes, brand fonts, data-theme selector
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:00:39 +02:00
BOHA
116797ca17 build(mui): add MUI v7 + Emotion + X pickers; align @types/react to 18
Install @mui/material@^7, @emotion/react@^11, @emotion/styled@^11,
@mui/x-date-pickers@^8 as production dependencies. Downgrade
@types/react and @types/react-dom from ^19 to ^18.3 to match the
React 18.3.1 runtime. tsc --noEmit and vite build:client both pass
cleanly with no new errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:57:59 +02:00
BOHA
ed4f1efd03 docs(plan): MUI migration Phase 0 (theming foundation) implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:54:32 +02:00
BOHA
6b76d4336e docs(spec): lock pilot = Vozidla; confirm two-layer refresh
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:48:26 +02:00
BOHA
a8c0e413f4 docs(spec): CSS->MUI migration design (soft-SaaS + dense tables, incremental)
Approved design for migrating the admin frontend off ~7,100 lines of hand-written CSS onto MUI, themed to the existing brand, dark/light preserved, modern 'soft-SaaS shell + dense tables' look, rolled out incrementally (foundation-first, always shippable). Verified via 4-way adversarial review (editorial, MUI-docs accuracy, codebase-fit, completeness) with fixes applied. Also gitignores .superpowers/ brainstorm mockups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:44:17 +02:00
BOHA
596d21712a chore(release): v1.9.8 — compact mobile filter bars + AdminDatePicker everywhere
Combines the undeployed v1.9.7 (compact 2-up filter bars) with the date-field
unification (all date fields via AdminDatePicker + mode-aware placeholder).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:58:11 +02:00
BOHA
b504d9d0b2 fix(dates): use AdminDatePicker everywhere + mode-aware default placeholder
- The project create-modal start/end dates used raw <input type="date">;
  converted to <AdminDatePicker> so every date field in the app now goes
  through the shared component (react-datepicker on desktop, native on touch).
- AdminDatePicker now defaults its placeholder by mode (date 'dd.mm.rrrr',
  month 'mm.rrrr', time 'hh:mm') so empty date fields always show a format
  hint; explicit placeholders (e.g. 'Od'/'Do' range filters) still override.
- Dropped WarehouseReceiptForm's ad-hoc 'dd.mm.yyyy' so it inherits the
  unified Czech default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:57:52 +02:00
BOHA
e082a6ef06 chore(release): v1.9.7 — compact mobile filter bars
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:11:59 +02:00
BOHA
1662453d9c fix(mobile): compact 2-up filter bars instead of full-width column
.admin-search-bar on phones (<=768px) was flex-direction:column with each
control forced full-width (min-width:100% at <=480), so multi-filter bars
(warehouse receipts/issues, sales lists) became a tall stack of huge controls.
Now: the search box spans the row; filter selects/date pickers sit two-per-row;
the reset button spans the row. 44px/16px touch sizing unchanged; applies
consistently to every page using .admin-search-bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:11:46 +02:00
BOHA
9dd2e07f19 chore(release): v1.9.6 — mobile responsiveness (Phase 1+2 + tabs/comboboxes)
Supersedes the undeployed v1.9.5: adds the mobile tab-bar scroll fix and
combobox touch-target fixes on top of the Phase 1+2 responsive rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:54:51 +02:00
BOHA
18064a972c fix(mobile): tab bars scroll on phones + combobox 44px touch targets
- .admin-tabs: horizontal-scroll + 44px tabs at <=640px so 4-tab bars
  (WarehouseReports, Invoices/Offers status filters, OffersTemplates) no longer
  overflow off-screen.
- Customer searchable dropdown + warehouse ItemPicker: option rows >=44px,
  clear button enlarged, list clamped to viewport width at <=640px.
Native selects/toggles were already mobile-safe (44px at <=768px). Mobile-only; desktop unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:54:36 +02:00
BOHA
996835dae5 chore(release): v1.9.5 — mobile responsiveness (Phase 1+2)
Global small-phone rules (form-row stacking, KPI collapse, table-action touch
targets, datepicker/filemanager mobile) + area fixes (Quill picker dropdowns,
invoice month-nav, attendance map/clock-card). Mobile-only; desktop unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:45:47 +02:00
BOHA
c73b980ce7 feat(mobile): responsive Phase 1+2 — global small-phone rules + area fixes
All changes are mobile-only (max-width media queries); desktop unchanged.

Global (systemic):
- forms.css: all .admin-form-row* grids stack to 1 column at <=480px (fixes
  detail-page info grids, settings forms, modal forms staying 2-up on phones).
- components.css: .admin-kpi-4/-3 stat grids collapse to 1 column at <=480px.
- tables.css: row-action icons get 44px touch targets at <=768px.
- datepicker.css: constrain the date-picker popper to the viewport at <=480px.
- filemanager.css: toolbar wraps, new-folder input full-width, breadcrumb scrolls.

Area specifics:
- offers.css: Quill picker dropdowns kept on-screen (max-width) at <=480px.
- invoices.css: month-nav wraps + 44px touch targets at <=640px.
- attendance.css: Leaflet map height scales with viewport; clock-card padding
  tightened at <=480px.

Audit confirmed all data tables (warehouse incl.) are already wrapped in the
scroll container, so no table overflow/markup changes were needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:44:53 +02:00
BOHA
dafa6d6aab chore(release): v1.9.4 — modal UX unification + project responsible-person filter
- Unify modals: ConfirmModal now locks scroll; 8 ad-hoc modals migrated to the
  shared FormModal (consistent open/close animation, blurred backdrop, ESC,
  click-outside, scroll-lock, mobile, reduced-motion). FormModal gains
  submitDisabled + subtitle props.
- Projects: the responsible-person picker (create modal + edit page) lists only
  active users with projects.view (edit page keeps the current assignee).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:54:32 +02:00
BOHA
80803cc868 refactor(modals): migrate 8 ad-hoc modals to the shared FormModal
BulkAttendanceModal, ShiftFormModal, OrderConfirmationModal, DashQuickActions
trip modal, the Attendance leave modal, and DashProfile's 3 modals (edit
profile, 2FA setup, 2FA disable) now use the shared FormModal instead of
hand-rolled overlays, inheriting the canonical open/close animation, blurred
backdrop, ESC-to-close, click-outside, scroll-lock, staggered reveal, mobile
full-screen and reduced-motion. Footers stay pinned via the real FormModal
footer + submitDisabled; multi-step modals (2FA setup, order confirmation)
drive title/footer/close-guards per step. Behaviour preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:53:28 +02:00
BOHA
55fa4045ad feat(modals): FormModal gains submitDisabled + subtitle props
submitDisabled greys out the primary (so callers can gate it on validity
instead of a clickable-but-inert button); subtitle renders a muted line under
the title in the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:53:28 +02:00
BOHA
7f156cc721 fix(modals): ConfirmModal locks body scroll + respects loading on backdrop
ConfirmModal never called useModalLock, so the page scrolled behind every
confirmation dialog (8+ usages). Add the ref-counted scroll lock and guard
backdrop-click against the loading state, matching FormModal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:34:48 +02:00
BOHA
e0d2fccf50 fix(projects): edit-page responsible picker shows only active users (keeps current assignee)
Mirror the create-modal fix on ProjectDetail: filter the 'Zodpovědná osoba'
select to active users (already scoped to projects.view), but keep the
project's currently-assigned user selectable even if inactive, labelled
'(neaktivní)', so the saved value still shows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:24:41 +02:00
BOHA
a1c251650b fix(projects): responsible-person picker shows only active users with projects.view
The create-project modal's 'Zodpovědná osoba' select listed every user
(including inactive ones). Filter the query server-side by projects.view and
hide inactive users client-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:19:15 +02:00
BOHA
103908cfb8 chore(release): v1.9.3 — manual project & order creation (price + PO upload)
Manual standalone project creation (shared numbering pool, with tracking:
collision-safe, release-on-delete, audit, Samostatný/Z objednávky badge) and
manual order creation without an offer (header form, optional linked project,
price line item, PO PDF upload mirroring the from-offer flow). Re-adds the
projects.create permission via migration + seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:12:01 +02:00
BOHA
449a1a2243 test: serialise test files (fileParallelism:false) to avoid shared-DB deadlock
The numbering and manual-create suites both lock the number_sequences
'shared' row (SELECT ... FOR UPDATE + deleteMany); running test files in
parallel raced them into a MySQL 1213 deadlock. Production never does that
concurrent delete, so this is purely a parallel-test isolation fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:01:28 +02:00
BOHA
337feb8b3a feat(orders-ui): manual order price line item + PO upload (hybrid submit)
Adds price/quantity fields (→ one line item) and a PDF attachment picker
to the manual-order create modal. Submit now uses apiFetch directly:
FormData (multipart) when a file is attached, JSON otherwise. Navigates
to the new order on success. Removes the old useApiMutation-based
createMutation; deleteMutation is untouched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 13:58:21 +02:00
BOHA
bc62d32efc feat(orders): manual order PO attachment + price items (multipart)
Extend createOrder service to accept optional attachmentBuffer/attachmentName
parameters and persist them. Restructure the POST / multipart branch in the
orders route to dispatch on quotationId presence: from-quotation path is
unchanged, new manual-multipart path validates with CreateOrderSchema and
forwards the attachment. Add two TDD tests covering price line items and
PO file attachment storage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:52:29 +02:00
BOHA
30dd5a3f3b feat(orders-ui): add manual order create modal (header only, optional project)
Mirrors the Projects.tsx pattern: useQuery-driven customers + next-number
fetches (enabled only when modal is open), OrderCreatePayload type normalises
customer_id "" → null so the server's CreateOrderSchema never receives 0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:17:53 +02:00
BOHA
5140d23d73 feat(projects-ui): add project create modal, type badge, empty-state copy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:13:43 +02:00
BOHA
a373102f3a feat(queries): next-number options for project/order create modals 2026-06-06 11:11:18 +02:00
BOHA
49ec3482ae feat(perms): re-add projects.create permission (migration + seed)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:10:10 +02:00
BOHA
f6cdfb9801 feat(orders): manual order optionally creates a linked project
Add `create_project` flag (default true) to `CreateOrderSchema` and
`CreateOrderData`. When true and `quotation_id` is absent, `createOrder`
creates a `projects` row sharing the order's shared-pool number and
`order_id` link — mirroring the from-quotation flow. The route audits
the new project creation when it occurs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:06:25 +02:00
BOHA
bd9dc8d103 feat(projects): POST /projects + GET /projects/next-number 2026-06-06 11:04:01 +02:00
BOHA
0c5d0ee2f7 feat(projects): add createProject service (manual, shared numbering)
Introduces CreateProjectSchema (Zod 4, NaN-guarded coercions), the
createProject() service function (atomic shared-number generation inside
a $transaction), getNextProjectNumber() preview helper, and two Vitest
tests that verify standalone project creation and distinct sequential
numbers. Routes are deferred to a subsequent task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 11:01:10 +02:00
BOHA
fdf180809d fix(plan): make Plán prací usable on mobile + dev port → 3050
Mobile work-plan (Plán prací) responsive fixes — desktop unchanged:
- Toolbar stacks into clean full-width rows on ≤640px (nav group,
  centered range label, full-width segmented view toggle, full-width
  category button); drops the fixed 230px label min-width and the
  margin-left:auto that made the toolbar ragged on phones.
- Grid scroll container: 100dvh-based max-height, momentum scroll, and
  overscroll containment so a fling does not bounce the whole page.
- ≤480px tightens columns (date col 72px via the colgroup <col>, person
  cols 132px) so ~2 people are comfortably visible at 360–390px.
- Touch (@media hover:none): faint persistent + on empty editable
  cells, since the hover-reveal add hint never fires on a phone.

Dev: `npm run dev` (backend) now listens on 3050 instead of 3000 to
avoid colliding with other local apps; CLAUDE.md dev-port note updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 10:16:00 +02:00
BOHA
52f3da73ad chore(release): v1.9.1 — overnight consistency audit fixes + v1.9.0 modal/test patch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:36:00 +02:00
BOHA
83aba93a98 docs(audit): finalize report — applied changes + review roadmap
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:41:57 +02:00
BOHA
744baec2ec feat(security): audit-log session termination (single + all-others)
The two session-revocation endpoints wrote no audit entry. Added a 'session' EntityType (+ Czech label 'Relace') and logAudit on both deletes, so revoking sessions is recorded in the forensic trail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:40:41 +02:00
BOHA
1f05ef6a55 fix(dates): use local 'today' for date-input defaults instead of the UTC date
11 form defaults across 6 files used new Date().toISOString().split('T')[0] (the UTC date), which lands on yesterday during the post-midnight Prague window (UTC+1/+2). Added todayLocalStr() (local YYYY-MM-DD) in formatters and replaced them. Future-date expressions (e.g. invoice due_date +14d) left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:38:58 +02:00
BOHA
94a9fb8b5a docs(claude): codify enforced conventions; correct stale sanitization/error-catch notes
Adds a 'Conventions (enforced)' section (response helpers, parseId, parseBody, roleName-not-role admin check, broad invalidation, single-source label maps, the two date regimes + no UTC-today, Zod 4 idioms, Czech messages) and corrects known-issue #4 (NAS catches now logged) and #5 (HTML sanitization is in place, not a gap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:35:25 +02:00
BOHA
9c582e1a43 fix(nas): log swallowed filesystem errors instead of silently dropping them
nas-file-manager (21) and nas-financials-manager (9) had empty catch blocks that swallowed mkdir/write/rm/rename/stat/readdir failures with zero logging, making NAS issues undebuggable (violates the 'never silently swallow errors' rule). Added a contextual console.error to each real-failure catch (matching the existing service console.* pattern); control flow and return values unchanged. Left 3 ENOENT-as-expected catches (move/createFolder existence checks, symlink-traversal guard) as deliberate no-logs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:33:40 +02:00
BOHA
9ae49c0d6a refactor: route/schema consistency (parseId, broad invalidation, Czech messages, Zod 4 strictObject)
- OfferDetail: invalidate broad ["offers"] not ["offers","list"] (CLAUDE.md convention)
- trips.ts/sessions.ts: use parseId helper (validated 400) instead of raw parseInt for route ids
- audit-log.ts: z.object().strict() -> z.strictObject() (Zod 4 idiom)
- attendance.ts, orders/offers schemas: English user-facing strings -> Czech
- project-files.ts: unify 'not found' phrasing to the codebase-dominant 'nenalezen' form

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:29:04 +02:00
BOHA
522afefc80 refactor(frontend): fix undefined --danger-color, dedup formatDate & require2FAOptions, drop double plan.css import
- base.css: var(--danger-color) (undefined, no fallback -> inherited color) -> var(--danger)
- attendanceHelpers: re-export formatDate from formatters instead of a byte-identical copy
- dashboard.ts: remove dead duplicate require2FAOptions (canonical copy is in settings.ts)
- settings.ts: remove unused @deprecated systemSettingsOptions alias
- PlanWork: drop redundant per-page plan.css import (loaded globally in AdminApp)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:22:51 +02:00
BOHA
c5f986adc1 fix(audit): correct + unify entity_type label map to match server values
Audit Log + dashboard feed used legacy keys (invoices_invoice, offers_quotation, trips, vehicles) that never matched the server's entity_type (invoice, quotation, trip, vehicle, ...) and omitted all warehouse_* entities, so many audit rows showed raw English. Single source of truth in lib/entityTypeLabels.ts, keyed to the actual EntityType values, with Czech labels for every type the server writes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:19:10 +02:00
BOHA
379725a096 fix(plan): correct admin role check (roleName not role) + drop stale category casts
isAdminLike read authData.role, which doesn't exist on AuthData (it's roleName; role is on JwtPayload), so admins were always self-scoped on /entries and /overrides. Typed the param (no more any). Also removed 4 'category: input.category as any' casts now that category is a String column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:16:23 +02:00
BOHA
05596642bc docs(audit): full 84-finding catalog from discovery sweep
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:14:47 +02:00
BOHA
58bf0348d7 docs(audit): start overnight codebase consistency audit report (C1, C2)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:00:04 +02:00
BOHA
55a2c50f79 test(auth): update verifyAccessToken tests to assert no-log for expected token errors
The JWT log-noise fix (88c9b7c) stopped logging expected invalid/expired tokens, but auth.service.test.ts still asserted it logged, leaving 2 failing tests on master/v1.9.0 (a wrong auth test file was run when that change landed). Tests now assert null + no console.error for JsonWebTokenError cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:59:22 +02:00
BOHA
86e4cbf345 fix(modals): commit FormModal footerLeft/hideCloseButton enhancements that committed code already depends on
PlanCellModal (and the modal system) reference FormModal's footerLeft slot and hideCloseButton, but those props lived only in an uncommitted working-tree change — so committed code did not typecheck (TS2322) and a build-from-committed (e.g. the v1.9.0 release) silently dropped the footer-left delete button in the plan entry/override edit modal. Commits the enhancements + their CSS so committed == working.

NOTE: production v1.9.0 is affected (plan edit-modal delete button missing); needs a patch release. Flagged in the overnight audit report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:56:18 +02:00
BOHA
852c127c00 chore(release): v1.9.0 — work-plan category management
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:37:41 +02:00
BOHA
71a0016a4a fix(migration): create replacement index before dropping the unique one
20260605122718_drop_wpo dropped uniq_wpo_user_date (the sole index covering the user_id FK) BEFORE creating idx_wpo_user_date, which MySQL rejects on a fresh apply ('Cannot drop index ... needed in a foreign key constraint'). Reordered so the replacement index exists first. This migration is not yet applied on production, so editing it is safe; the resulting index state is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:31:36 +02:00
BOHA
88c9b7c2b8 fix(auth): stop logging expected token expiry as an error
verifyAccessToken logged every invalid/expired access token via console.error with a full stack trace. Access tokens are ~15 min and the client silently refreshes + retries on the 401, so this is an expected condition that flooded production logs (~every 15 min per active user) and buried real errors. Now only genuinely unexpected failures (non-JsonWebTokenError, e.g. a DB error in loadAuthData) are logged. No behavior change — verification still returns null on any token error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:23:56 +02:00
BOHA
4a78d31a7f fix(types): resolve all client TypeScript errors (attendance + plan rollback)
- AttendanceAdmin: declare worked_hours_raw on UserTotalData (the hook sets it at runtime; the local interface omitted it)
- usePlanWork.rollbackMutation: type the mutation param as unknown + explicit cast so passing the TanStack mutation result (with the dynamically-stashed _rolled) no longer trips the weak-type check
tsc -p tsconfig.app.json --noEmit is now clean (0 errors, was 9).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:20:24 +02:00
BOHA
e845400fca fix(plan): keep retired category usable when editing an entry's note
Update validates the category only when it changes, so a note-only edit on an entry whose category was later hidden/deleted no longer 400s. The edit select also shows the current (retired) category marked '(skryta)' instead of silently snapping to the first active option; create defaults to an active category.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:17:23 +02:00
BOHA
fce27633a9 fix(ui): add admin-btn-danger variant so category delete confirm is styled
admin-btn-danger was referenced but never defined, so the 'Opravdu smazat' confirm rendered as a plain button. Define a solid danger button (uses --danger), matching the primary button's structure; the per-row delete trigger stays an outline via plan.css. (Includes the transparent-border button refinement so variants don't layout-shift.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:15:12 +02:00
BOHA
4d47897e27 feat(plan): add hard-delete for categories (blocked when in use)
DELETE /plan/categories/:id now hard-deletes the row, guarded: refuses when any live entry/override uses the key (suggests hide instead) and keeps >=1 active category. Modal gains a per-row Smazat with inline confirm. Hiding stays via PATCH is_active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:12:40 +02:00
BOHA
aef4ab92ec feat(plan): label plan_category audit rows in Czech
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 01:00:30 +02:00
BOHA
5f36c34d48 refactor(plan): commit category color on blur to avoid PATCH/audit spam
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:59:41 +02:00
BOHA
b94f4879e3 feat(plan): add Sprava kategorii management modal and button
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:55:20 +02:00
BOHA
dab5ad8aa5 feat(plan): drive category select and view label from DB categories
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:52:07 +02:00
BOHA
8b77a0a905 feat(plan): render category colors from DB via --cat-color
Replace 14 hardcoded per-category CSS rules with a single --cat-color
custom property set inline on each filled cell button, driven by the DB
categories list threaded from PlanWork → PlanGrid → PlanRangeChips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:47:53 +02:00
BOHA
4b1c42b17f feat(plan): add categories query, type, and map helpers (frontend)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:44:28 +02:00
BOHA
a55356d417 feat(plan): validate dynamic category keys, resolve audit label from DB
- Replace hardcoded planCategoryEnum z.enum with z.string().trim().min(1)
- Add assertActiveCategory() helper that rejects inactive/unknown keys
- Add resolveCategoryLabel() helper that resolves the Czech label from plan_categories DB table, falling back to built-in map
- Change PlanAuditDescriptionInput.category -> categoryLabel (pre-resolved string)
- Update buildPlanAuditDescription to use opts.categoryLabel directly
- Update all 6 buildPlanAuditDescription call sites in plan.service.ts to await resolveCategoryLabel(row.category)
- Add category validation in createEntry, updateEntry, createOverride, updateOverride
- Update planAuditDescription tests to pass categoryLabel instead of category

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:41:50 +02:00
BOHA
c983747418 feat(plan): add plan category CRUD routes with audit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:38:25 +02:00
BOHA
3db257faa9 feat(plan): add plan category service with tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:35:32 +02:00
BOHA
d3d7f7e199 feat(plan): add plan category zod schemas
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:33:47 +02:00
BOHA
084ec9289b feat(plan): add plan_categories table, migrate category enum to varchar
- Add `plan_categories` model (id, key, label, color, sort_order, is_active)
- Change `work_plan_entries.category` from ENUM plan_category to VARCHAR(50)
- Change `work_plan_overrides.category` from ENUM plan_category to VARCHAR(50)
- Remove the `plan_category` enum from schema
- Seed 7 built-in categories (work, preparation, travel, leave, sick, training, other)

Also fixes two pre-existing migration issues that blocked shadow-DB execution:
- 20260605122718: swap CREATE INDEX before DROP INDEX so MySQL FK constraint is not broken
- 20260514071455_init: fix quotations.project_code VARCHAR(50→100) to match actual DB schema

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:32:02 +02:00
BOHA
4a9c283789 fix(plan): readable audit descriptions, project labels, dashboard invalidation, view-only & sticky-column UI
Work-plan fixes from this session:
- Czech audit descriptions for plan entries/overrides (was empty '-')
- server-embedded project label on grid cells (was '-' past the 100-project cap)
- dashboard activity + audit-log cache invalidation on plan mutations
- read-only cells: no '+' on empty, keep hover on cells with a record
- view modal: Czech category + formatted dates
- sticky date column made opaque (no bleed-through on horizontal scroll)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:24:33 +02:00
BOHA
2f76fe0bc1 fix(plan): add 'Upravit celý rozsah' button, use class-based action rows 2026-06-05 13:58:07 +02:00
BOHA
9a11a8f001 feat(plan): show project name instead of raw id in cell 2026-06-05 13:51:27 +02:00
BOHA
f0816b126b fix(plan): make mutations throw on non-2xx so errors surface as alerts 2026-06-05 13:46:50 +02:00
BOHA
f31797fb5b fix(plan): allow empty notes, use local time for past-date gate
Three related fixes from manual smoke testing:
- Zod schema: drop min(1) on note field across all four schemas — note is
  optional in real usage, and the modal initialized it to empty.
- assertNotPastDate: replace toISOString() (UTC) with local-time getters
  to match the rest of the codebase. Fixes off-by-one near day boundaries.
- createEntry / updateEntry: also check the date_to endpoint for past
  dates, so a range can't extend backwards into the past even when its
  start is today or future. Defense in depth.

Modal: drop `required` attribute and asterisk from the note field. Tests:
add 5 new cases covering empty note, past date HTTP rejection, and the
new to-endpoint check in updateEntry.
2026-06-05 13:42:21 +02:00
BOHA
9c110abf46 fix(plan): narrow mode in EditForm IIFE and style plan-banner 2026-06-05 13:21:06 +02:00
BOHA
a0f351b0aa feat(plan): page, sidebar entry, and route registration 2026-06-05 13:17:16 +02:00
BOHA
916cf34efd feat(plan): edit modal with create / edit-range / override / view modes 2026-06-05 13:02:52 +02:00
BOHA
003807a074 feat(plan): grid component with sticky headers and weekend tint 2026-06-05 12:57:41 +02:00
BOHA
ca911bf96c feat(plan): react query options and page state hook 2026-06-05 12:54:50 +02:00
BOHA
9c10ff9f22 test(plan): HTTP route tests with auth, scoping, and force flag 2026-06-05 12:51:20 +02:00
BOHA
38a58b55c0 feat(plan): wire plan routes into the server 2026-06-05 12:42:53 +02:00
BOHA
4d8fd62961 feat(plan): REST routes for plan endpoints 2026-06-05 12:40:25 +02:00
BOHA
ef404e4362 feat(plan): listEntries/listOverrides with employee scoping 2026-06-05 12:33:20 +02:00
BOHA
fb022802ae feat(plan): override CRUD with replace-existing behavior 2026-06-05 12:30:27 +02:00
BOHA
795d4d5af9 feat(plan): entry CRUD with past-date lock and audit-data return 2026-06-05 12:19:21 +02:00
BOHA
74113035ae feat(plan): assertNotPastDate guard for write paths 2026-06-05 12:15:39 +02:00
BOHA
42f9561337 feat(plan): listPlanUsers with role/permission filter 2026-06-05 12:13:16 +02:00
BOHA
f79967d433 feat(plan): resolveGrid for batch cell resolution 2026-06-05 12:10:30 +02:00
BOHA
d410e7433e feat(plan): extend EntityType with work_plan_entry and work_plan_override 2026-06-05 12:07:48 +02:00
BOHA
0711bafac0 feat(plan): resolveCell with override-precedence semantics 2026-06-05 11:55:30 +02:00
BOHA
5273f88272 feat(plan): zod schemas for plan endpoints 2026-06-05 11:47:34 +02:00
BOHA
182d4ca4b3 feat(plan): add work_plan_entries, work_plan_overrides, plan_category enum 2026-06-05 11:44:08 +02:00
Claude
4773b5be22 docs(spec): Plán prací (work schedule) module design 2026-06-05 11:05:56 +02:00
BOHA
6074d6163b docs(release): document post-tarball migration hotfix procedure 2026-06-03 23:27:44 +02:00
BOHA
652e3f91fc fix(warehouse): add migration for warehouse permissions
The warehouse module v1.8.0 deploy created the warehouse tables via Prisma
migration but left the corresponding permissions (warehouse.view/operate/
manage/inventory) and admin role assignments un-inserted, because those
rows only existed in prisma/seed.ts, which is dev-only and cannot be run
against production.

This migration inserts the same 4 permissions seed.ts would (with
identical display_name and description) and assigns them to the admin
role. INSERT IGNORE makes it idempotent and preserves existing role
assignments. No data is modified or deleted.

Also strengthens CLAUDE.md: every database change or manipulation must
be a tracked Prisma migration. No external SQL, no seed-only data,
no manual fixes — reviewable, reversible, reproducible from a fresh
checkout.
2026-06-03 23:26:34 +02:00
BOHA
5233db2002 v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix
Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
2026-06-03 23:13:10 +02:00
BOHA
dac45baaa8 test(warehouse): add integration tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:41:15 +02:00
BOHA
f389c4b3cf feat(warehouse): add reservations, inventory, reports, and dashboard pages
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:36:31 +02:00
BOHA
fdc7037e60 feat(warehouse): add issue pages 2026-05-29 14:32:11 +02:00
BOHA
b02f6afe93 feat(warehouse): add receipt pages 2026-05-29 14:29:44 +02:00
BOHA
1b2ab7b4eb feat(warehouse): add items list and detail pages
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:27:15 +02:00
BOHA
29975ecad9 feat(warehouse): add categories, suppliers, locations pages 2026-05-29 14:23:31 +02:00
BOHA
e846f15126 feat(warehouse): add sidebar entry and routes 2026-05-29 14:21:35 +02:00
BOHA
86bebf9776 feat(warehouse): add shared warehouse components
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:20:42 +02:00
BOHA
7b7c6bde68 feat(warehouse): add TanStack Query options and types 2026-05-29 14:19:38 +02:00
BOHA
935fb235a6 feat(warehouse): register warehouse routes in server
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:18:16 +02:00
BOHA
d2d1cdbb92 feat(warehouse): add receipt, issue, reservation, inventory, and report routes
- Receipts: CRUD + confirm/cancel + multipart attachment upload/delete
- Issues: CRUD + confirm/cancel with auto-FIFO batch selection
- Reservations: list + create + cancel
- Inventory sessions: CRUD + confirm with auto system_qty calculation
- Reports: stock-status, project-consumption, movement-log, below-minimum
- Add confirm/cancel/upload audit actions and warehouse_receipt_attachment entity type

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:17:33 +02:00
BOHA
5f08b0e086 feat(warehouse): add master data API routes
Categories, suppliers, locations, and items CRUD endpoints with
pagination, search, computed stock fields, audit logging, and
conflict validation (409 for references, unit change with batches).
2026-05-29 14:12:53 +02:00
BOHA
637499a8ec feat(warehouse): add warehouse service with FIFO and business logic
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:11:22 +02:00
BOHA
f413bae348 feat(warehouse): add Zod validation schemas 2026-05-29 14:09:39 +02:00
BOHA
2b1de583fd feat(warehouse): add entity types, permissions, and number sequences 2026-05-29 14:08:35 +02:00
BOHA
5500cdb118 feat(warehouse): add Prisma schema for warehouse module
Add 13 warehouse models (sklad_categories, sklad_suppliers, sklad_locations,
sklad_items, sklad_batches, sklad_item_locations, sklad_receipts,
sklad_receipt_lines, sklad_receipt_attachments, sklad_issues,
sklad_issue_lines, sklad_reservations, sklad_inventory_sessions,
sklad_inventory_lines) and 4 enums (sklad_receipt_status, sklad_issue_status,
sklad_reservation_status, sklad_inventory_status).

Add reverse relations to projects and users models with named relations
to avoid conflicts with existing relations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 14:08:00 +02:00
BOHA
2254a13ad0 v1.7.0: fix offer created_at override, invoice PDF translations, remove has_order filter
- Add created_at to offer create/update schemas so user-selected date is stored instead of always defaulting to now()
- Translate payment method in invoice PDF based on language (Příkazem → Bank transfer, etc.)
- Add order_date and cnb_rate translation keys, replace hardcoded inline strings
- Remove duplicate has_order filter from offers backend route

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:35:36 +02:00
BOHA
1f5885de84 v1.6.9: bump version
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:20:37 +02:00
BOHA
705f58e3d1 v1.6.9: cleanup offer dead columns, add item template picker to offer form
- Remove dead DB columns: uuid, sync_version from quotations/quotation_items/scope_sections; is_deleted from quotation_items/scope_sections; content_editor_height from scope_sections
- Remove unused scope_title/scope_description from offer form state
- Remove dead CSS: offers-template-menu, offers-draft-indicator, exchange-rate
- Remove dead scope_title translation key from offers PDF
- Fix duplicate Customer interface in OfferDetail.tsx (use shared type)
- Add missing title field to ScopeTemplate interface
- Add item template picker dropdown next to "Přidat položku" button

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:20:22 +02:00
BOHA
0330453ad6 v1.6.8: remove offer exchange rate, add invoice item description, offer filters, table animations
- Remove exchange_rate and exchange_rate_date from quotations (schema, service, PDF, form)
- Add item_description field to invoice_items (schema, migration, service, form, PDF)
- Add offer status/customer/order filters with tab-based UI
- Clean up offer statuses to active/ordered/invalidated
- Allow invalidate action for non-ordered offers (not just expired)
- Add fade animations on offers and invoices table data changes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:58:51 +02:00
BOHA
66b98e2765 v1.6.7: add settings.system permission, move numbering/VAT to company tab, rename security tab to roles
- New settings.system permission with migration (idempotent INSERT)
- requireAnyPermission helper for route guards accepting multiple perms
- Move document numbering + currency/VAT cards from system tab to CompanySettings
- Rename security tab to roles, add canManageSystem alongside canManageCompany
- TOTP required endpoint and system-info now use settings.system
- Roles list now includes user_count
- Sidebar Settings link includes settings.system

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 14:03:31 +02:00
BOHA
55648c9a30 v1.6.6: fix permissions order in role edit modal
- Add secondary sort by id in permissions API to ensure deterministic within-module order

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 18:17:30 +02:00
BOHA
cd6d3daf43 v1.6.5: fix attendance unique constraint, schema sync, seed script
- Change attendance idx_attendance_user_date from unique to index (allow multiple shifts per day)
- Reset migrations to single baseline init migration
- Add seed script with admin user (admin/admin)
- Update CLAUDE.md with migration workflow documentation
- Various frontend fixes (queries, pages, hooks)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 16:21:34 +02:00
BOHA
1db5060c42 v1.6.4: fix RichEditor auto-scroll when opening offers with multiple sections
Replace Quill editor.format() with direct DOM style manipulation to
avoid getSelection(true) calls that steal focus and scroll the page
to the sections area on mount.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 08:07:18 +02:00
BOHA
4cabba395d v1.6.3: fix project file upload, filter responsible user by permission
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 20:26:32 +02:00
BOHA
59b478f262 v1.6.2: fix RichEditor auto-scroll and PDF offers multi-page header
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:23:36 +02:00
BOHA
e4f14a24b7 fix: handle plain month number in attendance route, not just YYYY-MM
AttendanceAdmin sends ?year=YYYY&month=M (separate params), but the
route handler assumed month was always in "YYYY-MM" format. When
query.month was just "4", the split produced NaN for month and 4 for
year, causing the service to skip the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 16:50:20 +02:00
BOHA
3bd0d055d9 v1.6.0: fix offer items mobile layout and localStorage draft save/restore
- Fix items table description column width on mobile (was ~82px, now ~260px)
- Activate unused offers-items-table CSS class in OfferDetail form
- Save all form fields to localStorage draft (language, VAT, exchange rate were missing)
- Use DRAFT_KEY constant in loadOfferDraft, add error logging

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 15:23:42 +02:00
BOHA
746d17e182 fix: parse YYYY-MM month filter correctly in attendance history
The frontend sends month as "YYYY-MM" but the route handler was passing
it through Number() which parsed only the year portion, causing the
service to ignore the month filter entirely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:29:47 +02:00
BOHA
e96e51598a v1.5.8: fix audit log table layout (Skeleton outside tbody)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:08:15 +02:00
BOHA
9abec36f07 v1.5.7: fix Settings system tab crash and OffersTemplates tab gap
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:29:10 +02:00
BOHA
ecd8e3679f fix: replace stray role reference in system settings tab with inline placeholder
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:04:22 +02:00
BOHA
ba95723b61 v1.5.6: boneyard-js skeleton migration, TanStack Query refactor, rate-limit config
- Replace hand-coded skeleton CSS/JSX with boneyard-js auto-generated bones
- Remove skeleton.css and @keyframes shimmer from base.css
- Add <Skeleton> wrappers with fixtures to all 25+ page components
- Generate 20 bone captures via boneyard CLI (CDP auth-gated capture)
- Refactor data fetching from useEffect+useState to TanStack Query
- Extract query hooks into src/admin/lib/queries/ and apiAdapter
- Add usePaginatedQuery hook replacing useApiCall/useListData
- Fix parseFloat || 0 anti-pattern in OfferDetail and OffersTemplates inputs
- Fix customer_id mandatory validation on offer creation
- Fix leave-requests comma-separated status filter (Prisma enum in: [])
- Add cross-entity cache invalidation for orders/offers/invoices/projects
- Make rate limits configurable via env vars (RATE_LIMIT_MAX, RATE_LIMIT_REFRESH, etc.)
- Add boneyard.config.json with routes and breakpoints

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:35:43 +02:00
BOHA
12289bdce3 fix: only show session-expired alert when user had a valid session
Added hadValidSessionRef to track whether the user was ever
authenticated during this page load. setSessionExpired() in
silentRefresh now only fires when the ref is true, preventing
the alert on direct visits by unauthenticated users.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 12:16:26 +02:00
BOHA
d1c5234a03 fix: allow logo endpoint without auth for <img> tag loading
Logo images are loaded via <img src> which doesn't carry auth cookies
reliably during login transitions. Changed from requireAuth to
optionalAuth — logos are not sensitive data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:52:24 +02:00
BOHA
27cc876e82 fix: add missing migration for totp_last_used_counter column
Column existed in Prisma schema but had no migration, causing 500 on
TOTP login in production where the column was absent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:45:11 +02:00
BOHA
82919d39f6 fix: remove manual project creation, smart sequence release, received-invoices schema fix
- Remove ProjectCreate page, POST /projects endpoint, and next-number endpoint
- Projects can only be created through orders (shared numbering sequence)
- Remove dead CreateProjectSchema and createProject service function
- Delete 'order' row from number_sequences (unused; code uses 'shared')
- Smart sequence release: decrement last_number only when deleting the highest number
- Fix received-invoices stats referencing non-existent is_deleted and amount_czk columns
- Update deploy instructions in CLAUDE.md (npm install, prisma migrate deploy)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 11:36:08 +02:00
BOHA
3481b97d47 fix: useEffect anti-patterns, attendance permissions, and received-invoices schema mismatch
- Remove ref-mirror useEffect in AuthContext (cachedUserRef already written at mutation sites)
- Replace useEffect slide direction in ReceivedInvoices with render-time computation
- Fix Login.tsx useEffect dependency array (mount-only alert should have [] deps)
- Move "project created" alert to navigation source in ProjectCreate, remove useEffect in ProjectDetail
- Move companySettings defaults into fetch callbacks in InvoiceDetail and OfferDetail
- Replace due_date useEffect with useMemo in InvoiceDetail
- Capture initial snapshots from API data instead of useEffect in InvoiceDetail, OfferDetail, OrderDetail
- Replace localStorage draft useEffect with lazy useState initializer in OfferDetail
- Fix attendance dropdown to filter by attendance.record permission only
- Fix clock-out 404 on update-address (remove departure_time filter for departure action)
- Fix received-invoices stats endpoint referencing non-existent is_deleted and amount_czk columns

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 10:28:15 +02:00
BOHA
d7c7fbad88 fix: security, validation, and data integrity fixes across 53 files
- Auth: HS256 algorithm restriction on JWT verify, timing-safe bcrypt
  for inactive/locked users, locked_until check in loadAuthData, TOTP
  fixes (async bcrypt, BigInt conversion, future-code counter fix)
- Validation: Zod enums for leave_type/status, numeric transforms on
  foreign keys, VAT 0% coercion fix (Number(v)||21 → v!=null checks)
- Permissions: requirePermission on attendance PUT, attendance_users
  and project_logs access checks, trips users filtered by trips.record
- Prisma queries: fixed roles.is:{OR} pattern (doesn't work on to-one
  relations), attendance_users now filters by attendance.record only
- Transactions: wrapped deleteOrder, createOrder, updateUser, deleteUser,
  duplicateOffer, bulkCreateAttendance, createLeave, scope-templates,
  leave-requests, company-settings, profile updates
- Frontend: mountedRef reset in useListData, blob URL cleanup on unmount,
  null checks on date fields, AdminDatePicker min/max for HH:mm
- Security headers: COOP, CORP, CSP frame-ancestors/form-action/base-uri
- Other: exchange-rate cache TTL, invoice-alert midnight comparison fix,
  numbering.service releaseSequence no-op, nas-offers filename sanitize,
  Content-Disposition header injection fix, mojibake Czech strings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 08:40:38 +02:00
BOHA
7f07032bf2 fix: attendance clock-in silently aborted by broken mountedRef guard
mountedRef was initialized to true but never reset on mount. The
cleanup function (useEffect return) set it to false on unmount. In
React 18 Strict Mode, components mount-unmount-remount during dev.
After the first cleanup, mountedRef stayed false forever.

Result: handlePunch set submitting=true, geolocation callbacks fired,
but every callback returned early at `if (!mountedRef.current) return`
before calling submitPunch. No server request, button stuck.

Fix: add `mountedRef.current = true` inside the useEffect body.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:34:01 +02:00
BOHA
d873c96ae3 fix: attendance clock-in hanging after geolocation confirmation
On desktop browsers without GPS hardware, getCurrentPosition with
enableHighAccuracy:true can silently hang after the user grants
permission — neither success nor error callback fires.

Previous safety timeout (12s) only reset the button without sending
the punch request, leaving users stuck. Now:
- enableHighAccuracy: false (faster fallback to IP-based location)
- Browser timeout reduced to 5s
- Safety timeout reduced to 6s and automatically calls submitPunch
  without GPS data instead of just showing an error
- Wrapped success callback in try/catch as additional safeguard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 08:23:12 +02:00
BOHA
c4f6723042 fix: attendance clock-in button stuck when geolocation fails or hangs
handlePunch set submitting=true before calling geolocation, but the
error callback never reset it. When geolocation was denied or timed out:
- Error alert showed
- GPS confirm modal opened
- Button stayed disabled showing "Zpracovávám..."
- User thought it was stuck; no server request appeared to happen

Also added a 12s safety timeout fallback because some browsers silently
hang on getCurrentPosition without calling either callback.

Fix: call setSubmitting(false) in the error callback and clear the
safety timeout in both success and error paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:40:51 +02:00
BOHA
9e699c4dd4 fix: React hooks rules violation in Login.tsx causing crash on load
useCallback hooks were placed AFTER conditional early returns.
When authLoading toggled from true -> false, the hook count changed
between renders (14 hooks vs 17 hooks), triggering React's
"Rendered more hooks than during the previous render" error.

Moved all useCallback definitions before the conditional returns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:31:00 +02:00
BOHA
ea81380225 fix: dashboard $queryRaw Date serialization with custom toJSON override
Prisma $queryRaw template literal interpolation fails when Date objects
are passed directly and Date.prototype.toJSON is overridden (returns
local time string instead of UTC ISO). MySQL driver receives a nested
JSON object instead of a flat parameter array.

Fix: convert monthStart/monthEnd to strings via toJSON() before
interpolating into the $queryRaw template literal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:23:27 +02:00
BOHA
a9bc82fac5 fix: Prisma $queryRaw MySQL type coercion for BigInt and Boolean
$queryRaw on MySQL returns BigInt for integer columns and 0/1 for booleans.
Passing these raw values back to Prisma client methods causes validation errors:
- Expected Int, provided BigInt
- Expected Boolean, provided Int

Fixed in auth refresh, TOTP login, and TOTP backup code flows by wrapping
storedToken.id, storedToken.user_id with Number() and remember_me with Boolean().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:18:38 +02:00
BOHA
8c278be941 test: add regression tests for Critical+High FLAWS_REPORT fixes
- Tests caught 2 real bugs:
  - Zod NaN bypass in orders/offers schemas (Number(v) || fallback)
  - invoiceTotalWithVat using Number() on { toNumber() } objects
- 7 new test files covering auth, env, exchange rates, NAS paths,
  schema NaN rejection, invoice VAT calculation, customer validation
- 45 tests passing, build clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 11:04:20 +02:00
BOHA
aa6c1b5094 refactor: fix all Low findings from FLAWS_REPORT audit
- Auth: TOTP params from config, JWT error logging, audit log failure
  logging, replaced_by_hash validation on token rotation
- Invoices: remove dead VAT code, consistent PDF permissions,
  WebP magic-byte detection, deduped exchange-rate fetches
- Orders/Offers: multipart limit from config, use paginated() helper,
  payment method from DB in PDF
- Projects: verify project exists before creating note
- Attendance: action_type enum validation, consistent local-time
  shift_date construction, holiday attendance in work fund,
  trips.view permission on last-km query
- Users: paginated() helper usage, remove duplicate dashboard keys,
  parallel currency conversion, single hashToken implementation
- Frontend: memoized customInput, reliable print onload, modal prop
  standardization (isOpen), ConfirmModal type icons, id===0 key
  fallback, Login useCallback, CompanySettings ConfirmModal,
  Attendance timeout cleanup, Dashboard memoization, beforeunload
  dirty-state warnings on Invoice/Offer/Order detail
- Schema: invoice_alert_log timestamp, config/env comment on
  Date.prototype.toJSON override
- Utils: exchange-rate inflight dedup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:45:37 +02:00
BOHA
4f4b12f039 security: fix all Medium findings from FLAWS_REPORT audit
- Auth: TOTP replay protection with counter tracking, constant-time
  backup code comparison, atomic lockout increment, per-token logout
- Invoices/PDFs: net-based VAT calculation, dangerous URL scheme
  stripping in cleanQuillHtml, orders-pdf error handling
- Orders: reject item changes on status transition, cascading
  delete cleanup, take:1 with orderBy
- Projects: atomic rename collision handling, MIME/extension
  validation, empty customer name rejection
- Attendance: Czech public holiday awareness in frontend fund
  calculation, leave_hours 0 handling, invalid date NaN guard,
  bounded per-month queries in workfund
- Users/Admin: profile audit logging + password validation, session
  revocation guard, session ID validation, dashboard DB aggregation,
  soft-deleted record protection in scope templates
- Frontend: FormField label linkage, Pagination ARIA, error
  handling in OrderConfirmationModal, 401 propagation, GPS emoji
  hidden from screen readers, table sort state fix, geolocation
  race/abort cleanup, Leaflet popup DOM safety, Vehicles toggleActive
  minimal body, CompanySettings ref mutation fix, OfferDetail unlock
  abort, AttendanceBalances combined fetches
- Utils: env validation, Puppeteer concurrency mutex, invoice alert
  cron cleanup on shutdown, body limit alignment, TOTP error logging,
  trustProxy from env, symlink rejection, rate cache Map usage

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 08:24:14 +02:00
BOHA
528e55991b security: fix all Critical and High findings from FLAWS_REPORT audit
- Auth: pessimistic locking on login tokens and refresh token rotation,
  backup code attempt counter, rate limiting verification
- Schema: unique constraints on business numbers, FK relations,
  unsigned/signed alignment, attendance duplicate prevention
- Invoices/PDFs: DOMPurify sanitization, bounded queries in stats
  and alerts, VAT rounding, Puppeteer error handling
- Orders/Offers: transactional parent+child creation, Zod NaN
  refinement, status enums, uniqueness checks
- Projects/Files: path traversal protection, streamed uploads,
  permission guards, query param validation
- Attendance/HR: duplicate checks, ownership validation, GPS
  restrictions, trip distance validation
- Frontend: modal lock reference counting, XSS escaping in print
  HTML, ref mutation fixes, accessibility attributes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 00:58:35 +02:00
BOHA
122eee175e docs: update CLAUDE.md release process and file count
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 21:36:49 +02:00
BOHA
5a28f75303 1.5.3
- feat: manual VAT override in order confirmation modal
- feat: order confirmation PDF respects user-selected applyVat toggle

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:17:20 +02:00
BOHA
07cb428287 1.5.2
- feat: order confirmation PDF generation with VAT support
- feat: order confirmation modal with custom item editing
- fix: attendance negative duration clamping and switchProject timing
- fix: Quill editor locked to Tahoma 14px, PDF heading sizes
- fix: invoice/offer PDF font consistency (Tahoma enforcement)
- fix: invoice alert cron improvements
- fix: NAS financials manager edge cases
- refactor: numbering service with unique sequence constraints

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 17:23:10 +02:00
BOHA
b197017644 1.5.1 2026-04-02 20:01:44 +02:00
BOHA
e9f07a4a39 fix: invoice edit/list improvements
- Due date uses days selector in edit mode (same as create)
- Overdue invoices fully editable (same as issued)
- Overdue status reversed to issued when due date moved to future
- Invoice list: edit icon for issued/overdue, eye for paid
- Invoice list: PDF opens blob from NAS (removed lang modal)
- NAS cleanup: properly scans directories when cleaning old PDFs
- Fixed syntax error from leftover else block

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 20:01:43 +02:00
BOHA
44d389201c 1.5.0 2026-04-02 15:47:46 +02:00
BOHA
3106aaf314 feat: full invoice editing before payment, NAS cleanup on date change
- Invoice edit mode now uses the same form as create mode (all fields editable)
- Bank account pre-selected by matching IBAN/account number
- Invoice number read-only in edit mode
- Paid invoices remain read-only
- NAS: old PDF deleted when invoice date changes to different month
- Buttons: Zobrazit fakturu, Uložit, Smazat + status transitions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:47:46 +02:00
BOHA
90e797b8fa 1.4.9 2026-04-02 15:25:35 +02:00
BOHA
1f7362c8af fix: invoice PDF — tighter layout, more room for items
- Page margins reduced, content width 186mm
- Header/grid padding tightened
- Table headers 8.5pt normal case, cells 4px padding
- Footer flows naturally across pages (no forced page break)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:25:35 +02:00
BOHA
fe44a2b12d 1.4.8 2026-04-02 12:55:24 +02:00
BOHA
8a9239311d feat: invoice PDF — larger fonts, order number and date in dates column
- Base font 9pt→10pt, all sub-elements scaled proportionally
- Order number and date shown in dates column when invoice linked to order
- Uses customer_order_number with fallback to internal order_number

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:55:24 +02:00
BOHA
cd25cd6ee4 1.4.7 2026-04-02 12:31:51 +02:00
BOHA
967fbba2a4 fix: invoice PDF footer — single line with space for signatures
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:31:51 +02:00
BOHA
41fe65c7fc 1.4.6 2026-04-02 12:01:52 +02:00
BOHA
09d345a312 fix: invoice PDF table — numbers 8pt, description column wider (36%)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:01:51 +02:00
BOHA
1a13d745f1 1.4.5 2026-04-02 11:56:06 +02:00
BOHA
ce184771a6 feat: invoice PDF redesign — professional table-based layout
- Header with red accent border, larger invoice number
- Address blocks in connected table grid with equal heights
- Customer and bank info highlighted with gray background
- Bank info uses same row layout as dates (aligned labels/values)
- Labels nowrap, values right-aligned
- Item font size 8pt, table header border gray
- Removed duplicate separator lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:56:05 +02:00
BOHA
7b6365f6b3 1.4.4 2026-04-02 11:28:13 +02:00
BOHA
44867c79f8 fix: PDF item names bold on Linux — font-weight 500→600
Linux lacks Segoe UI semibold, so weight 500 rendered as regular.
Changed to 600 which maps to bold on both Windows and Linux.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:28:12 +02:00
BOHA
09a9e8c2f0 1.4.3 2026-04-02 11:13:30 +02:00
BOHA
b26a6f40b9 fix: invoice PDF shows unit next to quantity (e.g. 193,50 / ks)
Adjusted column widths to prevent header overlap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:13:29 +02:00
BOHA
40cb5a4d76 1.4.2 2026-04-02 11:05:42 +02:00
BOHA
ecd97ae5a3 fix: bulk attendance fill creates holiday records instead of skipping
Holidays now get leave_type: "holiday" with 8h so they count in fund calculation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 11:05:42 +02:00
BOHA
d14e97d7bd 1.4.1 2026-04-02 10:56:26 +02:00
BOHA
ef891f8e01 fix: bulk attendance fill — accept string user_ids, skip holidays
- Schema now accepts both string and number user_ids (frontend sends strings)
- Bulk fill now skips Czech public holidays in addition to weekends

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 10:56:25 +02:00
BOHA
96ba5d034f 1.4.0 2026-03-28 09:03:06 +01:00
BOHA
2402b7cbc8 fix: "Moje žádosti" page shows only current user's requests
Admins were seeing all requests on their own requests page.
Added mine=1 param to force user_id filter regardless of role.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:03:05 +01:00
BOHA
79b2fa5570 1.3.9 2026-03-28 08:56:14 +01:00
BOHA
35fa172d36 fix: trips admin shows only users with trips.record permission
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:56:14 +01:00
BOHA
000a77ccf4 1.3.8 2026-03-27 21:27:16 +01:00
BOHA
ecd9f6a181 chore: fix npm audit vulnerabilities (brace-expansion, fastify, nodemailer, picomatch)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:27:14 +01:00
BOHA
68e6d80903 1.3.7 2026-03-27 17:32:22 +01:00
BOHA
af1b41994c fix: attendance shows only users with attendance.record permission
- Filter attendance admin/balances/workfund to users with attendance.record
  permission or admin role
- New attendance_users API action for user dropdown
- Fix missing prisma import in attendance route
- Fix user edit: empty password no longer blocks save (preprocess to undefined)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:32:22 +01:00
BOHA
9779112066 1.3.6 2026-03-27 13:50:00 +01:00
BOHA
e8d6dc1567 fix: dashboard offers card showing wrong counts
Queried status "converted"/"expired" but actual DB values are
"ordered"/"invalidated". Updated label "Prošlé" → "Zneplatněné".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:50:00 +01:00
BOHA
f9dd49591e 1.3.5 2026-03-27 13:44:54 +01:00
BOHA
8cdf057ab3 feat: CNB exchange rates, multi-currency KPI stats, invoice PDF VAT in CZK
- ČNB exchange rate service with date-specific rates and caching
- Invoice/received invoice stats convert foreign currencies to CZK
- Dashboard revenue converts all currencies to CZK
- Invoice PDF: VAT recap table always in CZK with CNB rate footer
- Inline styles replaced with utility classes (step 4 cleanup)
- Spinner animation exempt from prefers-reduced-motion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:44:53 +01:00
BOHA
a3ef37d0d2 1.3.4 2026-03-27 13:00:46 +01:00
BOHA
e0ea997c24 refactor: split admin.css monolith, standardize CSS architecture
- Split admin.css (3228 lines) into 12 focused files: variables, base,
  forms, buttons, layout, components, tables, skeleton, datepicker,
  filemanager, pagination, responsive
- Extracted shared styles from offers.css and dashboard.css into
  components.css and forms.css (offers-* → admin-* prefix)
- Standardized naming: dash-kpi-* → admin-kpi-*, session-* → dash-session-*,
  rich-editor → admin-rich-editor
- Deleted duplicate offers-tabs (using admin-tabs everywhere)
- Deduplicated DatePicker and FileManager CSS (~360 lines removed)
- Added 16 utility classes to base.css (font sizes, widths, gaps, margins)
- Deleted empty admin.css

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:00:45 +01:00
BOHA
cde560a2c3 1.3.3 2026-03-27 10:47:46 +01:00
BOHA
e6198e1b67 fix: file viewers blocked on mobile — open blank window before async fetch
Mobile browsers block window.open() after async operations. Changed all
file viewers to open a blank window synchronously in the click handler,
then set location.href after fetch completes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:47:45 +01:00
378 changed files with 89979 additions and 32989 deletions

View File

@@ -1,14 +1,23 @@
// Block direct .env file edits (not .env.example, .env.production, .env.test)
let data = '';
process.stdin.on('data', chunk => data += chunk);
process.stdin.on('end', () => {
//
// Hook exit-code contract (Claude Code PreToolUse):
// exit 0 = allow (stdout JSON is only parsed on exit 0)
// exit 2 = BLOCK — the reason must be written to STDERR
// any other exit code = non-blocking error (the tool call proceeds)
// So to actually block, we must print to stderr and exit 2.
let data = "";
process.stdin.on("data", (chunk) => (data += chunk));
process.stdin.on("end", () => {
try {
const input = JSON.parse(data);
const filePath = input.tool_input?.file_path || '';
const basename = filePath.split('/').pop().split('\\').pop();
if (basename === '.env') {
console.log(JSON.stringify({ decision: 'block', reason: 'Direct .env edits are blocked. Use .env.example instead.' }));
process.exit(1);
const filePath = input.tool_input?.file_path || "";
const basename = filePath.split("/").pop().split("\\").pop();
if (basename === ".env") {
console.error("Direct .env edits are blocked. Use .env.example instead.");
process.exit(2);
}
} catch {
// Deliberate fail-open: on unparseable input exit 0 (allow). Exiting 2
// here would block EVERY Edit/Write if the hook payload format changed.
}
} catch {}
});

View File

@@ -1,43 +0,0 @@
---
name: compare-php
description: Compare a feature between PHP boha-app and TS boha-app-ts to verify migration parity
---
# Compare PHP vs TS Implementation
Compare a specific feature, component, or endpoint between the original PHP project (D:\cortex\boha-app) and the TypeScript migration (D:\cortex\boha-app-ts).
## Usage
The user will specify what to compare. Examples:
- `/compare-php attendance print`
- `/compare-php invoice PDF generation`
- `/compare-php offer numbering logic`
## Process
1. Search the PHP codebase (D:\cortex\boha-app) for the relevant implementation
2. Search the TS codebase (D:\cortex\boha-app-ts) for the same feature
3. Compare:
- API endpoints and request/response shape
- Business logic and calculations
- Database queries
- Frontend behavior
4. Report differences found — what's missing, what's different, what's extra
## Key directories
**PHP project:**
- API handlers: `D:\cortex\boha-app\api\admin\handlers\`
- API routes: `D:\cortex\boha-app\api\admin\`
- Frontend pages: `D:\cortex\boha-app\src\admin\pages\`
- Frontend components: `D:\cortex\boha-app\src\admin\components\`
- Frontend hooks: `D:\cortex\boha-app\src\admin\hooks\`
- Includes/utils: `D:\cortex\boha-app\api\includes\`
**TS project:**
- API routes: `D:\cortex\boha-app-ts\src\routes\admin\`
- Services: `D:\cortex\boha-app-ts\src\services\`
- Frontend pages: `D:\cortex\boha-app-ts\src\admin\pages\`
- Frontend components: `D:\cortex\boha-app-ts\src\admin\components\`
- Frontend hooks: `D:\cortex\boha-app-ts\src\admin\hooks\`

View File

@@ -1,32 +1,63 @@
# Database
# ─────────────────────────────────────────────────────────────────────────
# boha-app-ts environment template. Copy to `.env` (dev) / `.env.test` (tests).
# Required vars throw at boot if missing; optional vars show their defaults.
# ─────────────────────────────────────────────────────────────────────────
# ── Required ──────────────────────────────────────────────────────────────
DATABASE_URL=mysql://user:password@localhost:3306/app
# Server
PORT=3001
HOST=127.0.0.1
APP_ENV=local
# Auth — MUST regenerate for production: openssl rand -hex 32
JWT_SECRET=generate-with-openssl-rand-hex-32
JWT_SECRET=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
# TOTP — MUST regenerate for production: openssl rand -hex 32
TOTP_ENCRYPTION_KEY=REPLACE_WITH_64_CHAR_HEX_STRING_RUN_openssl_rand_hex_32
# ── Server ────────────────────────────────────────────────────────────────
PORT=3001 # production port (dev is hardcoded to 3050)
HOST=127.0.0.1
APP_ENV=local # local | production — controls CSP/CORS/HSTS
APP_URL= # base URL used in email links
# TRUST_PROXY=127.0.0.1,::1 # comma-separated trusted proxy IPs
# ── Tokens (seconds) ──────────────────────────────────────────────────────
ACCESS_TOKEN_EXPIRY=900
REFRESH_TOKEN_SESSION_EXPIRY=3600
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000
# TOTP — MUST regenerate for production: openssl rand -hex 32
TOTP_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
# ── TOTP / 2FA (optional, sensible defaults) ──────────────────────────────
# TOTP_ALGORITHM=SHA1
# TOTP_DIGITS=6
# TOTP_PERIOD=30
# LOGIN_TOKEN_EXPIRY_MINUTES=5
# File storage
# ── Rate limiting (optional) ──────────────────────────────────────────────
# RATE_LIMIT_MAX=300
# RATE_LIMIT_WINDOW=1 minute
# RATE_LIMIT_LOGIN=20
# RATE_LIMIT_TOTP=5
# RATE_LIMIT_REFRESH=10
# ── File storage (NAS network shares) ─────────────────────────────────────
NAS_PATH=Z:/02_PROJEKTY
MAX_UPLOAD_SIZE=52428800
# NAS_INVOICES= # invoice PDFs — {base}/Vydané|Přijaté/YYYY/MM/ (falls back to NAS_FINANCIALS_PATH if unset)
# NAS_ORDERS= # issued-order PDF archival — {base}/Vydané/YYYY/MM/ (off until set)
# NAS_OFFERS_PATH=
MAX_UPLOAD_SIZE=52428800 # 50 MB
# Email
# ── Email ─────────────────────────────────────────────────────────────────
CONTACT_EMAIL_TO=
CONTACT_EMAIL_FROM=
SMTP_FROM=
# SMTP_FROM_NAME=
LEAVE_NOTIFY_EMAIL=
# INVOICE_ALERT_EMAIL= # set to enable the daily 08:00 invoice-alert cron
# Optional SMTP transport (defaults to local sendmail when SMTP_HOST is unset):
# SMTP_HOST=
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=
# SMTP_PASS=
# App URL (for email links)
APP_URL=
# CORS (production only, comma-separated)
# ── CORS (production only, comma-separated) ───────────────────────────────
CORS_ORIGINS=
# ── AI assistant "Odin" (optional; feature self-hides when unset) ─────────
# ANTHROPIC_API_KEY=

10
.gitignore vendored
View File

@@ -7,9 +7,19 @@ dist/
dist-client/
*.css.map
# Build / tooling artifacts
*.tsbuildinfo
*.bak
# Local scratch
.playwright-mcp/
# Release archives
*.tar.gz
# Claude worktrees
.claude/worktrees/
.claude/settings.local.json
# Superpowers brainstorm mockups
.superpowers/

7
.prettierignore Normal file
View File

@@ -0,0 +1,7 @@
dist/
dist-client/
node_modules/
prisma/migrations/
src/admin/bones/
*.tsbuildinfo
package-lock.json

7
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"trailingComma": "all",
"tabWidth": 2,
"printWidth": 80
}

569
CLAUDE.md Normal file
View File

@@ -0,0 +1,569 @@
# CLAUDE.md — boha-app-ts
Business management system for a Czech company, rewritten from PHP to TypeScript/Node.js.
Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operations.
---
## Tech Stack
| Layer | Technology |
| -------------- | ---------------------------------------------------------------------------------------------------------------- |
| Runtime | Node.js, TypeScript 5.9.3 (strict) |
| HTTP Framework | Fastify 5.8.2 |
| ORM | Prisma 7.8.0 (Rust-free client + @prisma/adapter-mariadb driver adapter; datasource in prisma.config.ts) → MySQL |
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
| Validation | Zod 4.3.6 |
| Frontend | React 19.2.7 + Vite 8.0.0 + Material UI v7 (Emotion) |
| Testing | Vitest 4.1.0 + Supertest |
| PDF | Puppeteer 24.x |
| Email | nodemailer 8.x |
| Cron | node-cron 4.x |
| AI | @anthropic-ai/sdk 0.102 — Claude Sonnet 4.6 ("Odin" assistant) |
---
## Project Structure
```
src/
├── server.ts # Fastify server entry point — plugins, routes, error handler
├── routes/admin/ # HTTP route handlers (one file per entity)
├── services/ # Business logic (no classes, exported functions, uses Prisma directly)
├── schemas/ # Zod validation schemas (one file per entity)
├── middleware/ # auth.ts (requireAuth, requirePermission, optionalAuth)
│ # security.ts (CSP, HSTS, security headers)
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
├── config/ # env.ts (config singleton, Date.toJSON override)
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
├── admin/ # React 18 + Material UI frontend (~114 .tsx files)
│ ├── AdminApp.tsx # Router + lazy-loaded pages
│ ├── theme.ts # MUI theme — light/dark color schemes, tokens, component defaults
│ ├── GlobalStyles.tsx # App-wide global styles via MUI <GlobalStyles> (reset, typography, utilities)
│ ├── ui/ # MUI component kit (AppShell, Button, DataTable, Modal, …) — pages import from here
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
│ ├── components/ # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets, odin/ (AI chat)
│ ├── pages/ # One file per page/feature
│ ├── lib/ # React Query options & mutations (queries/) + shared label maps
│ ├── hooks/ # usePaginatedQuery, useTableSort, useDebounce, useReducedMotion, …
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
└── __tests__/ # Vitest tests (17 files: auth, numbering, warehouse, plan, invoices, ai/Odin, received-invoices VAT, …)
prisma/
├── schema.prisma # 52 models, MySQL, snake_case columns
└── migrations/ # Applied migrations
dist/ # Compiled server (CommonJS, ES2022)
dist-client/ # Built frontend (Vite, ES2020)
```
---
## Commands
```bash
# Development
npm run dev # Starts server in watch mode (manage frontend separately)
npm run dev:server # tsx watch src/server.ts
npm run dev:client # Vite dev server
# Build
npm run build # Build server + client
npm run build:server # tsc -p tsconfig.server.json → dist/
npm run build:client # vite build → dist-client/
# Run (production)
npm start # node dist/server.js
# Tests
npm test # vitest run (single pass) — runs against the app_test DB via .env.test
npm run test:watch # vitest watch
# Quality gates
npm run typecheck # tsc -b --noEmit (also type-checks the tests via tsconfig.test.json)
npm run lint # eslint . — react-hooks/rules-of-hooks is an ERROR; keep at 0 errors
npm run format # prettier --write .
# Database
npx prisma migrate dev # Create migration from schema changes + apply to dev
npx prisma migrate dev --name <descriptive_name> # Named migration
npx prisma migrate deploy # Apply pending migrations to production
npx prisma generate # Regenerate Prisma client after schema changes
npx prisma studio # DB browser GUI
npx prisma db seed # (Re)seed the dev database
npx prisma migrate diff --from-url <url> --to-schema-datamodel prisma/schema.prisma --script # Preview SQL before prod deploy
npx prisma migrate resolve --applied <migration> # Mark migration as applied without running SQL
```
**Do not start the dev server.** The user manages it separately.
**Before running `prisma migrate dev` or `prisma db push`, ask the user to stop their dev server and wait for confirmation.** Migrations can conflict with an active database connection from the running server.
---
## Environment Variables
Required:
```
DATABASE_URL=mysql://user:pass@host:3306/dbname
JWT_SECRET=<64-char hex string>
TOTP_ENCRYPTION_KEY=<64-char hex string>
```
Optional (with defaults):
```
PORT=3001 # Production port (dev default: 3050, hardcoded in server.ts)
HOST=127.0.0.1
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
REFRESH_TOKEN_SESSION_EXPIRY=3600 # 1 hour
REFRESH_TOKEN_REMEMBER_EXPIRY=2592000 # 30 days
NAS_PATH=Z:/02_PROJEKTY # Network share for project files
MAX_UPLOAD_SIZE=52428800 # 50MB
CONTACT_EMAIL_TO=
CONTACT_EMAIL_FROM=
SMTP_FROM=
LEAVE_NOTIFY_EMAIL=
APP_URL= # Used in email links
CORS_ORIGINS= # Comma-separated, production only
```
Use `.env` for dev, `.env.test` for tests.
---
## Architecture & Key Patterns
### Request Flow
```
Request → CORS → Cookie → Rate-limit → Security headers
→ requirePermission() or requireAuth()
→ Zod schema validation (parseBody helper)
→ Route handler
→ Service function
→ Prisma
→ success(reply, data) or error(reply, message, status)
```
### Response Format
All responses use this shape:
```typescript
// Success
{ success: true, data: T, message?: string, pagination?: {...} }
// Error
{ success: false, error: string }
```
Use the `success()` and `error()` helpers in routes — never write raw `reply.send()`.
### Service Pattern
Services are plain exported async functions, no classes:
```typescript
// src/services/foo.service.ts
export async function getFoo(id: number) {
const result = await prisma.foo.findUnique({ where: { id } });
if (!result) return { error: "Not found", status: 404 };
return { data: result };
}
// src/routes/admin/foo.ts
const result = await getFoo(id);
if ("error" in result) return error(reply, result.error, result.status ?? 400);
return success(reply, result.data);
```
### Error Handling
- Routes map service errors to HTTP responses using the pattern above.
- Global error handler in `server.ts` catches all unhandled exceptions; returns 500 with Czech message.
- **Never silently swallow errors.** Even if a failure is non-fatal, log it: `app.log.error(e, 'context')`.
- Error messages are in Czech (this is intentional — user-facing messages, Czech company).
### Permissions
```typescript
// Route-level guard
fastify.addHook("preHandler", requirePermission("invoices.view"));
// or multiple
fastify.addHook(
"preHandler",
requirePermission("invoices.view", "invoices.edit"),
);
// Admin role bypasses all permission checks
// Permissions follow the pattern: "entity.action" (e.g., "users.create", "invoices.delete")
```
### Audit Logging
Call `logAudit()` from `src/utils/audit.ts` whenever data is created/updated/deleted.
Pass `oldData` and `newData` so the diff is stored. Audit failures are non-fatal.
### Validation
Use Zod schemas from `src/schemas/`. All route bodies must be validated:
```typescript
const body = parseBody(FooSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
```
---
## Date & Timezone Handling (Critical Gotcha)
`src/config/env.ts` sets `process.env.TZ = 'Europe/Prague'` and overrides
`Date.prototype.toJSON()` to return local time (not UTC). This means:
- `JSON.stringify(new Date())` returns local Czech time, not UTC.
- All API responses with Date fields will contain local time strings.
- Prisma stores dates as UTC internally, but they read back as local due to the TZ setting.
- **Never assume UTC** when working with Date objects in this codebase.
- When writing new date comparisons or DB queries, use `new Date()` (already local) — do not manually offset.
- The override exists for PHP migration compatibility and Czech date display.
---
## TOTP / 2FA
- Secret stored AES-256-GCM encrypted in `users.totp_secret`.
- Supports two encoding formats: PHP legacy (base64 iv+cipher+tag) and TS (hex).
- Backup codes stored as encrypted JSON array in `users.totp_backup_codes`.
- When `company_settings.require_2fa = true`, all users must enroll before accessing the app.
- Login flow: password → if 2FA enabled → issue `loginToken` (5 min, single-use) → TOTP verify → issue access + refresh tokens.
---
## Testing
Tests live in `src/__tests__/`. They use Vitest + Supertest against a **real, throwaway test database** (`app_test`).
- **Isolated test DB (`app_test`):** the suite MUTATES a real MySQL DB, so it runs against `app_test` (NOT dev `app`), configured in **`.env.test`** (gitignored). `src/__tests__/setup.ts` **hard-throws** if `DATABASE_URL` doesn't name a `*test*` database — a stray URL can never corrupt dev/prod data. To (re)create it: `CREATE DATABASE app_test;` → copy `.env` to `.env.test` with the DB name swapped + `ANTHROPIC_API_KEY=` blanked → `DATABASE_URL=<app_test-url> npx prisma migrate deploy``DATABASE_URL=<app_test-url> npx tsx prisma/seed.ts`.
- The suite spans 18 files / 247 tests — auth (incl. happy-path login/refresh-rotation), numbering, warehouse (incl. FIFO oldest-first), plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create, AI/Odin, received-invoices VAT. Server-side only (no component tests yet).
- Tests are now **type-checked** by `tsc -b` (via `tsconfig.test.json`) — keep them compiling.
- Use `buildApp()` helper to spin up the Fastify instance for tests.
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout; `fileParallelism: false` (serialized) to avoid `number_sequences` deadlocks.
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
When adding new features, add tests in `src/__tests__/`. Name test files `<feature>.test.ts`.
---
## Frontend Conventions
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
- Data fetching uses **React Query** (query options + mutations in `src/admin/lib/queries/`); `src/admin/utils/api.ts` (`apiFetch`) handles token refresh automatically — dedupes concurrent refreshes, and token responses carry `expires_in` so the client refreshes BEFORE expiry (no reactive 401 churn).
- Custom hooks: `usePaginatedQuery`, `useTableSort`, `useDebounce`, `useModalLock`, `useReducedMotion`.
- Styling: **Material UI v7** (Emotion) — theme in `src/admin/theme.ts`, `sx`/`styled()` in components, app-wide rules in `GlobalStyles.tsx`. **No hand-written `.css` files, no Tailwind.** Use `theme.vars` for colours so light/dark resolve automatically.
### Query Invalidation Convention
Mutations must invalidate the **full domain** of any entity they touch. Prefer broad invalidation:
- `["users"]` over `["users", "list"]`
- `["trips"]` over `["trips", "vehicles"]`
Reason: React Query's `invalidateQueries` uses prefix matching, so `["trips"]` matches all
`["trips", ...]` sub-queries. This means new queries are automatically invalidated without
updating every mutation handler. Inactive queries are only marked stale, not refetched, so
the performance cost is minimal. Optimize to targeted keys only when profiling shows a problem.
When entity A embeds/references entity B, A's mutation handlers invalidate B's domain:
- User CRUD invalidates `["trips"]` and `["attendance"]` (both embed user data)
- Vehicle CRUD invalidates `["trips"]` (trips reference vehicles)
- Invoice CRUD invalidates `["orders"]` (orders reference invoices)
---
## Frontend — Styling & MUI (migration complete, shipped in v2.0.0)
The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 lines of hand-written CSS are gone — **there are no custom `.css` files in `src/admin`**; the only stylesheets imported anywhere are the two third-party libs (`react-quill-new/dist/quill.snow.css`, `leaflet/dist/leaflet.css`). Look-and-feel is "Soft-SaaS shell + dense tables", dark/light preserved. Full history/decisions/gotchas live in agent memory (`project_mui_migration.md`); the original spec/plans are under `docs/superpowers/`.
**Where styling lives**
- **Theme — `src/admin/theme.ts`:** `cssVariables` with `colorSchemeSelector: "[data-theme='%s']"`, light + dark `colorSchemes`, tokens, component defaults. Use **`theme.vars!.palette.*`** (the `vars` field is typed optional → `!`) so colours resolve per scheme; for alpha use the channel tokens — `rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`; for per-scheme one-offs use `theme.applyStyles("dark", { … })`.
- **Global rules — `src/admin/GlobalStyles.tsx`:** reset, typography, scrollbar, `::selection`, view-transition timing, and the utility classes (`.text-*`, `.flex-*`, `.mb-*`, …), all theme-aware. (The pre-React bootstrap spinner is inlined in `src/App.tsx` because it mounts before MUI.)
- **Component kit — `src/admin/ui/`:** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField/MonthField/TimeField (date-fns v4, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable + mobile card layout + `rowSx`/`rowDanger`/`rowInactive`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField/SwitchField, Field, Alert, PageHeader, FilterBar, StatCard, ProgressBar, FileUpload, EmptyState, LoadingState, ThemeToggle, PageEnter (staggered page entrance), RichEditorRoot/RichTextView (Quill). Dev-only `/ui-kit` showcase route.
**Building / editing pages**
- Pages import from the **kit** (`src/admin/ui/`); reach for `@mui/material` (`styled`/`sx`) directly only for one-off layout or infra (theme, GlobalStyles, the Quill/Leaflet wrappers).
- Wrap a page's top-level sections in `<PageEnter>`. Filters go in `<FilterBar>` with **bare** controls (no `<Field>` label) at the standard widths.
- When refactoring, **preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation.
**Conventions learned — don't regress**
- **Status row tints** = subtle channel-alpha washes (`rgba(var(--mui-palette-X-mainChannel) / 0.12)`), NEVER a solid `.light` fill: `.light` is a light colour in BOTH schemes, so white dark-mode text on it is invisible. Voided/disabled rows = `opacity` + muted text.
- **Icon badges** = solid `X.main` tile + **white** glyph (not an `X.light` tile + `X.main` glyph — that's low-contrast).
- **Theme is single-source:** `src/context/ThemeContext.tsx` owns the `<html data-theme>` attribute + the View-Transitions cross-fade, and persists under MUI's **`mui-mode`** localStorage key (the same key MUI reads on mount). Do NOT add a second theme key — that desyncs page vs toggle on refresh.
- **Dialogs** lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroller); Modal/ConfirmDialog freeze title/label/loading through the close fade so nothing flashes. Login renders OUTSIDE AppShell.
**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`, `npm run lint`. The solution `tsconfig.json` now references `tsconfig.test.json` too, so `tsc -b` **type-checks the test files** (previously skipped). ESLint (flat config in `eslint.config.mjs`) enforces `react-hooks/rules-of-hooks` as an **error** — that rule catches the "hook after an early `return`" bug class; keep `npm run lint` at zero errors (warnings are advisory). Prettier config is `.prettierrc.json` (`npm run format`).
---
## AI Assistant — "Odin" (shipped v2.1.5)
Admins-only Claude assistant on the `/odin` page (sidebar nav item "Odin"). **Phase-1 scope is invoice import ONLY** — general chat is guarded off in `OdinChat.submit` to avoid spending API credits: a text-only message gets a canned reply and makes NO AI call. Removing that one guard re-enables the `/chat` path for a later "general assistant" phase.
- **SDK / model:** `@anthropic-ai/sdk``claude-sonnet-4-6`. Server-side only — `ANTHROPIC_API_KEY` in `.env` (already set on prod; **don't touch env, the user manages it**). The whole feature self-hides when the key is absent (`/ai/usage` returns `configured:false`).
- **Backend:** `src/services/ai.service.ts` (chat, `extractInvoice` [PDF→structured-output JSON], cost/budget tracking, conversation CRUD with server-side auto-title), `src/routes/admin/ai.ts` (`/api/admin/ai/*`: usage, budget, chat, extract-invoices, conversations[/:id/messages]), `src/schemas/ai.schema.ts`. Every route `requirePermission("ai.use")` (granted to **admin only** via migration).
- **Data:** `ai_usage` (per-call token-cost ledger), `ai_conversations` + `ai_chat_messages` (per-user, FK cascade, auto-title from first message), `company_settings.ai_monthly_budget_usd` (default $50; `assertBudgetAvailable` → 402 at the cap).
- **Frontend:** `src/admin/pages/Odin.tsx``src/admin/components/odin/`: `OdinChat` (orchestrator + state + the proven submit/extract/save logic), `OdinSidebar` (conversation list; inline ≥md, slide-in Drawer below md), `OdinThread` (messages, framer-motion entrance, Newsreader serif greeting hero), `OdinComposer` (claude-style rounded multiline pill, attach/send icons), `InvoiceReviewCard`, `OdinMark` (animated brand mark — CSS keyframes via `styled`, not inline style; opacity-glow fallback under reduced-motion), `types.ts`. Queries in `src/admin/lib/queries/ai.ts`. `Fraunces``Newsreader` font added to `index.html`.
- **Invoice flow:** attach PDF(s) → `extract-invoices` → editable review cards → save to the EXISTING `POST /received-invoices`. **`received_invoices.amount` is GROSS (VAT-inclusive)** — VAT is back-calculated via `vatFromGross` in `received-invoices.ts` (`amount * rate/(100+rate)`), used by both the manual form and the AI import. The save button is gated on `invoices.create`.
- **Phase 2 (general assistant — NOT built):** forward-looking design notes in `docs/superpowers/specs/2026-06-08-odin-phase2-general-assistant-notes.md` (tool-use over services, structured message-block storage, per-action authorization). Phase-1 spec/plan also under `docs/superpowers/`.
---
## Database Conventions
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
- Soft-delete via `is_deleted` boolean (not all tables, check schema).
- Timestamps: `created_at`, `updated_at` (auto-managed by Prisma).
- Number sequences (`number_sequences` table) manage invoice/quotation numbering — never hardcode numbering logic.
- All significant tables have audit log entries. Check `audit_logs` model for the schema.
---
## Database Migrations (Critical Workflow)
**Golden rule: NEVER use `prisma db push`.** It bypasses migration history and causes drift
between the schema and migration tracking. Always use `prisma migrate dev` for every schema change.
**Every database change or manipulation must be a tracked Prisma migration.** This includes:
- Schema changes (tables, columns, indexes, constraints) — via `prisma migrate dev`
- Permission/role/seed data changes — via a migration that does the INSERTs/DELETEs explicitly
- Lookup data, default settings, or any other row-level baseline that production needs
- Backfills, data fixes, and one-shot corrections that should be in sync across environments
**What is NOT acceptable:**
- Running raw SQL against production (`mysql -e "..."`, `npx prisma db execute`, `pm2 exec`)
- `npx prisma db seed` as the only way to populate data (seed is dev-only convenience; prod must run the same SQL via a migration)
- "I'll fix it in the seed file" or "I'll add a row manually" without a migration to back it
The reason: every change to the production database must be reviewable, reversible, and reproducible from a fresh checkout. External SQL commands and seed-only changes cause drift — prod and dev diverge, rollback gets harder with every manual change, and the next deploy surprises us.
If you need to insert/update/seed data on production, write a migration. The migration's `migration.sql` is a normal SQL file — `INSERT INTO ...`, `UPDATE ...`, `DELETE ...` are all valid. Prisma will run it via `prisma migrate deploy` like any other migration.
### Making schema changes
```
1. Edit prisma/schema.prisma
2. npx prisma migrate dev --name descriptive_name
→ This creates a migration in prisma/migrations/ AND applies it to dev DB
3. npx prisma generate
4. Commit BOTH schema.prisma AND the new migration folder
git add prisma/schema.prisma prisma/migrations/
```
### Verifying before production deploy
```bash
# Preview what SQL will run on production (no changes applied)
npx prisma migrate diff \
--from-url "mysql://user:pass@prod:3306/app" \
--to-schema-datamodel prisma/schema.prisma \
--script
# Empty output = no diff. If it shows SQL, review it before deploying.
```
### Deploying migrations to production
The release process runs `prisma migrate deploy` on production.
This applies only pending migrations — safe, idempotent.
### If migrations get out of sync (drift)
```bash
# Diff production DB against local schema to find drift
npx prisma migrate diff \
--from-url "mysql://prod" \
--to-schema-datamodel prisma/schema.prisma \
--script
# If drift is safe (CREATE only, no DROPs): apply with db push ONCE, then baseline
# If drift includes DROPs: investigate before touching production
```
### Baselinining a database that has no migrations
If production was synced with `db push` and has no `_prisma_migrations` table:
```bash
# 1. Create initial migration locally
npx prisma migrate dev --name init
# 2. Copy to production and mark as applied (no SQL runs)
scp -r prisma/migrations user@prod:/var/www/app-ts/prisma/
ssh user@prod
cd /var/www/app-ts
npx prisma migrate resolve --applied <migration_name>
```
---
## Conventions (enforced — verified by the 2026-06-06 audit)
These are the unified rules across the codebase. Follow them for new code; the
audit found and fixed the deviations. Full report: `docs/codebase-audit-2026-06-06.md`.
**Routes**
- **Responses:** always `success(reply, data[, status, message])` / `error(reply, msg, status)` / the paginated helper. Never raw `reply.send({ success: true, ... })`. (Some legacy files still do — convert when you touch them.)
- **Route ids:** parse numeric params with `parseId((request.params as any).id, reply)` then `if (id === null) return;`. Do not use raw `parseInt` (it yields `NaN`, not a 400).
- **Bodies:** validate every body with `parseBody(Schema, request.body)``if ("error" in body) return error(reply, body.error, 400)`.
- **Permissions:** guard with `requirePermission` / `requireAnyPermission`. The admin shortcut is `authData.roleName === "admin"`. ⚠️ `AuthData` has **`roleName`**, not `role` (`role` only exists on `JwtPayload`). Don't type `authData` as `any` — it hides exactly this bug.
- **Audit:** call `logAudit` on every create/update/delete (and on security-relevant actions like session/token termination) with `oldValues`/`newValues`.
**Services**
- Plain exported async functions; return `{ data }` or `{ error, status }` (preferred over discriminated unions). Services own Prisma; routes stay thin.
- Respect soft-delete (`is_deleted: false`) in reads where the model has it.
**Schemas (Zod 4)**
- Use Zod 4 idioms: `z.strictObject({...})` / `z.looseObject({...})` (not deprecated `.strict()` / `.passthrough()`).
- Shared coercion helpers (number-from-form, nullable-number, boolean-from-form) belong in `src/schemas/common.ts` — don't copy-paste the `z.union([z.number(), z.string()]).transform(Number)` idiom (it's duplicated ~150× today; consolidating is a tracked follow-up). New number coercions should guard against `NaN`.
- User-facing messages in **Czech**; identifiers/keys in English.
**Frontend**
- **Query invalidation:** invalidate the **broad domain key** (`["offers"]`, not `["offers","list"]`). Prefix-matching covers sub-queries.
- **Single source of truth for shared maps:** audit `entity_type` → Czech label lives in `src/admin/lib/entityTypeLabels.ts` and MUST be keyed to the server's `EntityType` values (add the new key there when you add an entity type). Plan categories come from the DB via `lib/queries/plan.ts`. Don't duplicate label maps across files or across server/client.
- Prefer deriving state over `useEffect`; use React Query for fetching, not effects.
**Dates (two deliberate regimes — don't "fix" either)**
- **Plan module** does all date-only math in **UTC** (`setUTCDate`, `toISOString().slice(0,10)`) because its columns are `@db.Date` (UTC-midnight). Correct and stable.
- **Attendance/leave** writes `@db.Date` at **local noon** (`new Date(y, m, d, 12, 0, 0)`).
- **Frontend "today" / date-string round-trips:** use `utils/date.ts` `localDateStr` (server) / `normalizeDateStr` (client). **Never** use `new Date().toISOString().split("T")[0]` for "today" — it's the UTC date and is a day early during the late-evening Prague window.
## Conventions (enforced — added by the 2026-06-09 full audit)
The 2026-06-09 file-by-file audit traced most bugs to a handful of patterns. These are now rules — `npm run lint` + `tsc -b` (which now type-checks tests) catch some automatically.
**Zod validation — shared coercion helpers are MANDATORY**
- All numeric/boolean/date/email form fields MUST use the helpers in **`src/schemas/common.ts`**: `numberFromForm`, `numberInRange(min,max)`, `nonNegativeNumberFromForm`, `positiveNumberFromForm`, `intIdFromForm`, `nullableNumberFromForm`/`nullableIntIdFromForm`, `booleanFromForm`, `isoDateString`, `timeString`, `emailOrEmpty`.
- **NEVER** the raw `z.union([z.number(), z.string()]).transform(Number)` idiom — it silently yields `NaN` that flows into Prisma/business math (this was the single biggest bug class). FK ids → `intIdFromForm`/`nullableIntIdFromForm`; quantities/prices → `nonNegative`/`positive`; bounded values (VAT `0100`, month `112`) → `numberInRange`.
- `isoDateString`/`timeString` are **lenient** (they strip a trailing time/seconds component) because an edit form re-submits a `@db.Date` that the `toJSON` override serialised as `"YYYY-MM-DDT00:00:00"`. Use them for date/time fields, not a bare regex.
- An optional email a form may submit as `""`**`emailOrEmpty.nullish()`** (a bare `.email()` 400s the whole form, blocking unrelated saves).
**Deterministic ordering — always tiebreak a timestamp sort with `id`**
- `created_at`/`received_at` are **second-precision** (`@db.Timestamp(0)`/`DateTime(0)`), so `orderBy: { created_at: "desc" }` alone is non-deterministic for same-second rows. ALWAYS add `{ id: "desc" }` (or `asc`) as the secondary sort. (Bit `plan.service.resolveCell`/`resolveGrid` and warehouse FIFO `selectFifoBatches`.)
**Concurrency — locking discipline for stock / balance / sequence mutations**
- Any service op that read-modify-writes shared rows (stock, batches, reservations, balances, number sequences) MUST: run inside a `prisma.$transaction`; take the row lock with `SELECT … FOR UPDATE` via **`tx.$queryRaw`** (NOT `$executeRaw` — it does not reliably hold the lock); and lock rows in one global **ascending-id order** (parent → items → batches) so concurrent paths can't deadlock. See `warehouse.service.ts` `lockParentRow`/`lockRowsForUpdate` and `attendance.service.ts` `lockUserRow`.
- **Uniqueness checks** (username/email/document number) go INSIDE the create transaction (re-check immediately before insert) and catch `P2002` → 409. A pre-transaction check is a TOCTOU race that surfaces as a 500.
**Permissions — guard reads, not just writes**
- GET list/detail endpoints need a `requirePermission`/`requireAnyPermission` guard, NOT bare `requireAuth` (bare-auth reads leak data to any logged-in user). Role-management writes are admin-only and re-checked (no privilege escalation; no creating/rewriting the `admin` role).
**Frontend — Rules of Hooks (lint-enforced) + no UTC-today**
- ALL hooks (`useState`, `useApiMutation`, `useMemo`, …) run BEFORE any early `return` (the `<Forbidden/>`/`<Navigate/>` permission guard goes AFTER every hook). `eslint.config.mjs` sets `react-hooks/rules-of-hooks` to **error**`npm run lint` must stay at 0 errors. This was a systemic crash bug across ~14 pages.
- Mutations invalidate the **broad domain key**; a mutation that writes via raw `apiFetch` must still `invalidateQueries` the domain it touched (several dashboard widgets were missing this).
**Safety**
- `prisma/seed.ts` **refuses to run when `APP_ENV=production`** (it wipes/reseeds permissions). Never seed prod.
- Every catch logs (never silently swallow) — the NAS managers were the worst offenders.
---
## Known Issues & Gotchas
1. **Date.prototype.toJSON override** — global monkey-patch in `src/config/env.ts`. Side-effects on third-party libraries that serialize dates. Do not remove without migrating all date serialization.
2. **CJS/ESM mismatch in tests** — Server compiles to CommonJS (`tsconfig.server.json`), but Vitest runs in ESM by default. The `vitest.config.ts` resolves this, but be careful when adding dependencies that only support ESM.
3. **Mixed error patterns** — Some services return `{ error, status }`, others return discriminated unions `{ type: 'success' | 'error' }`. Prefer `{ error, status }` for consistency with existing routes.
4. **Never swallow errors** — log at minimum; never use empty `catch` blocks. The service layer logs via `console.*` (there is no request-scoped pino logger in services). The NAS managers' previously-silent filesystem catches are now logged. One exception: a `catch` that handles an _expected_ condition (e.g. `ENOENT` used as an existence check) may stay silent **only with a comment** saying so.
5. **HTML sanitization is in place — keep applying it** — Rich-text fields (invoice notes, quotation scope, order notes) ARE sanitized: server-side DOMPurify (jsdom) plus a `cleanQuillHtml` regex pass at all three PDF routes, and the frontend sanitizes before every `dangerouslySetInnerHTML` (InvoiceDetail, OrderDetail). (The old "sanitization gap" note was stale — verified by the 2026-06-06 audit.) When you add ANY new rich-text/HTML field, apply the same sanitization on BOTH the PDF path and the render path.
6. **Puppeteer PDF generation** — Runs a headless browser. Input to the HTML template must be sanitized. Do not pass unsanitized user data into PDF templates.
7. **NAS_PATH file access** — Project file uploads write to a network share path. In dev, this path may not be mounted. Features using `NAS_PATH` will fail gracefully (or not) if the path is unavailable.
8. **Prisma client regeneration** — After any schema change and migration, run `npx prisma generate`. The generated client is not committed to git.
9. **No CSRF tokens** — CSRF protection relies on `SameSite=Strict` cookies + CORS. Do not weaken CORS configuration.
10. **Czech locale hardcoded** — Error messages, month names, and some business logic strings are Czech. This is intentional.
11. **Seed file is dev-only**`prisma/seed.ts` is for local dev convenience. It is **not** the production data source. Any data the production database must have (permissions, default roles, baseline rows) belongs in a migration's `migration.sql`, not in the seed file. `npx prisma db seed` must never be run against production.
---
## Release Process
1. Bump version in `package.json`
2. `npm run build`
3. Commit and tag (`git tag -a vX.Y.Z`)
4. Push to Gitea (`git push origin master && git push origin vX.Y.Z`)
5. Create tarball: `tar -czf app-ts-X.Y.Z.tar.gz dist dist-client prisma prisma.config.ts package.json package-lock.json scripts`
(⚠️ `prisma.config.ts` is REQUIRED — Prisma 7 keeps the datasource URL there;
without it, `prisma generate`/`migrate deploy` on prod have no datasource)
6. Deploy via SSH to production server (`boha_admin@192.168.50.100`):
- Path: `/var/www/app-ts`
- Remove old files: `rm -rf dist dist-client prisma prisma.config.ts scripts package.json package-lock.json`
- Copy tarball to server: `scp app-ts-X.Y.Z.tar.gz boha_admin@192.168.50.100:/tmp/`
- Extract tarball: `tar -xzf /tmp/app-ts-X.Y.Z.tar.gz`
- Install dependencies: `npm install --omit=dev`
- Regenerate the Prisma client: `npx prisma generate`**MANDATORY**.
`npm install` skips regeneration when dependencies didn't change, leaving a
stale client that still selects dropped/renamed columns → P2022 500s in
prod (bit the v2.4.0 supplier release).
- Apply Prisma migrations: `npx prisma migrate deploy`
- Restart: `pm2 restart app-ts --update-env`
### Hotfixing a migration that was added after the tarball was built
If you write a new `prisma/migrations/<timestamp>_*` folder **after** the
release tarball has already been built and shipped, that migration is
NOT in the tarball and `prisma migrate deploy` on prod will report
"No pending migrations". The release tarball re-build re-runs the whole
release, but for a one-off hotfix you can ship just the new migration
folder:
```bash
# Local
cd prisma/migrations
tar -czf /tmp/warehouse_perms_migration.tar.gz <timestamp>_<name>/
scp /tmp/warehouse_perms_migration.tar.gz boha_admin@192.168.50.100:/tmp/
# On prod
ssh boha_admin@192.168.50.100
cd /var/www/app-ts/prisma/migrations
sudo -u boha_admin tar -xzf /tmp/warehouse_perms_migration.tar.gz
cd /var/www/app-ts
npx prisma migrate deploy
pm2 restart app-ts --update-env
```
The migration is still tracked in git and applied via `prisma migrate
deploy` — the only thing the tarball is doing is delivering the
`migration.sql` to prod, which is the same mechanism the main release
uses. This is preferred over running raw SQL on prod (which the policy
in "Database Migrations" forbids).
Do not push directly to production or restart services without confirmation.

73
README.md Normal file
View File

@@ -0,0 +1,73 @@
# boha-app-ts
Internal business-management system for a Czech company (attendance, invoicing,
leave/trips, projects, vehicles, warehouse, HR) — a TypeScript/Node.js rewrite
of the legacy PHP app. User-facing text is Czech by design.
> For full architecture, conventions, and gotchas, see **[CLAUDE.md](./CLAUDE.md)**.
## Stack
- **Backend:** Fastify 5 · Prisma 6 → MySQL · Zod 4 · JWT (HS256) + TOTP 2FA
- **Frontend:** React 18 · Vite · Material UI v7 (Emotion) · TanStack Query — an
SPA in `src/admin/`, served by the same Fastify process (Vite middleware in
dev, static files in prod)
- **Other:** Puppeteer (PDF) · nodemailer · node-cron · `@anthropic-ai/sdk` (the
admins-only "Odin" assistant)
## Prerequisites
- Node.js (LTS) and a MySQL database
- Copy `.env.example``.env` and fill in the **required** vars (`DATABASE_URL`,
`JWT_SECRET`, `TOTP_ENCRYPTION_KEY` — generate the keys with `openssl rand -hex 32`)
## Setup
```bash
npm install
npx prisma generate
npx prisma migrate dev # apply migrations to your dev DB
npx prisma db seed # optional: dev-only sample data (NEVER on prod)
```
## Develop
```bash
npm run dev:server # API (tsx watch) on http://127.0.0.1:3050
npm run dev:client # Vite dev server (manage separately)
```
## Build & run
```bash
npm run build # tsc server → dist/ + vite client → dist-client/
npm start # node dist/server.js
```
## Test
```bash
npm test # vitest run — hits a real test DB (.env.test); no Prisma mocks
```
## Quality gates
Before committing, all of these must pass:
```bash
npx tsc -b --noEmit # typecheck (NOT `tsc -p tsconfig.json`)
npm run build
npx vitest run
npm run lint # ESLint (incl. react-hooks) — see eslint.config.js
```
## Database migrations
Every schema/data change is a tracked Prisma migration — **never** `prisma db push`
or raw SQL on production. Stop the dev server before running `prisma migrate dev`.
See CLAUDE.md → _Database Migrations_ for the full workflow and the production
deploy/hotfix process.
## License
ISC

386
REVIEW.md Normal file
View File

@@ -0,0 +1,386 @@
# Codebase Audit — REVIEW.md
**Date:** 2026-06-08
**Scope:** entire repository (tracked source/config files via `git ls-files`)
**Method:** file-by-file review. Source of truth for progress — re-read before continuing after any compaction.
**Total files to review:** 277
---
## .claude/hooks/
- [x] .claude/hooks/block-env.js
- [x] .claude/hooks/format-on-save.js
## .claude/
- [x] .claude/settings.json
- [x] .claude/settings.local.json
## ./
- [x] .env.example
- [x] CLAUDE.md
- [x] boneyard.config.json
- [x] index.html
- [x] package.json
## prisma/
- [x] prisma/schema.prisma
- [x] prisma/seed.ts
## scripts/
- [x] scripts/migrate-received-invoices-to-nas.ts
- [x] scripts/rotate-totp-key.ts
## src/
- [x] src/App.tsx
## src/__tests__/
- [x] src/__tests__/ai.test.ts
- [x] src/__tests__/auth.service.test.ts
- [x] src/__tests__/auth.test.ts
- [x] src/__tests__/customers.schema.test.ts
- [x] src/__tests__/env.test.ts
- [x] src/__tests__/exchange-rates.test.ts
- [x] src/__tests__/helpers.ts
- [x] src/__tests__/invoices.service.test.ts
- [x] src/__tests__/manual-create.test.ts
- [x] src/__tests__/nas-file-manager.test.ts
- [x] src/__tests__/nas-project-folder.test.ts
- [x] src/__tests__/numbering.test.ts
- [x] src/__tests__/plan.test.ts
- [x] src/__tests__/planAuditDescription.test.ts
- [x] src/__tests__/planCategory.test.ts
- [x] src/__tests__/received-invoices-vat.test.ts
- [x] src/__tests__/schema-nan.test.ts
- [x] src/__tests__/setup.ts
- [x] src/__tests__/warehouse.test.ts
## src/admin/
- [x] src/admin/AdminApp.tsx
- [x] src/admin/GlobalStyles.tsx
## src/admin/components/
- [x] src/admin/components/AlertContainer.tsx
- [x] src/admin/components/AttendanceShiftTable.tsx
- [x] src/admin/components/BulkAttendanceModal.tsx
- [x] src/admin/components/BulkPlanModal.tsx
- [x] src/admin/components/ErrorBoundary.tsx
- [x] src/admin/components/Forbidden.tsx
- [x] src/admin/components/OrderConfirmationModal.tsx
- [x] src/admin/components/PlanCategoriesModal.tsx
- [x] src/admin/components/PlanCellModal.tsx
- [x] src/admin/components/PlanGrid.tsx
- [x] src/admin/components/PlanRangeChips.tsx
- [x] src/admin/components/ProjectFileManager.tsx
- [x] src/admin/components/RichEditor.tsx
- [x] src/admin/components/ShiftFormModal.tsx
- [x] src/admin/components/ShortcutsHelp.tsx
## src/admin/components/dashboard/
- [x] src/admin/components/dashboard/DashActivityFeed.tsx
- [x] src/admin/components/dashboard/DashAttendanceToday.tsx
- [x] src/admin/components/dashboard/DashKpiCards.tsx
- [x] src/admin/components/dashboard/DashProfile.tsx
- [x] src/admin/components/dashboard/DashQuickActions.tsx
- [x] src/admin/components/dashboard/DashSessions.tsx
- [x] src/admin/components/dashboard/DashTodayPlan.tsx
## src/admin/components/odin/
- [x] src/admin/components/odin/InvoiceReviewCard.tsx
- [x] src/admin/components/odin/OdinChat.tsx
- [x] src/admin/components/odin/OdinComposer.tsx
- [x] src/admin/components/odin/OdinMark.tsx
- [x] src/admin/components/odin/OdinSidebar.tsx
- [x] src/admin/components/odin/OdinThread.tsx
- [x] src/admin/components/odin/types.ts
## src/admin/components/warehouse/
- [x] src/admin/components/warehouse/ItemPicker.tsx
- [x] src/admin/components/warehouse/ReservationPicker.tsx
## src/admin/context/
- [x] src/admin/context/AlertContext.tsx
- [x] src/admin/context/AuthContext.tsx
## src/admin/hooks/
- [x] src/admin/hooks/useAttendanceAdmin.ts
- [x] src/admin/hooks/useDebounce.ts
- [x] src/admin/hooks/useModalLock.ts
- [x] src/admin/hooks/usePaginatedQuery.ts
- [x] src/admin/hooks/usePlanWork.ts
- [x] src/admin/hooks/useReducedMotion.ts
- [x] src/admin/hooks/useTableSort.ts
## src/admin/lib/
- [x] src/admin/lib/apiAdapter.ts
- [x] src/admin/lib/entityTypeLabels.ts
## src/admin/lib/queries/
- [x] src/admin/lib/queries/ai.ts
- [x] src/admin/lib/queries/attendance.ts
- [x] src/admin/lib/queries/auditLog.ts
- [x] src/admin/lib/queries/common.ts
- [x] src/admin/lib/queries/dashboard.ts
- [x] src/admin/lib/queries/invoices.ts
- [x] src/admin/lib/queries/leave.ts
- [x] src/admin/lib/queries/mutations.ts
- [x] src/admin/lib/queries/offers.ts
- [x] src/admin/lib/queries/orders.ts
- [x] src/admin/lib/queries/plan.ts
- [x] src/admin/lib/queries/projects.ts
- [x] src/admin/lib/queries/settings.ts
- [x] src/admin/lib/queries/trips.ts
- [x] src/admin/lib/queries/users.ts
- [x] src/admin/lib/queries/vehicles.ts
- [x] src/admin/lib/queries/warehouse.ts
## src/admin/lib/
- [x] src/admin/lib/queryClient.ts
## src/admin/pages/
- [x] src/admin/pages/Attendance.tsx
- [x] src/admin/pages/AttendanceAdmin.tsx
- [x] src/admin/pages/AttendanceBalances.tsx
- [x] src/admin/pages/AttendanceCreate.tsx
- [x] src/admin/pages/AttendanceHistory.tsx
- [x] src/admin/pages/AttendanceLocation.tsx
- [x] src/admin/pages/AuditLog.tsx
- [x] src/admin/pages/CompanySettings.tsx
- [x] src/admin/pages/Dashboard.tsx
- [x] src/admin/pages/InvoiceDetail.tsx
- [x] src/admin/pages/Invoices.tsx
- [x] src/admin/pages/LeaveApproval.tsx
- [x] src/admin/pages/LeaveRequests.tsx
- [x] src/admin/pages/Login.tsx
- [x] src/admin/pages/NotFound.tsx
- [x] src/admin/pages/Odin.tsx
- [x] src/admin/pages/OfferDetail.tsx
- [x] src/admin/pages/Offers.tsx
- [x] src/admin/pages/OffersCustomers.tsx
- [x] src/admin/pages/OffersTemplates.tsx
- [x] src/admin/pages/OrderDetail.tsx
- [x] src/admin/pages/Orders.tsx
- [x] src/admin/pages/PlanWork.tsx
- [x] src/admin/pages/ProjectDetail.tsx
- [x] src/admin/pages/Projects.tsx
- [x] src/admin/pages/ReceivedInvoices.tsx
- [x] src/admin/pages/Settings.tsx
- [x] src/admin/pages/Trips.tsx
- [x] src/admin/pages/TripsAdmin.tsx
- [x] src/admin/pages/TripsHistory.tsx
- [x] src/admin/pages/UiKit.tsx
- [x] src/admin/pages/Users.tsx
- [x] src/admin/pages/Vehicles.tsx
- [x] src/admin/pages/Warehouse.tsx
- [x] src/admin/pages/WarehouseCategories.tsx
- [x] src/admin/pages/WarehouseInventory.tsx
- [x] src/admin/pages/WarehouseInventoryDetail.tsx
- [x] src/admin/pages/WarehouseInventoryForm.tsx
- [x] src/admin/pages/WarehouseIssueDetail.tsx
- [x] src/admin/pages/WarehouseIssueForm.tsx
- [x] src/admin/pages/WarehouseIssues.tsx
- [x] src/admin/pages/WarehouseItemDetail.tsx
- [x] src/admin/pages/WarehouseItems.tsx
- [x] src/admin/pages/WarehouseLocations.tsx
- [x] src/admin/pages/WarehouseReceiptDetail.tsx
- [x] src/admin/pages/WarehouseReceiptForm.tsx
- [x] src/admin/pages/WarehouseReceipts.tsx
- [x] src/admin/pages/WarehouseReports.tsx
- [x] src/admin/pages/WarehouseReservations.tsx
- [x] src/admin/pages/WarehouseSuppliers.tsx
## src/admin/
- [x] src/admin/theme.test.ts
- [x] src/admin/theme.ts
## src/admin/ui/
- [x] src/admin/ui/Alert.tsx
- [x] src/admin/ui/AppShell.tsx
- [x] src/admin/ui/Button.tsx
- [x] src/admin/ui/Card.tsx
- [x] src/admin/ui/Checkbox.tsx
- [x] src/admin/ui/ConfirmDialog.tsx
- [x] src/admin/ui/DataTable.tsx
- [x] src/admin/ui/DateField.tsx
- [x] src/admin/ui/EmptyState.tsx
- [x] src/admin/ui/Field.tsx
- [x] src/admin/ui/FileUpload.tsx
- [x] src/admin/ui/FilterBar.tsx
- [x] src/admin/ui/LoadingState.tsx
- [x] src/admin/ui/Modal.tsx
- [x] src/admin/ui/MonthField.tsx
- [x] src/admin/ui/MuiProvider.tsx
- [x] src/admin/ui/PageEnter.tsx
- [x] src/admin/ui/PageHeader.tsx
- [x] src/admin/ui/Pagination.tsx
- [x] src/admin/ui/ProgressBar.tsx
- [x] src/admin/ui/RichText.tsx
- [x] src/admin/ui/Select.tsx
- [x] src/admin/ui/SidebarNav.tsx
- [x] src/admin/ui/StatCard.tsx
- [x] src/admin/ui/StatusChip.tsx
- [x] src/admin/ui/Tabs.tsx
- [x] src/admin/ui/TextField.tsx
- [x] src/admin/ui/ThemeToggle.tsx
- [x] src/admin/ui/TimeField.tsx
- [x] src/admin/ui/index.ts
- [x] src/admin/ui/navData.tsx
- [x] src/admin/ui/useDialogScrollLock.ts
## src/admin/utils/
- [x] src/admin/utils/api.ts
- [x] src/admin/utils/attendanceHelpers.ts
- [x] src/admin/utils/dashboardHelpers.ts
- [x] src/admin/utils/formatters.ts
## src/config/
- [x] src/config/database.ts
- [x] src/config/env.ts
## src/context/
- [x] src/context/ThemeContext.tsx
## src/
- [x] src/main.tsx
## src/middleware/
- [x] src/middleware/auth.ts
- [x] src/middleware/security.ts
## src/routes/admin/
- [x] src/routes/admin/ai.ts
- [x] src/routes/admin/attendance.ts
- [x] src/routes/admin/audit-log.ts
- [x] src/routes/admin/auth.ts
- [x] src/routes/admin/bank-accounts.ts
- [x] src/routes/admin/company-settings.ts
- [x] src/routes/admin/customers.ts
- [x] src/routes/admin/dashboard.ts
- [x] src/routes/admin/invoices-pdf.ts
- [x] src/routes/admin/invoices.ts
- [x] src/routes/admin/leave-requests.ts
- [x] src/routes/admin/offers-pdf.ts
- [x] src/routes/admin/orders-pdf.ts
- [x] src/routes/admin/orders.ts
- [x] src/routes/admin/plan.ts
- [x] src/routes/admin/profile.ts
- [x] src/routes/admin/project-files.ts
- [x] src/routes/admin/projects.ts
- [x] src/routes/admin/quotations.ts
- [x] src/routes/admin/received-invoices.ts
- [x] src/routes/admin/roles.ts
- [x] src/routes/admin/scope-templates.ts
- [x] src/routes/admin/sessions.ts
- [x] src/routes/admin/totp.ts
- [x] src/routes/admin/trips.ts
- [x] src/routes/admin/users.ts
- [x] src/routes/admin/vehicles.ts
- [x] src/routes/admin/warehouse.ts
## src/schemas/
- [x] src/schemas/ai.schema.ts
- [x] src/schemas/attendance.schema.ts
- [x] src/schemas/auth.schema.ts
- [x] src/schemas/bank-accounts.schema.ts
- [x] src/schemas/common.ts
- [x] src/schemas/customers.schema.ts
- [x] src/schemas/invoices.schema.ts
- [x] src/schemas/leave-requests.schema.ts
- [x] src/schemas/offers.schema.ts
- [x] src/schemas/orders.schema.ts
- [x] src/schemas/plan.schema.ts
- [x] src/schemas/planCategory.schema.ts
- [x] src/schemas/profile.schema.ts
- [x] src/schemas/projects.schema.ts
- [x] src/schemas/received-invoices.schema.ts
- [x] src/schemas/roles.schema.ts
- [x] src/schemas/scope-templates.schema.ts
- [x] src/schemas/settings.schema.ts
- [x] src/schemas/trips.schema.ts
- [x] src/schemas/users.schema.ts
- [x] src/schemas/vehicles.schema.ts
- [x] src/schemas/warehouse.schema.ts
## src/
- [x] src/server.ts
## src/services/
- [x] src/services/ai.service.ts
- [x] src/services/attendance.service.ts
- [x] src/services/audit.ts
- [x] src/services/auth.ts
- [x] src/services/exchange-rates.ts
- [x] src/services/invoice-alerts.ts
- [x] src/services/invoices.service.ts
- [x] src/services/leave-notification.ts
- [x] src/services/mailer.ts
- [x] src/services/nas-file-manager.ts
- [x] src/services/nas-financials-manager.ts
- [x] src/services/nas-offers-manager.ts
- [x] src/services/numbering.service.ts
- [x] src/services/offers.service.ts
- [x] src/services/orders.service.ts
- [x] src/services/plan.service.ts
- [x] src/services/planCategory.service.ts
- [x] src/services/projects.service.ts
- [x] src/services/system-settings.ts
- [x] src/services/users.service.ts
- [x] src/services/warehouse.service.ts
## src/types/
- [x] src/types/fastify.d.ts
- [x] src/types/index.ts
## src/utils/
- [x] src/utils/czech-holidays.ts
- [x] src/utils/date.ts
- [x] src/utils/encryption.ts
- [x] src/utils/html-to-pdf.ts
- [x] src/utils/pagination.ts
- [x] src/utils/planAuditDescription.ts
- [x] src/utils/response.ts
- [x] src/utils/totp.ts
## src/
- [x] src/vite-env.d.ts
## ./
- [x] tsconfig.app.json
- [x] tsconfig.json
- [x] tsconfig.server.json
- [x] vite.config.ts
- [x] vitest.config.ts

1330
REVIEW_FINDINGS.md Normal file

File diff suppressed because it is too large Load Diff

1385
REVIEW_FIXES.md Normal file

File diff suppressed because it is too large Load Diff

37
boneyard.config.json Normal file
View File

@@ -0,0 +1,37 @@
{
"breakpoints": [375, 768, 1280],
"out": "./src/admin/bones",
"color": "#e0e0e0",
"animate": "shimmer",
"shimmerColor": "#f0f0f0",
"speed": "1.2s",
"shimmerAngle": 110,
"wait": 3000,
"routes": [
"/",
"/users",
"/attendance",
"/attendance/history",
"/attendance/admin",
"/attendance/balances",
"/attendance/requests",
"/attendance/approval",
"/attendance/create",
"/trips",
"/trips/history",
"/trips/admin",
"/vehicles",
"/offers",
"/offers/new",
"/offers/customers",
"/offers/templates",
"/orders",
"/orders/1",
"/projects",
"/projects/80",
"/invoices",
"/invoices/new",
"/settings",
"/audit-log"
]
}

View File

@@ -0,0 +1,90 @@
# Codebase Consistency & Best-Practices Audit — 2026-06-06 (overnight)
**Branch:** `chore/codebase-consistency-audit` (off `master` @ v1.9.0). Nothing here is pushed or deployed; for user review in the morning.
**Mandate (from user):** scan the whole codebase, find inconsistencies, unify manners/conventions, apply best practices (use context7 for library docs). Edit CLAUDE.md to codify rules. Work only in the project dir. Use many agents.
**Operating rules I'm following (self-imposed for safe autonomous work):**
- Isolated branch only. Never touch `master`, never push, never deploy, never touch prod/DB.
- Keep `tsc` (server+client) at 0 and `vitest` green after every change set.
- Prefer documenting risky/large refactors over auto-applying them. Auto-apply only safe, verified, mechanical unifications.
- Don't `git add -A`; stage explicit paths.
---
## 🔴 CRITICAL FINDINGS (need user action)
### C1 — v1.9.0 production regression: plan edit-modal delete button missing
- **Cause:** `FormModal`'s `footerLeft` / `hideCloseButton` props + modal animations lived only in the user's _uncommitted_ working tree. Committed `PlanCellModal.tsx:225` (and the modal system) depend on `footerLeft`. Releases build from committed code (WIP stashed), so v1.9.0 shipped a `FormModal` without the footer-left slot → the **delete button in the plan entry/override edit modal does not render in production**. Also caused a committed-code `tsc` error (TS2322).
- **Impact:** plan entry/override deletion via the edit modal is broken in prod v1.9.0. Category management is NOT affected (it uses `hideFooter` + in-body delete). Minor cosmetic: modal entry animation, `hideCloseButton`.
- **Fix:** committed the modal enhancements (`86e4cbf` on this branch). **Needs a patch release (v1.9.1) to fix prod.**
- **Status:** fixed on branch; prod patch pending user.
### C2 — test suite was red on master/v1.9.0 (2 failing auth tests)
- **Cause:** the JWT log-noise fix (`88c9b7c`) stopped logging expected invalid/expired tokens, but `auth.service.test.ts` still asserted it logged. The wrong auth test file (`auth.test.ts`) was run when that change landed, so 2 failing tests shipped on master.
- **Fix:** updated the tests to assert null + no-log for `JsonWebTokenError` cases (`55a2c50`). Suite back to **134/134**.
- **Status:** fixed on branch. (No runtime impact; CI/test hygiene + would also be cleared by the v1.9.1 patch.)
---
## Findings log
(Discovery sweep results appended below as agents report. Severity: critical/high/medium/low. Fix class: safe-auto / needs-judgment / manual.)
## Changes applied on this branch (all verified: server+client tsc 0, vitest 134, build 0)
Correctness / bugs:
- `86e4cbf` **C1** modal `footerLeft`/`hideCloseButton` enhancements committed (fixes v1.9.0 regression + tsc error).
- `55a2c50` **C2** fix 2 failing `verifyAccessToken` tests (red on master).
- `379725a` **H1/H4** `isAdminLike` reads `roleName` not `role` (admins were self-scoped on /entries,/overrides); dropped 4 stale `as any` category casts.
- `c5f986a` **H5** corrected + unified audit `entity_type` label map (was showing raw English for invoice/order/quotation/trip/vehicle + all warehouse\_\*).
- `1f05ef6` date: `todayLocalStr()` replaces 11 UTC-`today` defaults (off-by-one in post-midnight Prague).
- `744baec` security: audit-log session termination (single + all-others); new `session` EntityType.
Consistency / cleanup:
- `9ae49c0` parseId (trips/sessions), broad `["offers"]` invalidation, Czech messages (attendance/orders/offers/project-files), Zod 4 `z.strictObject`.
- `522afef` undefined `--danger-color``--danger`, dedup `formatDate` + `require2FAOptions`, removed dead `systemSettingsOptions`, dropped double `plan.css` import.
- `9c582e1` **H2/H3** log the 30 silent NAS filesystem catches (kept 3 ENOENT-expected as deliberate no-logs).
Docs:
- `94a9fb8` CLAUDE.md: codified "Conventions (enforced)" section; corrected stale known-issues #4 (NAS) and #5 (sanitization is in place, not a gap).
- `58bf034`/`0559664` this report + full 84-finding catalog (`codebase-audit-findings-2026-06-06.md`).
## Remaining recommendations (NOT auto-applied — for your review)
Deliberately left for review (too broad/behavior-risky to apply unattended, or low value):
- **`reply.send` → helpers** (medium): ~1/3 of route files use raw `reply.send({success:true,...})` instead of `success()/paginated()`. Mechanical but touches many files + response-shape sensitive — convert per-file when touched. (List in findings catalog, dimension `routes`.)
- **Zod coercion helpers** (medium): the number/nullable-number/boolean-from-form coercions are copy-pasted ~150×; extract to `src/schemas/common.ts` (with a `NaN` guard — current coercion silently yields `NaN`). Big mechanical sweep — do with review.
- **console.\* → request logger** (medium): services log via `console.*` (bypasses pino/log-level). Architectural; needs a service-logging strategy.
- **`markOverdueInvoices`** (medium): swallows the DB-update failure and returns void, so the route treats a failed update as success — surface the error. Also it compares a `@db.Date` due_date against full `new Date()` (flips "overdue" on the due day) — compare date-only.
- **Dead exports** (low): `previewPattern()` (numbering.service), `usersQuery`/`planKeys.users()` (lib/queries/plan) — unused API surface; remove or wire up.
- **`ShiftFormModal`** (low): inline styles use `--danger-color`/`--success-color`/etc. (undefined; rely on fallbacks) — switch to the `--danger`/`--success` design tokens.
- **`.env.example` drift** (low): out of sync with vars read in `config/env.ts` — regenerate.
- **`@/*` path alias** (low): declared in tsconfig but not wired into Vite/vitest — either wire it up or remove.
- **warehouse soft-delete test** (low): a test named "soft delete" asserts a hard delete its own comment flags as buggy — clarify intent.
- **TOTP verify** doesn't increment failed-login counter (relies on rate limit); **HSTS** lacks `preload` — security hardening, low.
Full detail + file:line for every item: `docs/codebase-audit-findings-2026-06-06.md`.
## Progress tracker
- [x] Branch + clean baseline (tsc server 0 / client 0; vitest 134).
- [x] C1 modal regression + C2 red tests found & fixed (134/134).
- [x] Discovery sweep (16 parallel dimensions, 84 findings).
- [x] Synthesis + prioritization (high + safe-auto).
- [x] Safe unifications applied (12 commits, each verified green).
- [x] CLAUDE.md updated with codified conventions.
- [x] Remaining items documented as a review roadmap.
- [x] Final verification.
## ⚠️ Action for you in the morning
- **v1.9.1 patch:** C1 (plan edit-modal delete button) + C2 (red tests) affect production v1.9.0. Merge this branch (or cherry-pick `86e4cbf`,`55a2c50`,`379725a`,`c5f986a`) and cut a patch.
- Review this branch (`chore/codebase-consistency-audit`, 12 commits) and merge what you like. Nothing here is pushed or deployed.

View File

@@ -0,0 +1,544 @@
# Codebase Audit — Full Findings Catalog (2026-06-06)
Generated from the 16-dimension discovery sweep. 84 findings (high 5, medium 31, low 48).
## Dimension summaries
**svc-errors** — test2
**routes** — Route handlers in src/routes/admin/* are largely consistent and follow the documented patterns well: nearly every route uses a requirePermission/requireAnyPermission/requireAuth guard, validates bodies via parseBody(ZodSchema, ...) with the uniform `if ("error" in parsed) return error(reply, parsed.error, 400)` shape, uses parseId for numeric route params in most files, and logs audit entries on create/update/delete with old/new values. The warehouse, customers, invoices, projects, roles, and auth/totp files are exemplary. The real issues are: (1) a genuine authorization/visibility bug in plan.ts where the admin check reads a non-existent field; (2) a recurring style violation where roughly a third of files send raw `reply.send({ success: true, ... })` instead of the success()/paginated() helpers that CLAUDE.md mandates; (3) two security-relevant session-termination mutations with no audit logging; and (4) inconsistent ID parsing (raw parseInt vs the parseId helper) in trips.ts and sessions.ts. HTTP status codes are generally correct (201 on create, 404 not-found, 409 conflict, 403 permission), with one cluster of not-found/validation responses in project-files.ts falling back to the default 400.
**schemas** — The 21 Zod schema files in src/schemas/* are consistently organized (one file per entity, Create/Update pairs, z.infer type exports, Czech user-facing messages — all intentional and good). Every route body is validated via the shared parseBody helper (src/schemas/common.ts) — coverage is complete; the handful of raw `request.body` reads (attendance.ts, orders.ts, projects.ts, project-files.ts) either re-dispatch to parseBody with branch-specific schemas or do narrow manual checks, so there is no unvalidated-body gap. The dominant real issue is massive duplication of three coercion idioms — a number-from-form `z.union([z.number(),z.string()]).transform(v=>Number(v))`, a nullable variant with `z.null()`, and a boolean-from-form `z.preprocess(v=>v===true||v===1||v==="1", z.boolean())` — copy-pasted ~150+ times across files instead of living as shared helpers in common.ts. Secondary issues: the number coercion is unsafe (no NaN guard in most places, so "abc" silently becomes NaN), email validation is applied inconsistently (only users/profile, not customers/warehouse/settings), error-message language is mixed (Czech vs English "Must be a valid number"), no schemas use strict object mode so unknown keys are silently stripped, and the two inline route schemas use Zod-3-deprecated `.strict()`/`.passthrough()` rather than Zod 4's `z.strictObject()`/`z.looseObject()`. Severity is mostly low/medium: these are maintainability and robustness gaps, not security holes (the app is on Zod 4.3.6, so the modern helpers are available).
**logging** — Overall the codebase handles errors deliberately: routes use Fastify's pino logger (request.log.*/app.log.*), the global error handler logs unhandled exceptions, audit failures are logged and treated as non-fatal, and a couple of catch blocks (auth.ts verifyAccessToken, attendance/leave duplicate-record handling) are intentionally selective with good comments. The two real systemic issues are: (1) the NAS file/financials managers (src/services/nas-file-manager.ts, src/services/nas-financials-manager.ts) contain ~25 empty/silent catch blocks that swallow genuine filesystem operation failures (delete/mkdir/write/rename) with zero logging, returning only a generic Czech string — directly violating the CLAUDE.md "never silently swallow errors" rule and making NAS issues undebuggable; and (2) all service-layer error logging uses console.* instead of the configured pino logger, so those messages bypass structured logging and the production log-level config. A secondary correctness smell: markOverdueInvoices swallows DB-update failures and returns void, so its route caller treats a failed update as success. Frontend error handling is consistent (alert.error with Czech fallback); a few best-effort .catch(()=>{}) swallows on background calls are defensible but undocumented.
**prisma** — Solid Prisma usage overall. Real issues in findings.
**security** — Auth and crypto fundamentals are solid: refresh tokens are SHA-256-hashed with rotation + reuse detection (replaced_at/replaced_by_hash + FOR UPDATE), TOTP has counter-based replay protection, password comparison is timing-safe (dummy bcrypt hash on user-not-found/locked/inactive), TOTP secrets are AES-256-GCM encrypted, account lockout + per-route rate limits exist, bcrypt cost is 12, and JWT verification pins HS256. Contrary to CLAUDE.md "known issue #5", HTML sanitization for invoice notes, quotation scope, and order notes is NOT missing: DOMPurify (jsdom) is applied at all three server-side PDF routes plus a regex-based cleanQuillHtml second pass, and both frontend render sites (InvoiceDetail, OrderDetail) sanitize before dangerouslySetInnerHTML. NAS file access has strong path-traversal defenses (rejects "..", null bytes, confines to base, rejects symlink components, MIME/extension allowlist). The real gaps are narrower: the TOTP-code verification step does not increment failed-login counters (relies only on a 5/min rate limit), HSTS lacks preload, and the server-side invoice/offer PDF endpoints serve attacker-influenced (but sanitized) HTML as same-origin text/html. No SQL injection found — all $queryRaw uses are parameterized tagged templates.
**dates** — Date handling splits into two well-reasoned, internally-consistent regimes plus a set of genuine off-by-one bugs at the boundary between them. (1) The plan module (src/services/plan.service.ts, src/admin/pages/PlanWork.tsx, usePlanWork.ts, PlanGrid.tsx) deliberately does ALL date-only arithmetic in UTC (setUTCDate/getUTCDate, toISOString().slice(0,10), proper ISO-8601 week calc). Because every column it touches is `@db.Date` (stored at UTC-midnight by Prisma), this is correct and stable — explicitly documented at plan.service.ts:63-67 and 342-352. Do not "fix" it. (2) The attendance/leave server code constructs `@db.Date` writes at LOCAL noon (new Date(y,m,d,12,0,0)), which is the robust convention. The real problems: a repeated frontend pattern `new Date().toISOString().split("T")[0]` for "today" defaults computes the UTC date, giving yesterday in the late-evening Prague window; the invoices/received-invoices/trips edit forms round-trip API date strings through `new Date(x).toISOString().split(...)` instead of the existing string-slice helper `normalizeDateStr`, risking a shift; trips' raw API datetime string is fed into a native <input type=date> (blank on mobile); markOverdueInvoices compares a `@db.Date` due_date against full `new Date()` so invoices flip to "overdue" on their due day; and getCzechDate uses a non-ISO local week formula that disagrees with PlanWork's ISO week. A correct local-date string helper (utils/date.ts localDateStr, and frontend normalizeDateStr) already exists but isn't used in the offending spots.
**ts-safety** — Both tsconfigs enable `strict: true` and there are zero `@ts-ignore`/`@ts-nocheck` directives, so the baseline is good. The real risk is concentrated, not diffuse: 77 `any` occurrences cluster in the work-plan stack (plan.service.ts, routes/admin/plan.ts, hooks/usePlanWork.ts) and a few PDF/HTML builders. The single most important finding is a latent authorization-scoping bug masked by an `any`-typed parameter in plan.ts (`isAdminLike` reads `authData.role`, but `AuthData` only has `roleName`), which makes admins always scope to their own plan rows. Systemically, ~47 of 107 exported service functions have no explicit return type, which forces `(result as any).status` casts in route handlers (projects.ts, quotations.ts) because the inferred error union isn't uniform. There is also a genuinely duplicated/contradictory `PaginationMeta` shape defined three times with diverging fields. Non-null assertions are mostly the defensible `request.authData!` post-auth pattern and are not a concern.
**rq-data** — The frontend data layer is built on a clean, idiomatic foundation: every read is a `queryOptions()` factory in `src/admin/lib/queries/*` (TanStack Query v5 best practice), data flows through a thin `apiAdapter.ts` (jsonQuery/paginatedJsonQuery) that unwraps the `{success,data}` envelope and throws on error, and a purpose-built `useApiMutation` hook (lib/queries/mutations.ts) wraps `useMutation` + apiFetch + broad invalidation exactly as the docs recommend. The factory pattern is consistently applied for queries and `useApiMutation` is adopted in 34 files. The real weakness is on the WRITE side: a large minority of mutations bypass `useApiMutation` and hand-roll `apiFetch` + `await response.json()` + manual `invalidateQueries` + `try/catch alert("Chyba připojení")` (14 files, ~27 sites), duplicating the exact logic the hook exists to centralize. Within that hand-rolled code the broad-domain invalidation convention from CLAUDE.md is violated in OfferDetail.tsx (uses narrow `["offers","list"]` while Offers.tsx uses broad `["offers"]`). useAttendanceAdmin.ts runs an entirely parallel data system (manual fetchData + setTimeout(300) instead of query factories). Query-key conventions themselves are sound; minor smells are a duplicated `require2FAOptions` factory and an unused deprecated alias. None of these are correctness bugs — they are consistency/maintainability gaps. Best-practice basis: TanStack Query v5 docs (/tanstack/query) confirm prefix-matching invalidation (validating the broad-key convention) and the useMutation→onSuccess→invalidateQueries pattern that useApiMutation implements.
**react** — The frontend is mostly modern and disciplined: data flows through TanStack Query via lib/queries/*, lazy-loading is consistent for every page in AdminApp.tsx, and there are two solid reusable modal primitives (FormModal, ConfirmModal) used in 34 files. The real issues are concentrated and concrete: (1) one large hook (useAttendanceAdmin.ts, ~830 lines) opts out of TanStack Query and hand-rolls fetching with useEffect/useState, complete with silent empty catches and no race-condition guard — the single biggest consistency/best-practice gap; (2) two editable, mutable lists use the array index as React key (OrderConfirmationModal items, OfferDetail scope sections), which the React docs flag as a correctness bug because rows are deleted/reordered while holding controlled-input state; (3) modal usage is inconsistent — alongside FormModal/ConfirmModal, ~7 components hand-roll the admin-modal-overlay markup with diverging accessibility (DashProfile/DashQuickActions omit role="dialog"/aria-modal entirely; none of the hand-rolled ones wire Esc-to-close that FormModal provides). Several "sync server data into form state via useEffect" effects exist (InvoiceDetail, OfferDetail, CompanySettings, Settings); these use a one-shot ref/flag guard and are an accepted-but-not-ideal pattern per the React "You Might Not Need an Effect" guidance, so they are reported as low severity. Per React docs I pulled: avoid adjusting/resetting state on prop change in an Effect (prefer key-reset or compute during render), and data-fetching effects need an ignore/abort cleanup to avoid stale overwrites.
**css** — The admin CSS is a single global stylesheet assembled from 19 per-feature files, all imported in AdminApp.tsx (so every selector shares one global namespace — the file split is purely organizational, not scoped). Theming is well-architected and consistent: a central variables.css drives a token system via [data-theme="dark"]/[data-theme="light"] (no prefers-color-scheme mixing), and the large majority of color usage correctly references CSS variables. Genuine issues are: (1) one undefined variable (--danger-color) used in base.css; (2) an inconsistent class-naming scheme — most files use either admin-* or a feature prefix (plan-*, dash-*, fm-*, attendance-*, offers-*), but warehouse uses admin-warehouse-*, invoices mixes admin-badge-*/invoice-*/received-*, and a block of leave badges in components.css drops the admin- prefix entirely; (3) heavy duplication of the badge color recipe (background: color-mix(... 15%) + matching text color) across leave/order/project/invoice/attendance badges, with two different recipes (--success-soft vs color-mix --success 15%) used for the same semantic color; and (4) several defined-but-unused tokens plus an empty responsive.css stub and a double-import of plan.css. Hardcoded hex outside variables.css is mostly legitimate (#fff text on accent backgrounds, feature-scoped local theme token blocks in plan.css/layout.css), not a real problem.
**naming** — Naming in this codebase is broadly consistent and mostly intentional. Backend `src/` is overwhelmingly kebab-case for files (94 kebab vs 5 camelCase), service functions follow a clean `list*` (collections) / `get*` (single) / `create|update|delete*` (mutations) scheme with no fetch/load mixing, route verbs map cleanly to fastify methods, and Czech appears only in user-facing string literals (a documented, intentional choice — not an identifier problem). The frontend leans on its own consistent conventions: `handle*` event handlers (148 vs 8 `on*`, where the `on*` cases are local DOM listeners — idiomatic), `is*/has*` for boolean props/fields, and `*Options` for TanStack query factories (55 of them). The real, genuine deviations are a small cluster of files added during the "plan" feature work that broke the surrounding kebab-case convention (`planCategory.schema.ts`, `planCategory.service.ts`, `planAuditDescription.ts` and their tests), and the `plan.ts` query module that uses `gridQuery`/`usersQuery` instead of the established `*Options` suffix. The `.service.ts` suffix is applied to 10 of 20 service files, but this tracks a defensible entity-CRUD-vs-infra split rather than randomness. These are low-to-medium consistency gaps, not correctness issues; most fixes are renames with cross-file import ripples, so few qualify as safe-auto.
**deadcode** — The codebase is generally well-factored (date helpers in src/utils/date.ts and frontend formatters are correctly centralized and reused). The real dead-code/duplication problems are concentrated in two areas: (1) the three PDF route files (invoices-pdf.ts, orders-pdf.ts, offers-pdf.ts) each redefine the same set of HTML/number helpers (escapeHtml, formatNum, formatCurrency, formatDate, cleanQuillHtml) plus an identical JSDOM+DOMPurify bootstrap — and the copies have already diverged (one uses a regular space as a thousands separator where the others use a non-breaking space); and (2) a handful of genuinely unused exports and one orphan component file. escapeHtml alone is independently defined six times across server and frontend. There are no leftover debug console.logs in the conventional sense — service-layer console.* calls are the established (consistent) logging pattern here since services have no Fastify logger, and the one documented console.warn in plan.service.ts is intentional. No commented-out code blocks of note were found.
**i18n** — Localization is overwhelmingly correct and intentional: all React UI labels, placeholders, titles, button text, alert/toast messages, and the large majority of backend error strings are Czech with correct gender agreement (nenalezen/nenalezena/nenalezeno), while code identifiers, enum values, internal sentinels (e.g. EMAIL_EXISTS), env-validation throws, console.error logs, and dev-only React invariant throws ("useAuth must be used within...") are English — all intentional and not flagged. The genuine gaps are narrow: (1) a handful of English user-facing API error strings that leak to the client ("Missing attendance_id"/"Missing id" in attendance.ts, and "Unauthorized"/"Request failed (n)"/"Invalid JSON response" thrown in the frontend query layer that ProjectFileManager renders verbatim); (2) the parseBody helper surfaces raw Zod messages, so any validator lacking a custom Czech message leaks Zod's default English (enums, .max() length caps, TOTP token .min(1)); and (3) inconsistent Czech phrasing for "not found" — the compact "nenalezen*" form (92 uses) vs the verbose "nebyl/nebyla nalezen*" form (16 uses), sometimes for the identical entity ("Projekt nenalezen" vs "Projekt nebyl nalezen"). No mixed tone, no English UI chrome.
**tests part2b** — remaining two low findings
**config-structure** — The build/config layout is coherent: a project-references tsconfig.json splits server (CJS, ES2022, excludes src/admin) from app (ES2020, bundler resolution), with matching vite/vitest configs and env validation in src/config/env.ts. The main structural problem is the absence of a shared constants/types module spanning server and src/admin: domain label maps (entity-type, action, leave-type) and the canonical EntityType value list are duplicated and have DRIFTED. Most seriously, the frontend audit-log label/filter maps use PHP-legacy entity-type keys (offers_quotation, invoices_invoice, projects_project, trips, vehicles) that no longer match what the TS server writes (quotation, invoice, project, trip, vehicle) — a functional bug caused by the duplication. Secondary issues: a configured-but-unused @/* path alias (also absent from vite resolve), a package.json db:push script contradicting the CLAUDE.md golden rule, and .env.example drift vs config/env.ts. The one piece of genuine cross-boundary sharing (czech-holidays imported by src/admin from src/utils) is inconsistent with how everything else is duplicated and is structurally fragile.
## Findings
### 1. [HIGH/needs-judgment] Frontend audit-type label/filter maps use legacy keys that don't match server-written entity_type values
- **dimension:** config-structure
- **where:** src/admin/pages/AuditLog.tsx:47-66; src/admin/utils/dashboardHelpers.ts:21-40; src/types/index.ts:121-149; src/services/audit.ts:30,43; src/routes/admin/audit-log.ts:34; src/admin/lib/queries/auditLog.ts:18
- **problem:** The server's canonical EntityType union (src/types/index.ts) and every logAudit() call write values like 'invoice', 'customer', 'quotation', 'order', 'project', 'trip', 'vehicle' (e.g. invoices.ts:134 entityType:'invoice', vehicles.ts:68 'vehicle', quotations.ts:80 'quotation'). But the frontend ENTITY_TYPE_LABELS maps (duplicated identically in AuditLog.tsx and dashboardHelpers.ts) key on PHP-legacy values: offers_quotation, offers_customer, orders_order, invoices_invoice, projects_project, trips, vehicles. Two consequences: (1) the audit log UI falls back to showing raw entity_type strings for most entities because the lookup misses; (2) ENTITY_OPTIONS (derived from this map) feeds the entity-type filter dropdown, which sends e.g. entity_type=invoices_invoice to audit-log.ts:34 where it is exact-matched (where.entity_type = String(...)), so filtering by Faktura/Objednavka/Projekt/Jizda/Vozidlo returns zero rows.
- **fix:** Make EntityType a single runtime source of truth (an `as const` array exported from a module importable by both server and src/admin) and derive both the TS union and the label maps from it, or at minimum fix the frontend keys to match the server values (invoice, customer, quotation, order, project, trip, vehicle) and de-duplicate the two copies. Add a small test asserting every EntityType value has a label.
### 2. [HIGH/needs-judgment] NAS file manager swallows real filesystem failures with empty catch blocks and zero logging
- **dimension:** logging
- **where:** src/services/nas-file-manager.ts:118; src/services/nas-file-manager.ts:139; src/services/nas-file-manager.ts:153; src/services/nas-file-manager.ts:176; src/services/nas-file-manager.ts:426; src/services/nas-file-manager.ts:531; src/services/nas-file-manager.ts:679
- **problem:** nas-file-manager.ts has ~25 catch blocks and not a single logging call (Grep for console./.log./logger returns no matches). While many are legitimate existence/control-flow checks where the error is expected (e.g. lines 471-473, 525-527, 666-668), several swallow genuine operation failures: deleteProjectFolder (118), createProjectFolder (139), delete (426) returning 'Nepodařilo se smazat...', createFolder mkdir (531) returning 'Nepodařilo se vytvořit složku', and countItems (679). The underlying OS error (EACCES, ENOSPC, network share down, etc.) is discarded, so when a NAS operation fails in production there is nothing in the logs to diagnose it. This directly violates the CLAUDE.md rule: 'Never silently swallow errors. Even if a failure is non-fatal, log it.'
- **fix:** In the catch blocks that represent actual operation failures (delete, mkdir, rename move, write, copy), capture the error and log it via the Fastify logger before returning the generic Czech message. Since these are service methods without request context, either accept an optional logger/request param or have the calling route log the returned error with the original cause. At minimum, distinguish 'expected ENOENT existence check' catches (which can stay silent) from 'operation should have succeeded' catches (which must log).
### 3. [HIGH/needs-judgment] NAS financials manager swallows write/read/delete/mkdir errors without logging
- **dimension:** logging
- **where:** src/services/nas-financials-manager.ts:135; src/services/nas-financials-manager.ts:151; src/services/nas-financials-manager.ts:163; src/services/nas-financials-manager.ts:184
- **problem:** saveReceivedInvoice (135) catches a writeFileSync failure and returns the error string to the caller but never logs it server-side. readReceivedInvoice (151-153), deleteReceivedInvoice (163-165) and ensureDir (184-186) use fully empty 'catch { return null/false }' blocks that discard the filesystem error entirely. A failed received-invoice save/read/delete on the network share leaves no server-side trace, again violating the 'never silently swallow' rule.
- **fix:** Log the caught error (with the resolved path) before returning null/false/error-string. As with nas-file-manager, thread the Fastify logger in or log the returned error at the route layer. The empty 'catch {}' forms at 151/163/184 are the worst offenders — they should at least preserve the error variable and log it.
### 4. [HIGH/needs-judgment] plan.ts admin check reads non-existent authData.role field — admins cannot see other users' plan rows
- **dimension:** routes
- **where:** src/routes/admin/plan.ts:43-45; src/routes/admin/plan.ts:120; src/routes/admin/plan.ts:142; src/services/plan.service.ts:780; src/services/plan.service.ts:799; src/types/index.ts:51
- **problem:** isAdminLike() does `return authData?.role === 'admin'`, but the AuthData interface (src/types/index.ts:51) has `roleName`, not `role`. Every other consumer in the codebase uses `roleName` (middleware/auth.ts:47,69; users.ts:109; services/auth.ts). So `authData.role` is always undefined and isAdminLike() always returns false. It is passed as the `isAdmin` argument to listEntries()/listOverrides() (plan.ts:120,142), where plan.service.ts:780/799 do `effectiveUserId = isAdmin ? query.user_id : actorUserId`. Net effect: GET /plan/entries and GET /plan/overrides silently ignore the user_id query param for everyone, including admins, so an admin can never read another employee's raw plan/override rows. (Security direction is safe — it over-restricts rather than leaking — but the feature is broken for admins.)
- **fix:** Change isAdminLike to use the documented role/permission model: either `authData?.roleName === 'admin'` to match middleware/auth.ts, or better, check the relevant permission (e.g. authData.permissions.includes('attendance.manage')) consistent with how trips.ts:22 and attendance.ts:208 distinguish admin vs self. Also drop the `any` type on the parameter and type it as AuthData.
### 5. [HIGH/needs-judgment] `any`-typed param hides admin-scoping bug: isAdminLike reads nonexistent `authData.role`
- **dimension:** ts-safety
- **where:** src/routes/admin/plan.ts:43; src/routes/admin/plan.ts:44; src/routes/admin/plan.ts:120; src/routes/admin/plan.ts:142; src/types/index.ts:44; src/services/plan.service.ts:780; src/services/plan.service.ts:799
- **problem:** `isAdminLike(authData: any)` returns `authData?.role === "admin"`. The `AuthData` type (types/index.ts:44) has NO `role` field — the admin role is on `roleName` (used correctly everywhere else: auth.ts:47/69, users.ts:109). Because the parameter is typed `any`, the compiler cannot flag that `.role` is always `undefined`, so `isAdminLike` always returns `false`. It is passed as the `isAdmin` arg to `listEntries`/`listOverrides`, where `effectiveUserId = isAdmin ? query.user_id : actorUserId`. Result: an admin querying another user's plan is silently scoped to their own rows; the `user_id` query param is ignored for everyone. The `any` is the direct cause the type system did not catch the `role` vs `roleName` typo.
- **fix:** Type the parameter as `AuthData | null | undefined` (or reuse the existing `roleName === "admin"` convention) and change the comparison to `authData?.roleName === "admin"`. This both fixes the bug and lets the compiler verify the field exists. Add/confirm a list-scoping test for the cross-user admin case.
### 6. [MEDIUM/needs-judgment] Domain label maps duplicated verbatim across frontend files (and diverging)
- **dimension:** config-structure
- **where:** src/admin/utils/dashboardHelpers.ts:1-47; src/admin/pages/AuditLog.tsx:17-66; src/admin/utils/attendanceHelpers.ts:80-97; src/admin/components/ShiftFormModal.tsx:338-340
- **problem:** ENTITY_TYPE_LABELS and ACTION_LABELS exist as near-identical copies in dashboardHelpers.ts and AuditLog.tsx, and have already diverged: ACTION_LABELS.create is 'Vytvoreni' in AuditLog but 'Vytvoril' in dashboardHelpers, and AuditLog's copy has extra entries (view, activate, deactivate, password_change, ...). Separately, leave-type labels (vacation->'Dovolena', sick->'Nemoc', unpaid->'Neplacene volno') are repeated in dashboardHelpers.ts:2-4, attendanceHelpers.ts:83-85, and inline <option> values in ShiftFormModal.tsx. There is no shared constants module, so these will keep drifting.
- **fix:** Extract one constants module (e.g. src/admin/constants/labels.ts) holding ENTITY_TYPE_LABELS, ACTION_LABELS, LEAVE_TYPE_LABELS and import it everywhere; ideally co-locate with the shared EntityType list from the audit-labels finding.
### 7. [MEDIUM/safe-auto] Undefined CSS variable --danger-color used in base.css
- **dimension:** css
- **where:** src/admin/base.css:268
- **problem:** .admin-error-stack sets color: var(--danger-color), but no file defines --danger-color (variables.css defines --danger, --error, --accent-color, etc., but never --danger-color). The property has no fallback, so the error-stack text color resolves to the inherited/initial color instead of the intended danger red. This is a copy-paste/naming bug, not an intentional choice.
- **fix:** Change to color: var(--danger) (the intended token). Optionally add a --danger-color alias in variables.css if other call sites expect it, but a grep shows base.css:268 is the only consumer.
### 8. [MEDIUM/needs-judgment] Duplicated badge color recipe across 5 feature areas
- **dimension:** css
- **where:** src/admin/components.css:142; src/admin/components.css:160; src/admin/components.css:178; src/admin/invoices.css:5; src/admin/attendance.css:333
- **problem:** The same status-badge formula — background: color-mix(in srgb, var(--X) 15%, transparent); color: var(--X) — is hand-repeated for every semantic color across leave badges (badge-pending/approved/rejected, components.css:142-152), order badges (admin-badge-order-*, :160-174), project badges (admin-badge-project-*, :178-188), invoice badges (admin-badge-invoice-*, invoices.css:5-17), and attendance leave badges (badge-vacation/sick/holiday, attendance.css:333-345). E.g. info-15% is duplicated 4x and danger-15% 4x. Worse, the recipe is inconsistent with the generic badges right above it: admin-badge-success/info/danger (components.css:106-128) use the --*-soft tokens instead of color-mix 15%, so two different visual recipes exist for the same semantic 'success/danger badge'.
- **fix:** Introduce semantic helper classes (e.g. .admin-badge-tone-success/info/warning/danger/muted) defined once, and have the leave/order/project/invoice/attendance status classes compose them, or apply the tone class in TSX. Pick one recipe (either --*-soft or color-mix 15%) so the same status reads identically everywhere. This removes ~20 near-identical rules.
### 9. [MEDIUM/needs-judgment] "Today" form defaults use UTC date (new Date().toISOString().split("T")[0]) → off-by-one in late evening
- **dimension:** dates
- **where:** src/admin/pages/InvoiceDetail.tsx:329; src/admin/pages/InvoiceDetail.tsx:331; src/admin/pages/Attendance.tsx:143; src/admin/pages/Attendance.tsx:144; src/admin/pages/Attendance.tsx:337; src/admin/pages/Attendance.tsx:338; src/admin/pages/AttendanceCreate.tsx:49; src/admin/pages/Trips.tsx:56; src/admin/pages/Trips.tsx:120; src/admin/components/dashboard/DashQuickActions.tsx:77
- **problem:** These default-date initializers compute today's date with new Date().toISOString().split("T")[0], which returns the UTC date. The whole app is intentionally Europe/Prague local time (config/env.ts), and Prague is always ahead of UTC. So between ~22:0024:00 (summer) / ~23:0024:00 (winter) local, UTC is still the previous day and the form pre-fills yesterday's date for trip_date, shift_date, leave date_from/date_to, invoice issue/tax dates. A user creating an attendance/trip/leave record late in the evening silently gets the wrong day.
- **fix:** Replace with a local-date string built from local getters. A correct helper already exists server-side (utils/date.ts localDateStr); add/import an equivalent frontend helper, e.g. `const d=new Date(); const today=`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}``, and use it for every "today" default. Note InvoiceDetail.tsx:330 due_date = new Date(Date.now()+14*86400000).toISOString()... has the same issue and should derive from the local issue_date instead.
### 10. [MEDIUM/needs-judgment] Edit forms round-trip API date strings through new Date().toISOString() instead of string slicing
- **dimension:** dates
- **where:** src/admin/pages/InvoiceDetail.tsx:481; src/admin/pages/InvoiceDetail.tsx:484; src/admin/pages/InvoiceDetail.tsx:487; src/admin/pages/InvoiceDetail.tsx:658; src/admin/pages/ReceivedInvoices.tsx:373-377
- **problem:** When populating a date <input>/AdminDatePicker from an API value, these do new Date(inv.issue_date).toISOString().split("T")[0]. The API value is already a local-time string (Date.toJSON override drops the Z), so new Date() parses it as local, then toISOString() converts back to UTC. For @db.Date values (read back as UTC-midnight → local +1/+2h) this currently lands on the right day in Prague, but it is fragile: it depends on the offset always being positive and the time-of-day being near midnight. The codebase already has the correct, timezone-safe primitive — normalizeDateStr (src/admin/utils/attendanceHelpers.ts:309), which regex-slices the YYYY-MM-DD prefix off the string without ever constructing a Date.
- **fix:** Use normalizeDateStr(inv.issue_date) / a string slice for date-only fields instead of routing through new Date().toISOString(). For computedDueDate (InvoiceDetail.tsx:654-659) the date-only UTC math is self-consistent, but switching to plain string/day arithmetic via the same helper removes the latent risk.
### 11. [MEDIUM/needs-judgment] Raw API datetime string assigned to a native <input type=date> value (mobile blanks the field)
- **dimension:** dates
- **where:** src/admin/pages/Trips.tsx:140; src/admin/components/AdminDatePicker.tsx:92-96
- **problem:** openEditModal sets form.trip_date = trip.trip_date, but trip_date is a @db.Date serialized by the toJSON override as a full datetime string like "2026-06-05T02:00:00" (routes/admin/trips.ts returns the Prisma object directly). On desktop AdminDatePicker uses date-fns parse(val,"yyyy-MM-dd",...) which tolerates the trailing time, so it works. But on touch devices (AdminDatePicker.tsx:127-140) it renders <input type="date" value="2026-06-05T02:00:00">, which is not a valid date-input value — browsers show it blank, so the user appears to have no date and may submit an empty/changed value.
- **fix:** Normalize before seeding the form: trip_date: normalizeDateStr(trip.trip_date). More robustly, have AdminDatePicker's NativeInput coerce its value to the date prefix for mode="date" so any caller passing a datetime string is handled.
### 12. [MEDIUM/needs-judgment] markOverdueInvoices flips invoices to "overdue" on their due date (date-only column vs full now())
- **dimension:** dates
- **where:** src/services/invoices.service.ts:78-85
- **problem:** due_date is a @db.Date column (stored at UTC-midnight, e.g. 2026-06-06T00:00:00Z for "due 2026-06-06"). The query `due_date: { lt: new Date() }` compares it against the current full timestamp. Any time after local midnight on the due day, now() (e.g. 2026-06-06T06:00Z) is already greater than the stored UTC-midnight, so an invoice due *today* is immediately marked overdue. An invoice is normally not overdue until the day after its due date.
- **fix:** Compare against local start-of-today, not now(): build today = new Date(y,m,d,0,0,0) from local getters and use `due_date: { lt: today }` (and the reverse branch `gte: today`). This makes the boundary inclusive of the due day. Confirm the intended business rule with the user (due-day inclusive vs exclusive) before changing.
### 13. [MEDIUM/needs-judgment] Three PDF route files duplicate the same helper set (escapeHtml, formatNum, formatCurrency, formatDate, cleanQuillHtml, DOMPurify bootstrap)
- **dimension:** deadcode
- **where:** src/routes/admin/invoices-pdf.ts:14; src/routes/admin/invoices-pdf.ts:19; src/routes/admin/invoices-pdf.ts:26; src/routes/admin/invoices-pdf.ts:35; src/routes/admin/invoices-pdf.ts:44; src/routes/admin/orders-pdf.ts:12; src/routes/admin/orders-pdf.ts:34; src/routes/admin/orders-pdf.ts:41; src/routes/admin/orders-pdf.ts:50; src/routes/admin/orders-pdf.ts:59; src/routes/admin/offers-pdf.ts:11; src/routes/admin/offers-pdf.ts:14; src/routes/admin/offers-pdf.ts:22; src/routes/admin/offers-pdf.ts:31; src/routes/admin/offers-pdf.ts:51; src/routes/admin/offers-pdf.ts:61
- **problem:** All three PDF generation routes independently define near-identical formatDate, formatNum, escapeHtml, and cleanQuillHtml functions, plus the same `const window = new JSDOM('').window; const DOMPurify = createDOMPurify(window);` bootstrap. The duplication has already caused a real divergence: orders-pdf.ts (formatNum, line 45) uses a regular ASCII space ' ' for the thousands separator, while invoices-pdf.ts (line 30) and offers-pdf.ts (line 26) use a non-breaking space ' '. This is exactly the failure mode duplication creates — the copies drift and produce inconsistent output (orders invoices can line-wrap mid-number where the others won't). cleanQuillHtml/escapeHtml are security-sensitive sanitizers, so divergence here is more than cosmetic.
- **fix:** Extract the shared PDF helpers into a single module (e.g. src/utils/pdf-format.ts or extend src/utils/html-to-pdf.ts): escapeHtml, formatNum(value, decimals), formatCurrency, formatDate, cleanQuillHtml, and a shared DOMPurify instance. Import them in all three *-pdf.ts files. Pick one canonical thousands separator (the non-breaking space is correct for print) so output is consistent across invoice/order/offer PDFs.
### 14. [MEDIUM/safe-auto] English user-facing API error strings in attendance route
- **dimension:** i18n
- **where:** src/routes/admin/attendance.ts:188; src/routes/admin/attendance.ts:196
- **problem:** Two error() responses send raw English to the client: error(reply, "Missing attendance_id", 400) and error(reply, "Missing id", 400). Every other validation/not-found message in this file and across all routes is Czech (e.g. "Záznam nenalezen", "Nedostatečná oprávnění"). error() sends the string straight into the API envelope, so a user hitting these endpoints without the param sees an English message.
- **fix:** Replace with Czech equivalents, e.g. "Chybí ID docházky" and "Chybí ID" (or reuse the existing parseId/"Neplatné ID" pattern). Mechanical, no behavior change.
### 15. [MEDIUM/needs-judgment] Frontend query layer throws English errors that render in the UI
- **dimension:** i18n
- **where:** src/admin/lib/queries/projects.ts:96; src/admin/lib/queries/projects.ts:102; src/admin/lib/apiAdapter.ts:16; src/admin/lib/apiAdapter.ts:23; src/admin/lib/apiAdapter.ts:27; src/admin/lib/apiAdapter.ts:52; src/admin/lib/apiAdapter.ts:64; src/admin/lib/apiAdapter.ts:68; src/admin/lib/queries/mutations.ts:51; src/admin/lib/queries/mutations.ts:56; src/admin/components/ProjectFileManager.tsx:230
- **problem:** The TanStack Query helpers throw new Error with English literals: "Unauthorized", "Invalid JSON response", and `Request failed (${status})`. ProjectFileManager.tsx:230-232 renders filesError.message verbatim (only falling back to the Czech "Nepodařilo se načíst soubory" when message is empty), so on a 401 or non-JSON/unknown-status response the user sees English ("Unauthorized", "Request failed (500)"). ErrorBoundary.tsx:30 likewise renders error.message under a Czech heading. The server-supplied result.error is Czech, but these client-side fallbacks are not. Note projects.ts:94 already does this right with the Czech "Chyba připojení", making the English siblings inconsistent.
- **fix:** Czech-ify the user-visible fallbacks: "Unauthorized" -> e.g. "Přihlášení vypršelo" / "Nejste přihlášeni", "Request failed (n)" -> "Požadavek selhal (n)", "Invalid JSON response" -> "Neplatná odpověď serveru". These are user-facing message strings, not error codes, so translating them is safe; just confirm no code branches on the literal message text (none observed).
### 16. [MEDIUM/needs-judgment] Service-layer logging uses console.* instead of the configured Fastify/pino logger
- **dimension:** logging
- **where:** src/services/audit.ts:57; src/services/mailer.ts:34; src/services/invoices.service.ts:88; src/services/exchange-rates.ts:47; src/services/leave-notification.ts:120; src/services/invoice-alerts.ts:220; src/services/invoice-alerts.ts:224; src/services/plan.service.ts:168; src/utils/totp.ts:27; src/services/auth.ts:363
- **problem:** server.ts:40-41 configures a pino logger (level 'warn' in production, 'info' in dev) and routes correctly use request.log.*/app.log.*. But every service/util logs via console.error/warn/log. These messages bypass pino's structured JSON output, log-level filtering (in production console.log/info still print regardless of the 'warn' level), and any future log transport/aggregation. CLAUDE.md's error-handling guidance explicitly shows app.log.error(e, 'context') as the pattern. invoice-alerts.ts:224 and plan.service.ts:168 in particular emit console.log/console.warn that will print in production despite the configured warn level.
- **fix:** Standardize service logging on the Fastify logger. Since services are plain functions without app context, the cleanest fix is to pass the request/logger into service calls that can log, or export a shared logger instance (e.g. the pino instance from the app) that services import. The CLAUDE.md known-issue list already flags 'Mixed error patterns'; this is the logging analogue. If a full refactor is out of scope, at least demote informational console.log calls (invoice-alerts.ts:224) so they don't spam production stdout.
### 17. [MEDIUM/needs-judgment] markOverdueInvoices swallows DB failure and returns void, so the route treats failure as success
- **dimension:** logging
- **where:** src/services/invoices.service.ts:76; src/services/invoices.service.ts:87; src/routes/admin/invoices.ts:37
- **problem:** markOverdueInvoices() wraps two updateMany calls in try/catch, logs to console.error on failure, and returns nothing. It is invoked from an onRequest hook (invoices.ts:33-39) on every GET (throttled hourly). Because the error is fully swallowed and not surfaced, a persistent DB failure to flip overdue statuses is invisible to the request path and only visible via console (which, per the previous finding, isn't even in the structured log). The throttled best-effort design is reasonable, but the failure should be logged through the Fastify logger that the hook has access to (request.log).
- **fix:** Either keep the best-effort behavior but pass request.log into markOverdueInvoices (or log at the hook: wrap the await in try/catch and request.log.error(err, 'markOverdueInvoices failed')), so failures land in the structured production log instead of bare console.error. Do not change it to throw — that would break unrelated GET requests.
### 18. [MEDIUM/needs-judgment] camelCase 'plan*' files break the kebab-case convention of the backend src/ tree
- **dimension:** naming
- **where:** src/schemas/planCategory.schema.ts; src/services/planCategory.service.ts; src/utils/planAuditDescription.ts; src/utils/planAuditDescription.test.ts; src/services/planCategory.test.ts
- **problem:** The backend src/ tree (excluding src/admin) is kebab-case for 94 of 99 .ts files. Sibling files in the same directories establish the convention clearly: src/schemas uses bank-accounts.schema.ts, received-invoices.schema.ts, scope-templates.schema.ts; src/services uses exchange-rates.ts, nas-file-manager.ts, invoice-alerts.ts; src/utils uses czech-holidays.ts, html-to-pdf.ts. The only 5 camelCase backend files are this 'plan' feature cluster (planCategory.schema.ts, planCategory.service.ts, planAuditDescription.ts plus two test files). Note the same feature's other files DO follow the convention (plan.schema.ts, plan.service.ts), so this is internal inconsistency within one feature, not a sub-convention.
- **fix:** Rename to kebab-case to match the directory convention: plan-category.schema.ts, plan-category.service.ts, plan-audit-description.ts (and matching .test.ts files), updating their import paths. Mechanical but touches importers, so verify references after renaming.
### 19. [MEDIUM/needs-judgment] createInvoice header and items not atomic
- **dimension:** prisma
- **where:** src/services/invoices.service.ts:345-388
- **problem:** invoices.create then a separate invoice_items.createMany with no transaction; a failure leaves a header without items plus a consumed sequence. updateInvoice 469-485, createOrder, createOffer all wrap header and children in a transaction.
- **fix:** Wrap both writes in one transaction; move generateInvoiceNumber inside it.
### 20. [MEDIUM/needs-judgment] N1 in getBelowMinimumItems per-item aggregate in a loop
- **dimension:** prisma
- **where:** src/services/warehouse.service.ts:232-261; src/services/warehouse.service.ts:199-205
- **problem:** getItemTotalStock with item.id called inside the loop; each runs its own sklad_batches aggregate, N1 round trips scaling with the catalog.
- **fix:** One sklad_batches groupBy on item_id with is_consumed false summing quantity; build an id to total map; filter in memory.
### 21. [MEDIUM/manual] useAttendanceAdmin hook bypasses TanStack Query and hand-rolls fetching (with silent catches + race condition)
- **dimension:** react
- **where:** src/admin/hooks/useAttendanceAdmin.ts:818; src/admin/hooks/useAttendanceAdmin.ts:837; src/admin/hooks/useAttendanceAdmin.ts:866; src/admin/hooks/useAttendanceAdmin.ts:915
- **problem:** The whole AttendanceAdmin data layer loads projects, users, attendance records and leave balances with manual apiFetch inside useEffect + useState, instead of the TanStack Query pattern the rest of the app standardizes on (lib/queries/*). Consequences: (a) no caching/dedupe/refetch story consistent with other pages; (b) the load effects swallow errors with empty `catch { /* silent */ }` blocks (lines 827-829, 856-858), which also violates the project's 'never silently swallow errors' rule; (c) fetchData (line 866) re-runs on every month/filterUserId change with no abort or ignore flag, so a slow earlier response can overwrite a newer one — exactly the race the React docs warn about for effect-based fetching. The kept eslint-disable on the deps (line 909) is a symptom of fighting the linter rather than modeling this as a query.
- **fix:** Migrate these loads to useQuery options in src/admin/lib/queries/attendance.ts (projectListOptions, attendanceUsersOptions, attendance records keyed by [month,filterUserId], balances keyed by year). That removes all four effects, the manual loading state, the silent catches, and the race condition for free. If a full migration is out of scope now, at minimum add an `ignore`/AbortController guard in fetchData and log the caught errors via the alert/log path instead of empty catches.
### 22. [MEDIUM/needs-judgment] Array index used as React key on editable, mutable lists
- **dimension:** react
- **where:** src/admin/components/OrderConfirmationModal.tsx:247; src/admin/pages/OfferDetail.tsx:1500
- **problem:** Both lists render rows with controlled inputs and support remove/add (and OfferDetail also reorders) yet key by index. OrderConfirmationModal: items.map((item,i)=><tr key={i}>) with updateItem(i,...) and removeItem via prev.filter((_,i)=>i!==index) (line 88). OfferDetail: sections.map((section,idx)=><div key={idx}>) with delete via prev.filter((_,i)=>i!==idx) (line 1594) and swap-reorder (lines 1540/1567). Per React docs, indices as keys on a list whose order/length changes cause React to reuse the wrong DOM node and component state — here that means after deleting a row the input values/focus shift to the wrong row. This is a genuine correctness bug, not a style nit.
- **fix:** Key by a stable id. ConfirmationItem/ScopeSection should carry a stable client id (e.g. crypto.randomUUID() assigned when the row is created, or the server item id when present) and use key={item.id}. Read-only, non-reordering tables (Warehouse recentMovements, WarehouseReports rows) can keep index keys but ideally use the row's domain id.
### 23. [MEDIUM/needs-judgment] Inconsistent modal implementation: FormModal/ConfirmModal vs hand-rolled overlays with diverging accessibility
- **dimension:** react
- **where:** src/admin/components/FormModal.tsx:88; src/admin/components/dashboard/DashProfile.tsx:266; src/admin/components/dashboard/DashProfile.tsx:405; src/admin/components/dashboard/DashProfile.tsx:660; src/admin/components/dashboard/DashQuickActions.tsx:321; src/admin/components/BulkAttendanceModal.tsx:47; src/admin/components/ShiftFormModal.tsx:252; src/admin/components/OrderConfirmationModal.tsx:108; src/admin/pages/WarehouseLocations.tsx:339; src/admin/pages/Attendance.tsx:936
- **problem:** There are two good reusable modal primitives (FormModal handles body-scroll lock, Esc-to-close, role=dialog/aria-modal/aria-labelledby, focus-grouped footer; ConfirmModal similar), but at least 7 components reimplement the admin-modal-overlay + backdrop + motion markup by hand with inconsistent behavior. DashProfile's three modals (lines 266/405/660) and DashQuickActions (line 321) omit role="dialog", aria-modal, and aria-labelledby entirely; none of the hand-rolled overlays wire Esc-to-close (only FormModal/ConfirmModal do). BulkAttendanceModal/ShiftFormModal/OrderConfirmationModal include the ARIA roles but still lack Esc handling. The result is duplicated markup and an accessibility/UX behavior that varies modal-to-modal.
- **fix:** For the smaller content modals (DashProfile edit-profile, 2FA setup/disable, DashQuickActions trip modal) switch to FormModal — they fit its API and would inherit Esc/lock/ARIA. For the large complex forms (ShiftFormModal, BulkAttendanceModal, OrderConfirmationModal) where FormModal's per-child stagger animation is awkward, factor the overlay+backdrop+Esc+ARIA shell into a shared ModalShell component and have these consume it, so a11y/Esc behavior is uniform. At minimum, add role="dialog"/aria-modal/aria-labelledby to the DashProfile/DashQuickActions overlays.
### 24. [MEDIUM/safe-auto] Raw reply.send({ success: true, ... }) used instead of success()/paginated() helpers
- **dimension:** routes
- **where:** src/routes/admin/attendance.ts:32; src/routes/admin/attendance.ts:108; src/routes/admin/attendance.ts:118; src/routes/admin/attendance.ts:128; src/routes/admin/attendance.ts:142; src/routes/admin/attendance.ts:165; src/routes/admin/attendance.ts:179; src/routes/admin/attendance.ts:190; src/routes/admin/attendance.ts:203; src/routes/admin/attendance.ts:235; src/routes/admin/trips.ts:73; src/routes/admin/leave-requests.ts:57; src/routes/admin/invoices.ts:62; src/routes/admin/projects.ts:41; src/routes/admin/received-invoices.ts:80; src/routes/admin/customers.ts:100
- **problem:** CLAUDE.md explicitly states: 'Use the success() and error() helpers in routes — never write raw reply.send().' Many handlers hand-roll the envelope. The paginated-list cases (attendance.ts:235, trips.ts:73, leave-requests.ts:57, invoices.ts:62, projects.ts:41, received-invoices.ts:80, customers.ts:100) duplicate exactly what paginated(reply, data, meta) produces; the single-object cases (attendance.ts:32,108,118,...) duplicate success(reply, data). This is purely a consistency/maintainability gap (the shapes happen to match today), but it bypasses the central helper, so any future change to the response envelope or status handling won't propagate.
- **fix:** Replace the raw `return reply.send({ success: true, data, pagination })` calls with `return paginated(reply, data, buildPaginationMeta(total, page, limit))`, and `return reply.send({ success: true, data })` with `return success(reply, data)`. attendance.ts and customers.ts already import these helpers; trips/leave-requests/invoices/projects/received-invoices import paginated where needed.
### 25. [MEDIUM/needs-judgment] Session-termination mutations have no audit logging
- **dimension:** routes
- **where:** src/routes/admin/sessions.ts:80-99; src/routes/admin/sessions.ts:102-127
- **problem:** DELETE /sessions/:id (revoke a specific refresh token) and DELETE /sessions?action=all (revoke all other sessions) mutate security-relevant state — they invalidate refresh tokens — but call no logAudit(). CLAUDE.md and the database conventions say data that is created/updated/deleted should be audit-logged, and these are exactly the kind of security events (forced logout / session revocation) that belong in the forensic trail. Every other mutating route in the module logs an audit entry; these two are the notable omissions among security-sensitive endpoints.
- **fix:** Add logAudit() calls after the successful update/updateMany in both handlers (e.g. action 'delete' or 'logout', entityType 'session' or 'refresh_token', entityId the session id for the single case), mirroring the audit pattern used in totp.ts and auth.ts for login/logout events.
### 26. [MEDIUM/needs-judgment] Two competing mutation patterns coexist; ~27 raw apiFetch mutations bypass useApiMutation
- **dimension:** rq-data
- **where:** src/admin/lib/queries/mutations.ts:88; src/admin/pages/WarehouseReservations.tsx:106; src/admin/pages/WarehouseReservations.tsx:136; src/admin/pages/Invoices.tsx:208; src/admin/pages/Invoices.tsx:234; src/admin/pages/Offers.tsx:222; src/admin/pages/OfferDetail.tsx:734; src/admin/pages/OfferDetail.tsx:757; src/admin/pages/OfferDetail.tsx:782; src/admin/hooks/useAttendanceAdmin.ts:1039
- **problem:** The project ships a dedicated useApiMutation hook (mutations.ts) that wraps useMutation + apiFetch + envelope unwrapping + broad invalidation, and it is adopted in 34 files. But 14 files (~27 call sites) still hand-roll the same flow: `const response = await apiFetch(url,{method:...}); const result = await response.json(); if(result.success){ queryClient.invalidateQueries(...); alert.success(...) } else { alert.error(...) } } catch { alert.error('Chyba připojení') }`. This duplicates exactly what useApiMutation centralizes (error throwing as ApiError, success/error branching, invalidation), and the two paths diverge in behavior: useApiMutation throws typed ApiError with .status so callers can branch on HTTP code, whereas the raw path collapses all failures into a generic Czech 'Chyba připojení' string and never exposes status. This is the dominant inconsistency in the data layer. TanStack v5 docs document the useMutation→onSuccess→invalidateQueries flow that useApiMutation implements; the raw path is the non-idiomatic outlier.
- **fix:** Standardize new and touched mutations on useApiMutation (pass `invalidate: ["warehouse"]` etc. instead of manual invalidateQueries, and use mutate/mutateAsync's onSuccess/onError for UI side-effects). Migrate the raw-apiFetch mutation sites incrementally, starting with the simplest CRUD pages (WarehouseReservations, Invoices delete/toggleStatus). Document useApiMutation as the canonical mutation entry point in CLAUDE.md's Frontend Conventions so the pattern stops regrowing.
### 27. [MEDIUM/safe-auto] Broad-domain invalidation convention violated: OfferDetail uses narrow ["offers","list"]
- **dimension:** rq-data
- **where:** src/admin/pages/OfferDetail.tsx:681; src/admin/pages/OfferDetail.tsx:693; src/admin/pages/OfferDetail.tsx:739; src/admin/pages/OfferDetail.tsx:765; src/admin/pages/OfferDetail.tsx:792; src/admin/pages/Offers.tsx:227
- **problem:** CLAUDE.md mandates invalidating the broad domain key (`["offers"]` over `["offers","list"]`) because React Query prefix-matches. OfferDetail.tsx invalidates the narrow key `["offers","list"]` in five handlers (save edit/create, create order, invalidate, delete), while the sibling Offers.tsx (line 227) and CompanySettings.tsx (line 437) correctly invalidate broad `["offers"]`. Consequence: the narrow key does NOT prefix-match the offer-detail cache entry `["offers", id]` (offerDetailOptions, offers.ts:144) nor `["offers","next-number"]`, so after editing an offer the detail query for OTHER offers / next-number stays stale until refetched by other means. The TanStack v5 docs' prefix-match example (`invalidateQueries({queryKey:['projects']})`) is the exact rationale CLAUDE.md cites — OfferDetail is the lone violator.
- **fix:** Change the five `["offers","list"]` invalidations in OfferDetail.tsx to broad `["offers"]` to match the convention and the rest of the offers code. (The delete handler additionally removeQueries(["offers",id]) which is fine to keep.) Mechanical and behavior-safe: broadening only marks more inactive queries stale; active ones already refetch.
### 28. [MEDIUM/manual] useAttendanceAdmin runs a parallel data system instead of query factories / useApiMutation
- **dimension:** rq-data
- **where:** src/admin/hooks/useAttendanceAdmin.ts:1049; src/admin/hooks/useAttendanceAdmin.ts:1050; src/admin/hooks/useAttendanceAdmin.ts:1121; src/admin/hooks/useAttendanceAdmin.ts:1256; src/admin/hooks/useAttendanceAdmin.ts:1286
- **problem:** This 1200+ line hook does its own data orchestration: each mutation does `queryClient.invalidateQueries(["attendance"])` AND `await fetchData(false)` AND `await new Promise(r=>setTimeout(r,300))` to wait for the manual refetch (e.g. lines 1049-1052, 1121-1123). It runs a hand-rolled `fetchData` alongside React Query rather than relying on the attendance.ts queryOptions factories and letting invalidation drive refetch. The fixed 300ms sleep is a race-condition smell (it guesses how long the refetch takes) and the double invalidate+fetchData duplicates work. This is the single largest divergence from the otherwise-consistent data layer.
- **fix:** Treat as a larger refactor (out of scope for safe-auto): migrate the hook's reads to the attendance.ts queryOptions factories and its writes to useApiMutation so invalidation alone drives refetch, removing fetchData and the setTimeout(300). At minimum, drop the redundant manual fetchData+sleep where invalidateQueries already covers it. Flag for a dedicated cleanup ticket.
### 29. [MEDIUM/needs-judgment] Three coercion idioms duplicated ~150+ times instead of shared helpers
- **dimension:** schemas
- **where:** src/schemas/common.ts (only has parseBody); src/schemas/invoices.schema.ts:5-33; src/schemas/orders.schema.ts:6-27; src/schemas/warehouse.schema.ts:85-219; src/schemas/attendance.schema.ts:19-145; src/schemas/settings.schema.ts:15-62; src/schemas/trips.schema.ts:4-37; src/schemas/vehicles.schema.ts:8-39; src/schemas/bank-accounts.schema.ts:14-34; src/schemas/received-invoices.schema.ts:5-60
- **problem:** The same three sub-schemas are copy-pasted across nearly every file: (1) number-from-form `z.union([z.number(), z.string()]).transform((v) => Number(v))`, (2) its nullable form `z.union([z.number(), z.string(), z.null()]).transform((v) => (v === null ? null : Number(v)))`, and (3) boolean-from-form `z.preprocess((v) => v === true || v === 1 || v === '1', z.boolean())`. There are 150+ occurrences. common.ts only exports parseBody — no shared id/number/bool/date primitives exist, even though plan.schema.ts (intFromForm/isoDate) and planCategory.schema.ts (hexColor) already prove the team knows how to factor these out locally. This is the single biggest source of drift: any fix to coercion behavior must be applied in dozens of places.
- **fix:** Add shared exports to src/schemas/common.ts, e.g. `numFromForm`, `nullableNumFromForm`, `boolFromForm`, `idFromForm`, `isoDate`, and import them everywhere. Zod 4 (in use here, v4.3.6) provides `z.coerce.number()` and `z.stringbool()` which replace idioms (1) and (3) outright; per Zod 4 docs `z.stringbool()` throws on unrecognized strings (safer than the silent preprocess). Consolidating also lets you fix the NaN bug (next finding) in one spot.
### 30. [MEDIUM/needs-judgment] Number coercion silently produces NaN (no validation guard in most files)
- **dimension:** schemas
- **where:** src/schemas/trips.schema.ts:4,12-13; src/schemas/vehicles.schema.ts:8-17,29-34; src/schemas/invoices.schema.ts:26-29,48-52,89-92; src/schemas/settings.schema.ts:15-62; src/schemas/attendance.schema.ts:20-37,90-129; src/schemas/received-invoices.schema.ts:5-6,35-60; src/schemas/bank-accounts.schema.ts:14-18,31-34
- **problem:** Most `.transform((v) => Number(v))` coercions have no follow-up refine, so a non-numeric string parses to `NaN` and passes validation. e.g. CreateTripSchema vehicle_id/start_km/end_km (trips.schema.ts:4,12,13) accept garbage as NaN; settings break_threshold_hours, clock_rounding_minutes, max_login_attempts etc. all coerce to NaN silently. Notably this is applied INCONSISTENTLY: orders.schema.ts and offers.schema.ts DO add `.refine((v) => !Number.isNaN(v), ...)` after every numeric transform, and invoices.schema.ts:8-12 throws inside the transform for quantity — so the same project has three different behaviors for the identical concern. NaN then flows into Prisma/business logic.
- **fix:** Standardize on one safe numeric primitive (ideally the shared helper from the previous finding) that rejects NaN — e.g. `z.coerce.number()` which natively errors on non-numeric input, or `.refine((v) => !Number.isNaN(v))`. Apply it uniformly so trips/vehicles/settings/attendance match the stricter orders/offers behavior.
### 31. [MEDIUM/needs-judgment] Email validation applied inconsistently across schemas
- **dimension:** schemas
- **where:** src/schemas/users.schema.ts:5,21; src/schemas/profile.schema.ts:4; src/schemas/customers.schema.ts (no email field, but); src/schemas/warehouse.schema.ts:30,42; src/schemas/settings.schema.ts:38-41
- **problem:** Email fields are validated as proper emails only in users.schema.ts and profile.schema.ts via `z.string().email(...)`. Other schemas that accept email-typed data use plain `z.string().nullish()` with no format check: warehouse supplier `email` (warehouse.schema.ts:30,42), and company-settings `invoice_alert_email`, `leave_notify_email`, `smtp_from` (settings.schema.ts:38-40). The notify/alert emails in settings are used to actually send mail, so an invalid value is a silent operational failure.
- **fix:** Apply email-format validation to the email-typed fields in warehouse and settings schemas (e.g. `z.string().email(...).nullish()`, allowing empty/null). Note: Zod 4 prefers the top-level `z.email()` over the now-deprecated `z.string().email()`; consider migrating the existing users/profile usages too for consistency.
### 32. [MEDIUM/needs-judgment] Services return error with no status, forcing routes to hardcode or cast the HTTP code
- **dimension:** svc-errors
- **where:** src/services/attendance.service.ts:376; src/routes/admin/attendance.ts:50; src/routes/admin/projects.ts:73; src/routes/admin/quotations.ts:286
- **problem:** Many service functions return an error object with NO status field, while the documented convention is error plus status. Routes compensate inconsistently: attendance hardcodes the status at the call site (400 at attendance.ts:50, 404 at :70 for the same shape), and projects/quotations cast result.status with as-any. The same conceptual error yields 400 from one route and 404 from another by call-site choice, not service intent.
- **fix:** Make every error path return error plus status. Then collapse route call sites to the uniform check already used by plan.ts, removing the as-any casts.
### 33. [MEDIUM/needs-judgment] String-sentinel error values decoded by routes instead of self-describing error plus status
- **dimension:** svc-errors
- **where:** src/services/invoices.service.ts:393; src/services/offers.service.ts:231; src/services/projects.service.ts:149; src/routes/admin/invoices.ts:165
- **problem:** updateInvoice, deleteProject and updateOffer return machine-codes as the error string (not_found, has_order, invalidated) without a status. The Czech message and HTTP status are reconstructed in the route via if/else chains ending in a fall-through 500 Neznama chyba. This splits one entity error vocabulary across two files and is easy to desync.
- **fix:** Return the final Czech message plus status directly, as updateOrder/createOffer already do. The route then needs only the uniform one-line check and the 500 fall-throughs disappear.
### 34. [MEDIUM/needs-judgment] Duplicated and contradictory `PaginationMeta` / pagination shape (3 definitions)
- **dimension:** ts-safety
- **where:** src/types/index.ts:85; src/admin/lib/apiAdapter.ts:33; src/admin/hooks/usePaginatedQuery.ts:8
- **problem:** The pagination response shape is declared three times with diverging fields. The server type `PaginationMeta` (types/index.ts:85) has `page, limit, per_page, total, total_pages`. The frontend `PaginationMeta` (apiAdapter.ts:33) drops `limit` entirely (`total, page, per_page, total_pages`). A third inline `PaginatedResult.pagination` (usePaginatedQuery.ts:8-16) re-declares the same fields again. These are independent definitions that can drift; the missing `limit` on the frontend copy means a consumer expecting the server contract is silently incompatible.
- **fix:** Pick one canonical `PaginationMeta` (the server one in types/index.ts is the contract) and have the frontend import/derive from it, or at minimum collapse apiAdapter.ts and usePaginatedQuery.ts onto a single shared interface so the `limit` discrepancy is resolved and can't drift.
### 35. [MEDIUM/needs-judgment] Missing return types on ~47 of 107 exported service functions force `as any` casts in routes
- **dimension:** ts-safety
- **where:** src/services/projects.service.ts:95; src/routes/admin/projects.ts:73; src/routes/admin/projects.ts:106; src/routes/admin/quotations.ts:286; src/services/users.service.ts:59; src/services/offers.service.ts:161; src/services/invoices.service.ts:339
- **problem:** Roughly 47 of 107 exported `async function`s in src/services have no explicit return type and rely on inference. For functions like `updateProject` (projects.service.ts:95) the inferred type is a union of `null | { error, status } | <prismaRow>`; after `"error" in result` narrowing the row branch still has no `status`, so route handlers reach for `(result as any).status ?? 400` (projects.ts:73, projects.ts:106, quotations.ts:286) to read a field the compiler can't guarantee. The `any` cast erases all type checking on the result at exactly the error-handling boundary. The standard `{ data } | { error, status }` service contract documented in CLAUDE.md is not enforced because the return type is left implicit.
- **fix:** Annotate service functions with an explicit discriminated return type (e.g. a shared `ServiceResult<T> = { data: T } | { error: string; status: number }`, or `Result<T>` like plan.service.ts:387 already defines). With a uniform `status` on the error branch, the `(result as any).status` casts in routes can be deleted and become type-safe. This is the systemic root cause of the route-level casts.
### 36. [MEDIUM/needs-judgment] Heavy `any` in usePlanWork mutations plus mutation-object monkey-patching (`(createEntry as any)._rolled`)
- **dimension:** ts-safety
- **where:** src/admin/hooks/usePlanWork.ts:235; src/admin/hooks/usePlanWork.ts:241; src/admin/hooks/usePlanWork.ts:261; src/admin/hooks/usePlanWork.ts:273; src/admin/hooks/usePlanWork.ts:322; src/admin/hooks/usePlanWork.ts:355; src/admin/hooks/usePlanWork.ts:388; src/admin/hooks/usePlanWork.ts:430; src/admin/hooks/usePlanWork.ts:457; src/admin/hooks/usePlanWork.ts:478
- **problem:** The plan-work optimistic-update hook is the single densest `any` site in the frontend (20 occurrences). Mutation `mutationFn`/`onSuccess` bodies and vars are typed `any` (e.g. `(body: any)`, `(data: any, body: any)`), and rollback state is stored by mutating the mutation object: `(createEntry as any)._rolled = rolled` (and 5 more `_rolled` writes). `qc.getQueryData<GridData>(currentGridKey as any)` also casts the typed query key away. This defeats type safety on the optimistic-update path where cell-patching correctness matters most, and the `_rolled` side-channel is invisible to the type system, so a typo in the property name would silently break rollback.
- **fix:** Type the mutation bodies with the request DTOs (the plan entry/override create/update shapes already exist in schemas/ and lib/queries/plan.ts) and `onSuccess` data with the API response type. Replace the `(mutation as any)._rolled` side-channel with React Query's `onMutate`/context return value (the supported, fully-typed rollback mechanism), which removes the casts.
### 37. [LOW/safe-auto] Configured @/* path alias is unused and not wired into the Vite/vitest build
- **dimension:** config-structure
- **where:** tsconfig.app.json:19-22; tsconfig.server.json:16-19; vite.config.ts:4-32; vitest.config.ts:5-14
- **problem:** Both tsconfig.app.json and tsconfig.server.json declare baseUrl:'.' with paths {'@/*':['src/*']}, but a search for imports from '@/' across src returns zero matches — the alias is dead config. Worse, if someone did adopt it, vite.config.ts and vitest.config.ts define no matching resolve.alias, so TypeScript would resolve it but the Vite client build and Vitest would fail at runtime. The codebase consistently uses relative imports instead.
- **fix:** Either remove the baseUrl/paths block from both tsconfigs to avoid implying an alias convention the bundler does not support, or commit to it by adding the matching resolve.alias in vite.config.ts/vitest.config.ts and migrating imports. Removal is the lower-risk option given current usage.
### 38. [LOW/needs-judgment] package.json db:push / db:pull scripts contradict the documented migration golden rule
- **dimension:** config-structure
- **where:** package.json:16-17; CLAUDE.md
- **problem:** CLAUDE.md states the golden rule 'NEVER use prisma db push' (it bypasses migration history and causes drift), yet package.json exposes "db:push": "prisma db push" (and "db:pull": "prisma db pull") as first-class npm scripts, which invites exactly the prohibited workflow. The documented schema-change path is prisma migrate dev, which has no script alias.
- **fix:** Remove the db:push (and likely db:pull) scripts, or rename/guard them so they can't be run casually; add a db:migrate script wrapping `prisma migrate dev` to match the documented workflow.
### 39. [LOW/needs-judgment] Cross-boundary import of a server util by the client is inconsistent and structurally fragile
- **dimension:** config-structure
- **where:** src/admin/utils/attendanceHelpers.ts:1; src/admin/hooks/useAttendanceAdmin.ts:4; src/utils/czech-holidays.ts:1-8; tsconfig.server.json:21-30
- **problem:** src/admin code imports getHolidays/isHoliday from the server-side util src/utils/czech-holidays via '../../utils/czech-holidays'. This is the ONLY place server logic is genuinely shared with the client; every other shared concern (formatters, label maps, EntityType) is duplicated instead, so the sharing strategy is inconsistent. It also reaches across the build boundary: tsconfig.server.json compiles src/utils into the server bundle while Vite separately bundles the same file into the client. It works today only because czech-holidays has no Node-only dependencies (it imports just ./date). If czech-holidays ever pulls in a Node API (fs, crypto, prisma), the client build breaks silently.
- **fix:** Decide on one strategy: either introduce a dedicated src/shared/ (or src/common/) module for genuinely isomorphic helpers like czech-holidays and date formatters, referenced by both builds with an explicit boundary, or duplicate intentionally. Avoid ad-hoc src/admin -> src/utils relative imports that silently couple the two bundles.
### 40. [LOW/safe-auto] .env.example has drifted from the variables actually read in config/env.ts
- **dimension:** config-structure
- **where:** .env.example:1-32; src/config/env.ts:55-110
- **problem:** config/env.ts reads several env vars absent from .env.example: TOTP_ALGORITHM, TOTP_DIGITS, TOTP_PERIOD, LOGIN_TOKEN_EXPIRY_MINUTES, NAS_FINANCIALS_PATH, NAS_OFFERS_PATH, SMTP_FROM_NAME, INVOICE_ALERT_EMAIL, RATE_LIMIT_MAX/WINDOW/LOGIN/TOTP/REFRESH, and TRUST_PROXY. All have defaults so nothing breaks, but the example file no longer documents the full configuration surface, making prod tuning (rate limits, trust proxy) undiscoverable.
- **fix:** Add the missing optional variables (with their default values as comments) to .env.example so it stays a complete reference for config/env.ts.
### 41. [LOW/manual] Inconsistent badge/class naming scheme (admin- prefix dropped)
- **dimension:** css
- **where:** src/admin/components.css:142; src/admin/components.css:146; src/admin/components.css:150; src/admin/components.css:154; src/admin/attendance.css:333; src/admin/invoices.css:24; src/admin/invoices.css:70
- **problem:** Class naming is inconsistent within the single global namespace. Most badges use admin-badge-* (admin-badge-order-prijata, admin-badge-invoice-paid), but the leave-status badges in components.css:142-154 are bare badge-pending/badge-approved/badge-rejected/badge-cancelled, and attendance.css reuses bare badge-vacation/sick/holiday/unpaid. invoices.css mixes three schemes in one file: admin-badge-invoice-* (:5), invoice-month-* (:24), and received-upload-* (:70). Because all CSS is global, these bare badge-* names risk collision and make the convention ambiguous (admin-* for shared vs feature-prefix for page-local).
- **fix:** Document and enforce one rule: shared/reusable components use admin-*, page-local elements use a feature prefix (already done for plan-*, dash-*, fm-*). Rename bare badge-* to admin-badge-* (or feature-prefix them) — coordinate the matching className strings in the TSX. Not safe-auto because it requires synchronized TSX edits.
### 42. [LOW/needs-judgment] Defined-but-unused CSS variables (dead tokens)
- **dimension:** css
- **where:** src/admin/variables.css:27; src/admin/variables.css:34; src/admin/variables.css:14; src/admin/variables.css:15; src/admin/variables.css:26
- **problem:** Several tokens are declared but never referenced anywhere in *.css: --gradient-subtle (variables.css:27), --transition-slow (:34), --space-10 (:14) and --space-12 (:15). Additionally --gradient (:26) is used only once (attendance.css:220) and is just a duplicate literal of --accent-color (#d63031), so it is a redundant alias rather than a real gradient token. These are dead/redundant and add noise to the token system.
- **fix:** Remove the unused tokens, or wire them up if intended. For --gradient, either point attendance.css:220 at var(--accent-color) and delete --gradient, or rename it to something meaningful. Verify against TSX inline styles before deleting (grep showed no CSS consumers).
### 43. [LOW/needs-judgment] Empty responsive.css stub imported into the bundle
- **dimension:** css
- **where:** src/admin/responsive.css:1; src/admin/AdminApp.tsx:22
- **problem:** responsive.css contains only a 6-line comment ('reserved for responsive rules that span multiple components') and no rules, yet is imported at AdminApp.tsx:22. All responsive media queries actually live in their per-feature files (forms.css, layout.css, components.css, etc.), so the file is dead. It is harmless but misleading — a reader may expect cross-cutting breakpoints to live here.
- **fix:** Either delete the file and its import, or actually consolidate the repeated breakpoints here. Note there is no shared breakpoint token: 480/640/768/1024 recur across ~15 files as raw px (e.g. forms.css:293-312, components.css, layout.css). If kept, this file is the natural place to at least document the canonical breakpoint set.
### 44. [LOW/safe-auto] plan.css imported twice
- **dimension:** css
- **where:** src/admin/AdminApp.tsx:26; src/admin/pages/PlanWork.tsx:18
- **problem:** plan.css is imported globally in AdminApp.tsx:26 (alongside all other stylesheets) and again in PlanWork.tsx:18. Since AdminApp already loads it globally, the second import is redundant. The bundler de-dupes so there is no runtime double-application, but it is inconsistent — no other page re-imports its own CSS (e.g. WarehousePage does not re-import warehouse.css), so this is an outlier that suggests uncertainty about the loading model.
- **fix:** Remove the import at PlanWork.tsx:18 to match the convention that all admin CSS is loaded once in AdminApp.tsx. Low risk since the global import already covers it.
### 45. [LOW/needs-judgment] getCzechDate week number uses a non-ISO local formula that disagrees with the plan module's ISO week
- **dimension:** dates
- **where:** src/admin/utils/dashboardHelpers.ts:75-78; src/admin/pages/PlanWork.tsx:61-69
- **problem:** The dashboard header (getCzechDate) computes "Týden N" with a homegrown formula: Math.ceil(((now - Jan1)/86400000 + Jan1.getDay() + 1)/7) using local getters. This is not ISO-8601 (ISO weeks start Monday; week 1 is the week containing the first Thursday) and is also locale-dependent via getDay(). PlanWork.tsx:61-69 implements a correct ISO-8601 week (Thursday-anchored, UTC). For dates near year boundaries the two will display different week numbers for the same day, an internal inconsistency users can notice.
- **fix:** Extract PlanWork's isoWeekNumber into a shared util and reuse it in getCzechDate so the week number is computed one consistent (ISO) way across the UI.
### 46. [LOW/needs-judgment] Two divergent conventions for writing @db.Date columns (UTC-midnight vs local-noon)
- **dimension:** dates
- **where:** src/routes/admin/trips.ts:234; src/routes/admin/trips.ts:294; src/services/invoices.service.ts:362-364; src/services/attendance.service.ts:1176; src/services/attendance.service.ts:1227-1234
- **problem:** Date-only (@db.Date) columns are written two different ways. Trips and invoices do new Date(String(body.x)) where body.x is "YYYY-MM-DD" → UTC-midnight. Attendance constructs new Date(year,month,day,12,0,0) → local noon. Both currently store the correct date in Prague only because the local offset is always positive and 12:00 local is comfortably inside the day in UTC. The local-noon form is the robust one (immune to offset sign and DST edges); the UTC-midnight form is the fragile one. Mixing them is an inconsistency that invites a future off-by-one if anyone copies the wrong pattern or the deployment TZ ever changes.
- **fix:** Standardize on one convention for @db.Date writes — prefer the local-noon construction (or a single shared helper like dateOnly("YYYY-MM-DD")) and use it in trips/invoices too. Behavior is unchanged for Prague today, so this is a consistency/robustness cleanup, not an urgent fix.
### 47. [LOW/needs-judgment] escapeHtml is defined six times across the codebase
- **dimension:** deadcode
- **where:** src/routes/admin/invoices-pdf.ts:35; src/routes/admin/orders-pdf.ts:50; src/routes/admin/offers-pdf.ts:51; src/services/invoice-alerts.ts:18; src/services/leave-notification.ts:21; src/admin/hooks/useAttendanceAdmin.ts:341
- **problem:** There are six separate definitions of an escapeHtml(str) function. Five escape &,<,>," identically; the sixth (useAttendanceAdmin.ts:341) additionally escapes the single quote ('). They are used for HTML email bodies, PDF templates, and print HTML — all contexts where consistent escaping matters. The subtle difference (one escapes the apostrophe, the others don't) is the kind of inconsistency that hides XSS/output bugs.
- **fix:** Move escapeHtml into a single shared util (server-side src/utils, plus one for the frontend, or share via a common module). Standardize on the stricter variant that also escapes the single quote. Replace the six local copies with imports.
### 48. [LOW/safe-auto] formatDate duplicated between two frontend util files
- **dimension:** deadcode
- **where:** src/admin/utils/formatters.ts:20; src/admin/utils/attendanceHelpers.ts:24
- **problem:** formatters.ts exports `formatDate(dateStr)` => `d.toLocaleDateString('cs-CZ')` with a '—' fallback, and attendanceHelpers.ts exports an identical `formatDate` (same body, same '—' fallback). Both are imported across the app, so two names resolve to the same logic in different modules — a maintenance hazard if one is changed.
- **fix:** Keep the canonical formatDate in formatters.ts and have attendanceHelpers re-export or import it instead of redefining. Update attendanceHelpers consumers to the shared one.
### 49. [LOW/safe-auto] Dead export: previewPattern() in numbering.service.ts
- **dimension:** deadcode
- **where:** src/services/numbering.service.ts:343
- **problem:** `previewPattern(pattern, prefix, code)` is exported but has zero references anywhere in src (verified by grep across .ts/.tsx). It is the only public function in the numbering service with no callers; all sibling preview/generate functions are used.
- **fix:** Remove previewPattern, or wire it into the settings UI/route that previews a numbering pattern if that was its intent. As-is it is dead code.
### 50. [LOW/safe-auto] Dead export: usersQuery + planKeys.users() in plan query module
- **dimension:** deadcode
- **where:** src/admin/lib/queries/plan.ts:111; src/admin/lib/queries/plan.ts:95
- **problem:** `usersQuery()` (plan.ts:111) is exported but never imported or used by any component/page. Its query key `planKeys.users()` (plan.ts:95) is referenced only by usersQuery itself. The `/api/admin/plan/users` endpoint string appears only inside this dead query (the PlanWork grid gets its users from the grid payload instead). So the frontend query, its key entry, and the `users: () => ['plan','users']` key are an orphaned cluster. (The backend route src/routes/admin/plan.ts:63 still exists and was not assessed as dead from the server side.)
- **fix:** Remove usersQuery and the planKeys.users key entry. Separately confirm whether the backend GET /plan/users route still has any consumer; if not, it can be removed too.
### 51. [LOW/needs-judgment] Dead export: getMonthlyWorkFund() in czech-holidays.ts
- **dimension:** deadcode
- **where:** src/utils/czech-holidays.ts:95
- **problem:** `getMonthlyWorkFund(...)` is exported but has zero references across src (verified by grep). Sibling exports getHolidays/isHoliday/getBusinessDaysInMonth are all used; this one is not.
- **fix:** Remove getMonthlyWorkFund, or use it where monthly work-fund hours are currently computed inline (e.g. attendance.service.ts computes fund hours as `getBusinessDaysInMonth(...) * 8` in several places — that inline math is what this helper appears intended to replace).
### 52. [LOW/needs-judgment] Unused file: ReservationPicker.tsx
- **dimension:** deadcode
- **where:** src/admin/components/warehouse/ReservationPicker.tsx:11
- **problem:** The whole file (54 lines, default export ReservationPicker) has no importers anywhere in the project — grep for 'ReservationPicker' returns only self-references inside the file. The sibling warehouse pickers (ItemPicker, BatchPicker, SupplierSelect, LocationSelect) are all imported and used; this one is an orphan.
- **fix:** Delete src/admin/components/warehouse/ReservationPicker.tsx, or wire it into the warehouse issue/reservation flow if it was intended for an unfinished feature.
### 53. [LOW/safe-auto] Inconsistent Czech phrasing for "not found" (nenalezen vs nebyl nalezen)
- **dimension:** i18n
- **where:** src/routes/admin/project-files.ts:61; src/routes/admin/project-files.ts:73; src/routes/admin/project-files.ts:92; src/routes/admin/project-files.ts:112; src/routes/admin/project-files.ts:165; src/routes/admin/project-files.ts:228; src/routes/admin/project-files.ts:270; src/services/warehouse.service.ts:967; src/services/warehouse.service.ts:1021; src/routes/admin/projects.ts:56
- **problem:** Two phrasings express the same concept. The dominant, compact form "nenalezen/nenalezena/nenalezeno" appears ~92 times across routes/services; the verbose form "nebyl/nebyla nalezen*" appears ~16 times (all of project-files.ts plus warehouse.service.ts reservations/inventory). The clash is sometimes on the identical entity: projects.ts:56 "Projekt nenalezen" vs project-files.ts:61 "Projekt nebyl nalezen"; warehouse routes "Kategorie nenalezena"/"Dodavatel nenalezen" vs warehouse.service.ts "Rezervace nebyla nalezena"/"Inventarizace nebyla nalezena". Same product, two wordings for the same 404.
- **fix:** Standardize on the dominant compact form ("Projekt nenalezen", "Soubor nenalezen", "Složka nenalezena", "Rezervace nenalezena", "Inventarizace nenalezena"). Pure string normalization, no logic change. Low priority polish.
### 54. [LOW/needs-judgment] parseBody leaks default English Zod messages for validators without a custom message
- **dimension:** i18n
- **where:** src/schemas/common.ts:12; src/schemas/received-invoices.schema.ts:27; src/schemas/attendance.schema.ts:25; src/schemas/leave-requests.schema.ts:4; src/schemas/offers.schema.ts:92; src/schemas/orders.schema.ts:93; src/schemas/attendance.schema.ts:8; src/schemas/plan.schema.ts:42; src/schemas/auth.schema.ts:26
- **problem:** parseBody (common.ts:12) joins each Zod issue's .message and returns it directly as the user-facing API error. Fields with explicit Czech messages (e.g. .min(1, "Název je povinný")) are fine, but validators relying on Zod defaults emit English to the user: z.enum([...]) without a message -> "Invalid enum value..."; .max(500) on note/address -> "String must contain at most 500 character(s)"; TOTP secret/code .min(1) (auth.schema.ts:26-33) -> "String must contain at least 1 character(s)". Most are hard for a normal user to trigger (enum values come from dropdowns), but the length caps on free-text note/address fields are reachable.
- **fix:** Add Czech messages to the user-reachable validators (the free-text .max() caps and any enum a user could submit), or centralize a Czech errorMap on the Zod instance so defaults are Czech globally. Lower urgency for purely internal enums/tokens. Treat as needs-judgment because deciding which validators are user-reachable requires per-field review.
### 55. [LOW/safe-auto] Best-effort frontend background calls swallow errors silently with .catch(() => {})
- **dimension:** logging
- **where:** src/admin/pages/Attendance.tsx:235; src/admin/pages/Attendance.tsx:238; src/admin/pages/InvoiceDetail.tsx:778
- **problem:** Three fire-and-forget calls (reverse-geocode address update, and post-save PDF pre-generation) use '.catch(() => {})' with no logging. These are genuinely non-critical side effects, so failing silently is a defensible product choice, but the empty handler gives no breadcrumb when the address never updates or the PDF isn't pre-generated. Unlike the rest of the frontend, which consistently surfaces errors via alert.error with a Czech fallback, these are invisible.
- **fix:** Add a console.warn (or a debug-level log) in the catch so the swallow is intentional-and-traceable rather than fully silent, e.g. '.catch((e) => console.warn("address update failed", e))'. No user-facing alert is warranted for these background ops.
### 56. [LOW/needs-judgment] plan.ts query factories use '*Query' suffix instead of the established '*Options' convention
- **dimension:** naming
- **where:** src/admin/lib/queries/plan.ts:98; src/admin/lib/queries/plan.ts:111
- **problem:** Across src/admin/lib/queries, 55 query factories use the '*Options' suffix (userListOptions, orderListOptions, attendanceBalancesOptions, planCategoriesOptions, etc.). plan.ts itself uses planCategoriesOptions correctly, but then defines gridQuery (line 98) and usersQuery (line 111) with a '*Query' suffix instead. These are the only two '*Query'-suffixed factories in the whole queries directory, so the names read as a different kind of thing than they are (they are queryOptions() factories identical in shape to the *Options ones).
- **fix:** Rename gridQuery -> planGridOptions and usersQuery -> planUsersOptions for parity with the surrounding 55 *Options factories. Both are used inside the same module/feature, so the blast radius is small.
### 57. [LOW/needs-judgment] '.service.ts' suffix applied to only half of service files, with two borderline cases
- **dimension:** naming
- **where:** src/services/exchange-rates.ts; src/services/system-settings.ts; src/services/numbering.service.ts
- **problem:** Of 20 files in src/services, exactly 10 carry the '.service.ts' suffix and 10 do not. The split largely tracks a sensible distinction (entity CRUD services like invoices.service.ts / users.service.ts are suffixed; infra/integration helpers like mailer.ts, audit.ts, nas-*-manager.ts, leave-notification.ts are not), so this is not pure randomness. However the boundary is fuzzy: exchange-rates.ts (exports getRate/toCzk) and system-settings.ts (exports getSystemSettings) are read/query services that resemble the suffixed group but lack the suffix, while numbering.service.ts is suffixed despite being a low-level sequence helper. A reader cannot rely on the suffix to predict a file's role.
- **fix:** Document the intended rule in CLAUDE.md (e.g. '*.service.ts = entity business-logic services; bare name = infra/integration helpers') and rename the borderline files to match it, OR drop the suffix entirely since the directory name 'services/' already conveys the role. Either way pick one rule; do not leave it implicit.
### 58. [LOW/needs-judgment] queryKey helper naming: only 'plan' defines a 'planKeys' key-factory object; other domains inline keys
- **dimension:** naming
- **where:** src/admin/lib/queries/plan.ts:87
- **problem:** plan.ts introduces a dedicated planKeys object (all/grid/entries/overrides/users) to centralize query keys, which is a good pattern. But it is the only domain in src/admin/lib/queries that does so; every other module (users.ts, orders.ts, invoices.ts, trips.ts, etc.) inlines its queryKey arrays directly inside each *Options factory. The result is two coexisting key-management styles with no documented reason, so a contributor cannot tell which pattern to follow when adding a query.
- **fix:** Decide on one approach: either adopt the planKeys-style key-factory object per domain (more robust against typos and easier invalidation), or treat planKeys as a deliberate exception and note it. Low priority — purely a consistency/discoverability issue, no behavior impact.
### 59. [LOW/needs-judgment] getInvoiceStats walks the dataset four times with sequential per-invoice currency conversion
- **dimension:** prisma
- **where:** src/services/invoices.service.ts:247-256; src/services/invoices.service.ts:284-297
- **problem:** sumCzk awaits toCzk per invoice in a loop, called three times plus a fourth VAT loop; serializes many awaits. Cached so it is await overhead not network.
- **fix:** Pre-fetch the rate map once and convert synchronously, or resolve conversions together. Low priority.
### 60. [LOW/needs-judgment] getBalances runs a sequential leave_balances query per user
- **dimension:** prisma
- **where:** src/services/attendance.service.ts:490-504; src/services/attendance.service.ts:944-946
- **problem:** Loops users running leave_balances findFirst each; getPrintData already uses one findMany for the year then a map, so this is inconsistent.
- **fix:** One findMany filtered by user_id in the list and year; index by user_id.
### 61. [LOW/needs-judgment] enrich and body handlers typed as any, dropping Prisma types
- **dimension:** prisma
- **where:** src/services/orders.service.ts:41; src/services/offers.service.ts:49; src/services/invoices.service.ts:339; src/services/invoices.service.ts:391; src/services/projects.service.ts:95
- **problem:** enrichOrder and enrichQuotation and several handlers take any, losing the types Prisma generates for includes and mapped rows; code otherwise strict TS, deriving a getPayload type at attendance.service.ts 23-28.
- **fix:** Type enrich inputs with the Prisma getPayload helper; tighten bodies to existing input interfaces.
### 62. [LOW/needs-judgment] Server data synced into form state via useEffect instead of derived/key-reset (accepted-but-not-ideal)
- **dimension:** react
- **where:** src/admin/pages/InvoiceDetail.tsx:371; src/admin/pages/InvoiceDetail.tsx:454; src/admin/pages/OfferDetail.tsx:383; src/admin/pages/OfferDetail.tsx:428; src/admin/pages/CompanySettings.tsx:256; src/admin/pages/Settings.tsx:213
- **problem:** Multiple pages copy query data into local form state inside an effect, guarded by a one-shot ref/flag (formInitializedRef / dataReady / sysFormInitialized) or a derived-default merge (InvoiceDetail:371, OfferDetail:383 adjust currency/vat from companySettings on prop change). The React 'You Might Not Need an Effect' guidance explicitly lists 'adjusting/resetting state on prop change in an Effect' as an anti-pattern (causes an extra render with stale data) and recommends either computing during render or resetting state with a `key`. The flag-guarded population effects are the commonly-accepted compromise for editable forms seeded from server data, so this is low severity, but it is the same family of effect the docs steer away from.
- **fix:** Where feasible, prefer resetting form state by giving the editor a key (e.g. key={id} on the detail component) so a fresh mount re-seeds state without an effect, and compute the companySettings-derived defaults (currency/vat) during render rather than patching state in an effect. Treat as cleanup, not a blocker — the current guards prevent the worst (re-clobbering user edits).
### 63. [LOW/manual] 2FA/profile state prop-drilled through DashProfile (18 props)
- **dimension:** react
- **where:** src/admin/components/dashboard/DashProfile.tsx:10; src/admin/components/dashboard/DashProfile.tsx:40
- **problem:** DashProfile receives 18 props, ~14 of which are a single concern: the 2FA enrollment/disable flow state and its setters (totpEnabled, totpLoading, totpSubmitting, totpSecret, totpQrUri, totpCode/setTotpCode, backupCodes/setBackupCodes, show2FASetup/setShow2FASetup, show2FADisable/setShow2FADisable, disableCode/setDisableCode, plus onStart/onConfirm/onDisable callbacks). This is all owned by the Dashboard parent and threaded down, making the component signature brittle and the 2FA logic split across two files.
- **fix:** Encapsulate the 2FA flow in a dedicated hook (e.g. use2FA()) colocated with DashProfile, or move the 2FA modals fully into DashProfile and have it own that state. The parent then passes only totpEnabled (or nothing). This is a maintainability refinement, not a bug.
### 64. [LOW/safe-auto] Minor: derived-state effect in ItemPicker resets activeIndex via useEffect
- **dimension:** react
- **where:** src/admin/components/warehouse/ItemPicker.tsx:48
- **problem:** The effect at line 48 watches [items, activeIndex] to reset the highlighted index when the result list shrinks. This is derived state maintained through an effect (the docs' 'you might not need an effect' territory) and introduces an extra render cycle; it also lists activeIndex in deps while setting it, which is benign here only because of the guard. Low impact — the component otherwise correctly uses refs for keyboard nav.
- **fix:** Clamp activeIndex during render (e.g. const safeActive = activeIndex >= items.length ? -1 : activeIndex) or reset it in the same handler that changes the search/items rather than reacting in an effect. Optional cleanup.
### 65. [LOW/safe-auto] Inconsistent numeric ID parsing — raw parseInt instead of the parseId helper
- **dimension:** routes
- **where:** src/routes/admin/trips.ts:192-193; src/routes/admin/trips.ts:264-265; src/routes/admin/trips.ts:338-339; src/routes/admin/sessions.ts:84-85
- **problem:** response.ts exports parseId(raw, reply) specifically to centralize param-id parsing (it rejects NaN and id<=0 with a uniform 400 'Neplatné ID'). Most files use it, but trips.ts and sessions.ts hand-roll `parseInt(...,10)` + `isNaN` checks. Besides the duplication, the hand-rolled versions accept id <= 0 (e.g. 0 or negative) because they only check isNaN, whereas parseId also rejects id <= 0 — a subtle behavioral divergence. Error messages also differ ('Neplatné ID vozidla', 'Neplatné ID relace' vs the helper's 'Neplatné ID').
- **fix:** Replace the parseInt/isNaN blocks with `const id = parseId(request.params.id, reply); if (id === null) return;` (trips.ts uses request.params.id / vehicleId; keep a tailored message only if the distinct wording is desired). This unifies the <=0 rejection and the 400 response shape.
### 66. [LOW/needs-judgment] Several not-found / precondition errors in project-files.ts fall back to default 400 instead of an explicit status
- **dimension:** routes
- **where:** src/routes/admin/project-files.ts:68; src/routes/admin/project-files.ts:69; src/routes/admin/project-files.ts:88; src/routes/admin/project-files.ts:114; src/routes/admin/project-files.ts:124; src/routes/admin/project-files.ts:138; src/routes/admin/project-files.ts:179; src/routes/admin/project-files.ts:202; src/routes/admin/project-files.ts:229; src/routes/admin/project-files.ts:241; src/routes/admin/project-files.ts:279
- **problem:** Within the same file, downloadFile-not-found correctly returns error(reply, 'Soubor nebyl nalezen', 404) (line 73) and listFiles folder-not-found returns 404 (line 92), but the parallel 'Projekt nemá číslo projektu' (88,114,229), required-path messages (68,279), and fm-returned-error passthroughs (138,202,241) all omit the status and silently use the helper's default 400. Most are genuinely validation/precondition (400 is fine), but 'Projekt nemá číslo projektu' is a state precondition inconsistently a 400 here vs config-missing cases returning 500, and the omitted statuses make intent unclear. Consistency/clarity issue, not a correctness bug.
- **fix:** Pass an explicit status to each error() call so the intended HTTP semantics are visible and uniform (400 for client validation, 404/409/422 for state preconditions as appropriate). At minimum make the 'Projekt nemá číslo projektu' and required-path messages explicit rather than relying on the default.
### 67. [LOW/safe-auto] Redundant non-null assertions on parseBody result (body.data!) in plan.ts
- **dimension:** routes
- **where:** src/routes/admin/plan.ts:170; src/routes/admin/plan.ts:194; src/routes/admin/plan.ts:238-242; src/routes/admin/plan.ts:267; src/routes/admin/plan.ts:319; src/routes/admin/plan.ts:361
- **problem:** plan.ts writes `const body = parseBody(...); if ('error' in body) return ...; ... body.data!`. parseBody returns `{ data: T } | { error: string }` (schemas/common.ts:3-6), so after the `'error' in body` guard TypeScript already narrows body to `{ data: T }` and `.data` is non-null. Every other route file uses the cleaner `const parsed = parseBody(...); if ('error' in parsed) return ...; const body = parsed.data;` form without the `!`. The `!` is harmless but inconsistent and signals (falsely) that data might be nullable.
- **fix:** Adopt the prevailing pattern: bind `const parsed = parseBody(...)`, guard, then `const body = parsed.data` without the non-null assertion, matching customers.ts / warehouse.ts / attendance.ts.
### 68. [LOW/needs-judgment] usePlanWork re-implements the gridQuery factory inline with untyped apiFetch parsing
- **dimension:** rq-data
- **where:** src/admin/hooks/usePlanWork.ts:87; src/admin/lib/queries/plan.ts:98
- **problem:** plan.ts exports a `gridQuery(dateFrom,dateTo,view)` queryOptions factory (line 98) that uses jsonQuery<GridData> for type-safe envelope handling. usePlanWork.ts:87 ignores it and inlines its own useQuery with `apiFetch(...).then(r=>r.json().then((b:any)=>b.data as GridData))` — duplicating the URL construction, skipping jsonQuery's `!response.ok || !result.success` error check (so a backend `{success:false}` silently yields undefined data instead of throwing), and using `b:any`. The planKeys factory is reused for the key, but the queryFn diverges. This is the only place a query bypasses the apiAdapter.
- **fix:** Replace the inline useQuery body with the existing gridQuery factory: `useQuery({ ...gridQuery(isoDate(range.from), isoDate(range.to), view), placeholderData: keepPreviousData })`. Restores jsonQuery's error handling and removes the `any`. Low risk but should be eyeballed since the hook also reads this query's cache key elsewhere (key is unchanged, so cache stays compatible).
### 69. [LOW/safe-auto] Duplicate require2FAOptions factory (dead copy in dashboard.ts) and unused deprecated systemSettingsOptions alias
- **dimension:** rq-data
- **where:** src/admin/lib/queries/dashboard.ts:12; src/admin/lib/queries/settings.ts:82; src/admin/lib/queries/settings.ts:80
- **problem:** `require2FAOptions` is defined identically in two query files — dashboard.ts:12 and settings.ts:82 — both with the same key `["settings","2fa"]` and same URL. Both consumers (Dashboard.tsx:10, Settings.tsx:14) import the settings.ts copy, so the dashboard.ts copy is dead code. Because keys match it is not a cache-collision bug, but two sources of truth for one query is a maintenance hazard (edit one, miss the other). Separately, settings.ts:80 exports a `/** @deprecated */ systemSettingsOptions = systemInfoOptions` alias that has no importers (only systemInfoOptions is used).
- **fix:** Delete the duplicate require2FAOptions from dashboard.ts:12 (keep the settings.ts canonical one). Delete the unused deprecated systemSettingsOptions alias at settings.ts:80. Both are confirmed unreferenced by grep, so removal is mechanical and behavior-free.
### 70. [LOW/needs-judgment] Inconsistent filter-key serialization across list query factories
- **dimension:** rq-data
- **where:** src/admin/lib/queries/invoices.ts:129; src/admin/lib/queries/invoices.ts:157; src/admin/lib/queries/trips.ts:40; src/admin/lib/queries/warehouse.ts:194; src/admin/lib/queries/projects.ts:45
- **problem:** List factories are inconsistent in how the filters object becomes part of the query key. Most spread the raw filters object directly (invoices.ts:129 `["invoices","list",filters]`; warehouse, projects, offers, audit-log do the same), but trips.ts:40 and invoices received (invoices.ts:157) instead spell out an explicit object literal of the same fields. Functionally both work (React Query hashes the key deterministically and ignores object key order), but the inconsistency is gratuitous: the explicit form silently drops any new filter field that isn't manually listed, whereas the spread form auto-includes it. Not a bug today, just a divergence and a latent footgun for the spelled-out ones.
- **fix:** Pick one convention (spreading the filters object is the simpler, more future-proof choice) and apply it uniformly. Low priority — cosmetic consistency, no behavior change. Verify each endpoint's filters object contains only key-relevant fields before spreading.
### 71. [LOW/needs-judgment] Wrong TOTP code does not increment failed-login attempts (relies on rate limit only)
- **dimension:** security
- **where:** src/routes/admin/auth.ts:160-163; src/services/system-settings.ts
- **problem:** In the /login/totp handler, an invalid TOTP code returns 'Neplatný TOTP kód' (line 161-163) without touching failed_login_attempts or locked_until. The password stage (auth.ts service) and the backup-code endpoint (totp.ts:315-335) both implement lockout, but the primary TOTP step does not. The only protection against TOTP brute force after a valid login_token is the per-minute rate limit (config.rateLimit.loginTotp, default 5/min). The login_token lives 5 minutes and a fresh one can be re-minted by re-submitting the password, so a determined attacker who has the password gets a sustained ~5 guesses/minute against a 6-digit (1M) space without ever tripping account lockout.
- **fix:** On invalid TOTP code in /login/totp, increment failed_login_attempts inside a transaction and set locked_until when it reaches max_login_attempts, mirroring the backup-verify logic. This makes the second factor enforce the same lockout policy as the first.
### 72. [LOW/needs-judgment] Server-side PDF endpoints return attacker-influenceable HTML as same-origin text/html
- **dimension:** security
- **where:** src/routes/admin/invoices-pdf.ts:1076; src/routes/admin/offers-pdf.ts:764; src/routes/admin/invoices-pdf.ts:521; src/routes/admin/offers-pdf.ts:357
- **problem:** In non-save mode, the invoices-pdf and offers-pdf routes render the full document HTML and send it with reply.type('text/html').send(html) directly to the browser (not application/pdf). The notes/scope content is user-controlled rich text. It IS sanitized (DOMPurify.sanitize wrapped by cleanQuillHtml), so this is defense-in-depth rather than an open hole, but serving same-origin HTML built from user data is fragile: any future regression in the sanitizer, or any unescaped interpolation added to these large templates, becomes stored XSS on the app origin. The production CSP (security.ts:24-36) does mitigate inline script, but these PDF responses are served by the same Fastify app and inherit that origin.
- **fix:** Prefer returning application/pdf (render via htmlToPdf) to the client instead of raw HTML, as the orders-pdf route already does (orders-pdf.ts:889-892). If an HTML preview must be served, consider a Content-Disposition or a sandboxed delivery path. At minimum, keep the DOMPurify + cleanQuillHtml layering and avoid adding any non-escapeHtml interpolation of DB/user fields into these templates.
### 73. [LOW/needs-judgment] Scope/notes rich-text is sanitized only at render time, never on write
- **dimension:** security
- **where:** src/routes/admin/invoices-pdf.ts:521; src/routes/admin/orders-pdf.ts:409; src/admin/pages/InvoiceDetail.tsx:1206; src/admin/pages/OrderDetail.tsx:687
- **problem:** Invoice notes and quotation/order scope HTML are stored raw in the DB and sanitized at every read/render site (3 PDF routes + 2 frontend render sites). This is consistent and currently complete, but it means correctness depends on every present and future consumer remembering to sanitize. Any new feature that renders these fields (e.g. an email body, a new export, an admin preview) without DOMPurify reintroduces stored XSS. Sanitizing on write would make the stored value safe-by-default for all consumers.
- **fix:** Consider sanitizing rich-text scope/notes once on the write path (in the quotations/orders/invoices create+update services or Zod transforms) so stored data is already clean, treating render-time sanitization as defense-in-depth. Not urgent given current coverage is complete; flagging so the invariant is documented and enforced centrally.
### 74. [LOW/needs-judgment] HSTS header omits preload and is gated entirely on APP_ENV=production
- **dimension:** security
- **where:** src/middleware/security.ts:18-22; src/middleware/security.ts:23-36
- **problem:** Strict-Transport-Security is set to 'max-age=31536000; includeSubDomains' without 'preload', and both HSTS and the full CSP are only emitted when config.isProduction (APP_ENV==='production'). The max-age/includeSubDomains is good, but without preload the first-visit TLS-strip window remains. More notably, all CSP protection is absent in any non-production deployment (e.g. a staging box on real DNS), so the same app code runs without script-src/frame-ancestors restrictions there.
- **fix:** Add 'preload' to the HSTS value if the domain is (or will be) submitted to the preload list. Consider emitting at least a baseline CSP and the security headers in non-local non-production environments too, so staging/preprod is not left unprotected.
### 75. [LOW/needs-judgment] TOTP backup-code comparison does not stop at first match (minor timing/clarity)
- **dimension:** security
- **where:** src/routes/admin/totp.ts:307-312
- **problem:** The backup-code verify loop runs bcrypt.compare against every stored hash and records matchIndex but never breaks, so it always performs all 8 comparisons. This is intentional-looking constant-time behavior, but the loop also lets a later match overwrite an earlier one and does an unnecessary 8x bcrypt work per attempt. With bcryptCost=12, 8 comparisons per submission under a 5/min rate limit is a small but real CPU amplification, and the non-break pattern is easy to misread. The CLAUDE.md error-handling rule is not violated here; this is a robustness nit.
- **fix:** Either document that the full-scan is deliberate constant-time, or break on first match. Given backup codes are unique random values, breaking on match is safe and reduces bcrypt load; combine with the existing lockout to keep brute-force resistance.
### 76. [LOW/safe-auto] Error-message language mixed Czech/English within schemas
- **dimension:** schemas
- **where:** src/schemas/orders.schema.ts:9,16,26,37,45,71,81,106; src/schemas/offers.schema.ts:9,16,26,37,56,87; src/schemas/invoices.schema.ts:10,19 (thrown Error strings)
- **problem:** Project convention is Czech for user-facing messages. Most schemas follow this, but orders.schema.ts and offers.schema.ts mix English `{ message: 'Must be a valid number' }` (orders:9,16,26,37,71,81,106; offers:9,16,26,37,56,87) alongside Czech `'Musí být platné číslo'` (orders:55,60; offers:47) in the SAME file for the same validation. parseBody surfaces issue.message directly to the client (common.ts:12), so end users can see the English strings. invoices.schema.ts:10,19 also throw English `'Invalid quantity'`/`'Invalid unit_price'`.
- **fix:** Translate the English validation messages to Czech to match the rest of the codebase (e.g. 'Musí být platné číslo'). If a shared numeric helper is introduced (finding 1), it can carry one canonical Czech message and eliminate the divergence.
### 77. [LOW/safe-auto] Inline route schemas use Zod-3-deprecated .strict()/.passthrough()
- **dimension:** schemas
- **where:** src/routes/admin/audit-log.ts:15-20; .strict(); src/routes/admin/orders-pdf.ts:15-30; .passthrough()
- **problem:** The only two object-mode modifiers in the codebase use the Zod 3 forms `.strict()` (audit-log.ts:20) and `.passthrough()` (orders-pdf.ts:30). Per the Zod 4 changelog (the project is on zod ^4.3.6) these are deprecated in favor of the top-level `z.strictObject({...})` and `z.looseObject({...})`. They still work but are flagged for removal and are inconsistent with a Zod-4 codebase.
- **fix:** Migrate to `z.strictObject({ days: ..., confirm: ... })` and `z.looseObject({ items: ... })` respectively. Mechanical, no behavior change.
### 78. [LOW/needs-judgment] No schemas use strict object mode — unknown keys silently stripped everywhere
- **dimension:** schemas
- **where:** src/schemas/common.ts:8 (schema.parse, default strip); all src/schemas/*.schema.ts object definitions
- **problem:** Every body schema is a default `z.object(...)`, which strips unknown keys silently. Only audit-log.ts uses `.strict()`. For mutation endpoints this means a client typo (e.g. `is_activ` instead of `is_active`) is silently dropped rather than rejected, masking client bugs. This is a deliberate-tradeoff call (lenient APIs are sometimes intentional), so flagged as low, but the near-total absence of strictness combined with the broad Create/Update surface is worth a conscious decision rather than an accident.
- **fix:** Decide on a policy: either keep lenient (document it) or adopt `z.strictObject` for Create/Update bodies to catch client mistakes early. If adopting strict, do it via the shared pattern so it is uniform; watch for fields the frontend sends but the schema omits (audit before flipping).
### 79. [LOW/needs-judgment] Duplicated ScopeSection/Item sub-schemas across offers/orders/scope-templates
- **dimension:** schemas
- **where:** src/schemas/offers.schema.ts:3-39 (QuotationItemSchema, ScopeSectionSchema); src/schemas/orders.schema.ts:3-39 (OrderItemSchema, OrderSectionSchema); src/schemas/scope-templates.schema.ts:3-11 (ScopeSectionSchema); src/schemas/invoices.schema.ts:3-34 (InvoiceItemSchema)
- **problem:** QuotationItemSchema (offers:3-28) and OrderItemSchema (orders:3-28) are byte-for-byte identical. OrderSectionSchema (orders:30-39), ScopeSectionSchema (offers:30-39) and ScopeSectionSchema (scope-templates:3-11) are the same shape (title/title_cz/content/position) duplicated three times. These line/quote/section structures are the same domain concept reimplemented per file, so a change to e.g. position validation must be made in 3-4 places.
- **fix:** Extract a shared `LineItemSchema` and `ScopeSectionSchema` (e.g. in common.ts or a schemas/shared.ts) and import them. Note invoices.schema.ts:6-12 deliberately diverges (throws on quantity<=0), so keep that one specialized or make the shared schema configurable.
### 80. [LOW/needs-judgment] buildApp and token helpers duplicated instead of reusing helpers.ts
- **dimension:** tests part2b
- **where:** src/__tests__/helpers.ts:7; src/__tests__/warehouse.test.ts:26; src/__tests__/plan.test.ts:649
- **problem:** helpers.ts exports buildApp for auth and totp routes only. warehouse.test.ts and plan.test.ts each redefine their own buildApp, generateToken, and auth header helpers with slightly different setup, since warehouse adds securityHeaders and helpers does not. The token-minting and app-bootstrap logic now lives in three places and will drift, and a token minted wrong in one file is not caught by the others.
- **fix:** Extract a shared buildApp that takes routes and options plus one token and auth-header helper into helpers.ts and import everywhere, then re-run the full suite since it changes test bootstrap.
### 81. [LOW/safe-auto] Test named soft delete actually asserts a hard delete that its own comment flags as buggy
- **dimension:** tests part2b
- **where:** src/__tests__/warehouse.test.ts:630; src/__tests__/warehouse.test.ts:637
- **problem:** The test titled deactivates an item soft delete asserts a 404 after delete. Its own comment at lines 636 to 638 notes that the route hard-deletes the row and calls this inconsistent with the supplier soft-delete pattern, so the misleading name hides the discrepancy from future readers.
- **fix:** Rename the test to hard-deletes an item, and if soft-delete is the intended contract convert it to a todo or expected-fail test documenting the desired behaviour. Confirm the decision with the team.
### 82. [LOW/needs-judgment] `Record<string, any>` / untyped record builders in attendance PDF/HTML construction
- **dimension:** ts-safety
- **where:** src/admin/hooks/useAttendanceAdmin.ts:350; src/admin/hooks/useAttendanceAdmin.ts:382; src/admin/hooks/useAttendanceAdmin.ts:397; src/admin/hooks/useAttendanceAdmin.ts:598; src/services/offers.service.ts:49; src/services/orders.service.ts:41
- **problem:** Several HTML/PDF/total-calculation builders take `Record<string, any>` and iterate with `(log: any)`/`(i: any)` (useAttendanceAdmin buildUserSectionHtml/recordsByDate; offers `enrichQuotation`, orders `enrichOrder`). These produce HTML that is interpolated into PDF/print output, so an `any`-typed field that is unexpectedly an object or undefined can render `[object Object]` or `NaN` totals with no compile-time signal. Lower severity than the auth bug because output is internal documents, but it is the broadest untyped surface after the plan stack.
- **fix:** Define narrow input interfaces for the attendance record and quotation/order item shapes (the Prisma row types or the existing schemas can be reused) and replace the `Record<string, any>` parameters. At minimum type the `.map`/`.reduce` callbacks (`i`, `log`) with the item type to catch field-name and numeric-coercion mistakes.
### 83. [LOW/safe-auto] `category: input.category as any` casts bypass the Prisma category column type
- **dimension:** ts-safety
- **where:** src/services/plan.service.ts:451; src/services/plan.service.ts:518; src/services/plan.service.ts:639; src/services/plan.service.ts:710
- **problem:** Four `create`/`update` calls cast `input.category` (a plain `string`) to `any` to satisfy the Prisma `category` column type. Validity is enforced at runtime via `assertActiveCategory` (a DB lookup), so it's not unsafe in practice, but the `as any` discards the compiler's check and would also silence a future schema type change for this column.
- **fix:** If the Prisma column is an enum, cast to that specific enum type (e.g. `Prisma.$Enums.<Name>`) instead of `any`; if it is a `String` column, the cast is unnecessary and can be removed. Either way avoid `as any` so the field stays type-checked.
### 84. [LOW/manual] `request.authData!` non-null assertions: acceptable post-auth pattern (no action)
- **dimension:** ts-safety
- **where:** src/routes/admin/attendance.ts:30; src/middleware/auth.ts:44; src/routes/admin/plan.ts:119; src/routes/admin/warehouse.ts:1066; src/routes/admin/quotations.ts:139
- **problem:** Non-null assertions are widespread but almost entirely `request.authData!` inside handlers guarded by `requirePermission`/`requireAuth`, plus `result.error!`/`result.status!` after an `"error" in result` narrowing. These are correct given the middleware contract; the Fastify `authData` is declared optional on the request type but is always populated after the auth preHandler. Flagging only to note it was reviewed and is intentional, not a defect.
- **fix:** No change required. If you want to eliminate the `authData!` assertions, declare an `AuthenticatedRequest` type (or a module augmentation making `authData` required) for routes mounted behind the auth hook, so the `!` becomes unnecessary. Optional, cosmetic.

View File

@@ -0,0 +1,180 @@
# Cross-Module Consistency Report: Offers, Invoices, Orders
> Generated 2026-06-09 by a multi-agent UI/UX audit (4 by-dimension examiners + synthesis)
> across the Offers (nabídky), Invoices (faktury), and Orders (objednávky) modules.
> Examination only — no code was changed. File:line refs are at the time of the audit.
## 1. Executive Summary
The three document modules (Offers, Invoices, Orders) share a strong common foundation — `PageEnter`/`PageHeader`/`Card`/`FilterBar`/`DataTable`/`StatusChip`/`Pagination`, a near-identical line-item editor (dnd-kit drag, mobile cards, monospace per-row totals, per-rate VAT breakdown), and a uniform totals layout — so the divergences are concentrated in a handful of presentational and behavioral details rather than the architecture. **No single module is canonical across the board:** Invoices is the most mature on list-level intelligence (KPI StatCards, overdue row tinting) and is the only one with a polished load pattern (ReceivedInvoices' `hasLoadedOnce` skeleton suppression); Issued Orders is the cleanest detail-page reference (single editable form with a `readOnly` prop, semantically correct status-transition button colors); and Offers is the best on list-level state richness (three-tier `rowSx` tinting, search-aware empty states) but the worst on detail-form fidelity (raw HTML checkboxes, action-icon overload). The highest-value work clusters into four buckets: bring Orders up to Invoices' list intelligence (KPIs, filters, row tinting), converge the three detail forms on shared MUI kit components (Autocomplete customer picker, MUI Checkbox, RichEditor notes), fix two genuine safety/feature bugs (delete-button guards and Orders' missing delete), and a long tail of cheap label/width/alignment unifications in the line-item tables.
## 2. Recommended Canonical Patterns
| Dimension | Standardize on | Why |
| ------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| List KPI cards | **Invoices** (`Invoices.tsx:420-505`) | Only module with a 4-card metrics grid; gives business context Orders/Offers lack |
| List load/skeleton | **ReceivedInvoices** (`hasLoadedOnce` ref, lines 325/688/784) | Skeleton only on first mount, suppressed on refetch — no full-page blocking jank |
| List row state tinting | **Offers** (`Offers.tsx:650-684`) three-tier `rowSx` | Richest at-a-glance lifecycle signal; Invoices' single overdue tint is the minimal version |
| List status filter | **Tabs** for ≤4 states (Offers/Invoices), **Select** for ≥5 (IssuedOrders) | Tabs show all options inline; Select scales — choose by option count |
| Search-aware empty state | **Offers** (`Offers.tsx:840-855`) | Distinguishes search-empty (no CTA) from list-empty (with CTA) |
| Detail read-only mode | **IssuedOrderDetail** (single form + `readOnly` prop, `:638`) | DRY; avoids InvoiceDetail's duplicate read-only JSX branch that must be hand-synced |
| Status-transition button colors | **IssuedOrderDetail** (`:903-917`) | Destructive = `outlined`+`error`, positive = `contained`+`primary` (semantically correct) |
| Save-button loading | **Invoice/Order** (CircularProgress + text) | Clearer visual signal than Offers' text-only swap |
| Detail guard placement | **OfferDetail** (all guards top-level, `:1087-1091`) | Flattest; no `if (!dataReady)` buried inside render branches |
| Customer/supplier picker | **MUI Autocomplete freeSolo** (as in `ReceivedInvoices.tsx:914-929`) | Keyboard-accessible, searchable, Material-standard |
| Boolean fields | **MUI `CheckboxField` kit** (as in OrdersReceived modal) | Themed, accessible; replaces raw `<input type=checkbox>` |
| Notes fields | **RichEditor** (Offers/Orders already) | Uniform editing; future-proof for formatting |
| `issued_by` | **Read-only, auto-filled** (Invoices, `:1881-1885`) | Immutable provenance tied to the issuing user |
| Currency options | **Company settings** (Offers/Invoices) | Admin-configurable without code change |
| Line-item labels/widths | **Invoices+Orders majority** ("Jedn. cena", qty `5.5rem`, remove `2.5rem`, `align="center"`) | 2-of-3 already agree; Offers is the lone outlier |
| Amount column header | **"Celkem"** (all but ReceivedInvoices' "Částka") | Single label for the same concept |
## 3. Inconsistencies by Dimension
### A. Tables & Lists
**HIGH — Orders lack KPI StatCards.** Invoices renders a 4-card metrics grid (`Invoices.tsx:420-505`; ReceivedInvoices `:687-761`); IssuedOrders, OrdersReceived, and Offers render none. Orders users lose pipeline visibility (count by status, total value). Fix: add a 4-StatCard grid to IssuedOrders/OrdersReceived mirroring the Invoices layout. Decide whether Offers warrants its own. Effort: **M**.
**HIGH — Offers row-action overload (6-7 icons vs 3).** `Offers.tsx:540-641` renders View/Edit, Duplicate, Show/Create Order, Invalidate, PDF, Delete; Invoices/IssuedOrders/OrdersReceived keep 3. Fix: reduce Offers to 4 (View/Edit, Order toggle, PDF, Delete); move Duplicate/Invalidate into a row overflow menu or the detail page. Effort: **M**.
**MED — Orders missing list features that Invoices/Offers have.**
- _Row state tinting:_ Offers 3-tier (`:650-684`), Invoices 1-tier overdue (`:510-526`), all three Orders modules + ReceivedInvoices = none. Add `rowSx` (completed→success wash, overdue/expired→warning, voided→opacity) using the channel-alpha convention. Effort: **S** each.
- _Status filter:_ OrdersReceived has none; add a Select like IssuedOrders (`:305-313`, `STATUS_OPTIONS` `:53`). Effort: **S**.
- _Sortable columns:_ IssuedOrders/OrdersReceived expose only 3 `sortKey`s vs 5-6 in Invoices/Offers; add customer_name, total, status. Effort: **S**.
**MED — Load pattern blocks the page on refetch.** Offers (`:453`), Invoices (`:402`), IssuedOrders (`:189`), OrdersReceived (`:328`) return a full `<LoadingState/>` whenever `isPending`; only ReceivedInvoices suppresses the skeleton after first load (`hasLoadedOnce`, `:325/688/784`). Adopt the `hasLoadedOnce` ref pattern everywhere. Effort: **S-M**.
**MED — Amount column label split.** ReceivedInvoices uses "Částka"; everyone else "Celkem". Rename "Částka"→"Celkem". (supplier-vs-customer terminology "Dodavatel"/"Zákazník" is correctly domain-specific — leave it.) Effort: **S**.
**LOW — Offers tabs not centered.** Invoices/Orders wrap Tabs in a centered Box (`Invoices.tsx:712-721`, `Orders.tsx:161-170`); Offers (`:717-726`) left-aligns. Effort: **S**.
**LOW — Empty-state copy not search-aware.** Offers distinguishes search-empty vs list-empty (`:840-855`); the others show one message (+ stale CTA when filtered to empty). Effort: **S**.
_Already fine: PageHeader, FilterBar layout, Pagination placement, StatusChip colors, search-placeholder copy, draft-banner presence (Offers/Invoices by design)._
### B. Detail Buttons & Layout
**HIGH — Delete-button safety bugs + Orders feature gap.**
- OfferDetail (`:1191-1199`) allows delete even when invalidated/locked/completed.
- InvoiceDetail (`:1167-1175`, `:1734-1742`) allows delete even when `paid`.
- IssuedOrderDetail has **no delete at all** (no button/mutation/handler).
Fix: add `!readOnly`/status guards to Offers and Invoices; add a guarded delete to IssuedOrderDetail for parity. Effort: **M**.
**HIGH — Detail read-only handling diverges.** IssuedOrderDetail uses one form with a `readOnly`/`editable` flag (`:638`); InvoiceDetail forks a whole separate read-only JSX branch for paid invoices (`:1104-1615`) that must be hand-synced; OfferDetail is always editable. Fix: refactor InvoiceDetail's paid view to the single-form-`readOnly`-prop model. Effort: **L**.
**MED — Status-transition button styling inverted.** InvoiceDetail emphasizes positive ("paid"→`contained`+`primary`, `:1719-1733`); IssuedOrderDetail emphasizes danger ("cancelled"→`outlined`+`error`, `:903-917`). Standardize on the Order rule. Effort: **S**.
**MED — Back-button target inconsistent.** OfferDetail→`/offers` (`:1108`), InvoiceDetail→`/invoices` (`:1124`/`:1639`), IssuedOrderDetail→`/orders?tab=vydane` (`:836`). Make tabbed-landing modules carry the tab param: Invoices→`/invoices?tab=issued`. Effort: **S**.
**LOW — Save-button loading style.** Offers text-only (`:1187`); Invoice/Order spinner+text. Switch Offers to spinner+text. Effort: **S**.
**LOW — Create-success invalidation scope.** OfferDetail invalidates `["offers","orders","projects","invoices"]` (`:927`); Invoice/Order invalidate only their own domain. Verify Invoice/Order touch no foreign domains, else widen. Effort: **S**.
**LOW — Guard placement.** Hoist Invoice/Order `if (!dataReady)` guards to OfferDetail's top-level pattern when touching those files. Effort: **S**.
_Already fine: PageEnter wrapper, header layout + `headerActionsSx`, StatusChip-in-header, mobile breakpoints, Card sectioning, items-table structure, totals block, permission guards._
### C. Forms & Fields
**HIGH — Customer/supplier picker: 3 different widgets.** Offers (`OfferDetail.tsx:1307-1373`) and Invoices (`InvoiceDetail.tsx:1816-1876`) use a hand-rolled searchable dropdown; IssuedOrders (`:935-948`) uses a plain non-searchable `<Select>`; ReceivedInvoices (`:914-929`) uses MUI `Autocomplete freeSolo`. Standardize all on `Autocomplete freeSolo`. Effort: **M**.
**HIGH — `apply_vat` boolean: MUI Checkbox vs raw `<input>`.** Offers uses MUI `Checkbox` (`:1482-1488`); InvoiceDetail (`:2008-2017`) and IssuedOrderDetail (`:1023-1035`) use raw `<input type="checkbox">`. Standardize on the kit `CheckboxField`. Effort: **S**.
**HIGH — Notes field: TextField vs RichEditor.** Offers (`:1953-1966`) and Orders (`:1257-1272`) use RichEditor; Invoices (`:2232-2240`) and ReceivedInvoices modal (`:1214-1225`) use plain multiline TextField. Move Invoices notes to RichEditor — and per CLAUDE.md gotcha #5, apply DOMPurify + `cleanQuillHtml` sanitization on both the PDF and render paths. Effort: **M**.
**MED — `issued_by` editable in Orders, read-only in Invoices.** Make Orders read-only + auto-filled from auth. Effort: **S**.
**MED — VAT rate/toggle layout differs.** Adopt Offers' dual-field (rate Select + toggle) where a per-document rate applies. Effort: **M**.
**LOW — Currency options hard-coded in Orders.** IssuedOrders (`:96-99,985`) and OrdersReceived modal (`:575-580`) hard-code; point at company settings. Effort: **S**.
_Already fine: `<Field>` wrapper + error display, DateField usage, totals footer, Card grouping, basic-info grid, delivery/payment terms being Orders-only (domain-justified)._
### D. Line-Item Editors
Offers is the consistent outlier; Invoices+Orders already agree — canonicalize on them. Cheap, mostly-mechanical edits in `OfferDetail.tsx` (plus one internal Invoices fix).
**MED — Unit-price label.** Offers "Cena/ks" (`:336`, `:1625`) → "Jedn. cena". Effort: **S**.
**MED — Unit-price + quantity header alignment/width.** Offers default-left, qty `5rem``align="center"`, qty `5.5rem`. Also InvoiceDetail's paid read-only header uses `align="right"` (`:1429`) — switch to `center`. Effort: **S**.
**MED — Grand-total label.** Invoices "Celkem k úhradě:" is domain-appropriate (payment context) — **keep, intentional.**
**MED — Editable VAT-column header.** Unify the two editable forms on "DPH"; align Invoices' paid-view header. Effort: **S**.
**LOW — Remove-button cell width.** Offers `3rem``2.5rem`. Effort: **S**.
_Already fine: description/detail multiline fields (rows=2, vertical resize), unit-price column width `8rem`, per-row monospace total, mobile card metrics, dnd-kit drag config, totals area layout, per-rate VAT breakdown._
## 4. Suggested Fix Order
**Batch 1 — Mechanical quick wins (low judgment, low risk).**
1. Line-item unifications in `OfferDetail.tsx`: "Cena/ks"→"Jedn. cena", qty width `5rem``5.5rem`, `align="center"` on qty/unit-price headers, remove-button cell `3rem``2.5rem`. Plus in-Invoices fixes (paid-view unit-price `right``center`; "%DPH"→"DPH").
2. List: "Částka"→"Celkem" (ReceivedInvoices); center Offers tabs.
3. Detail: Offers save button → spinner+text; standardize status-transition button colors (Order rule); back-button tab params (Invoices→`?tab=issued`).
4. Forms: `apply_vat` raw `<input>` → kit `CheckboxField` in Invoice/Order; Orders currency Select → company settings; Orders `issued_by` read-only+auto-filled.
**Batch 2 — Small behavioral additions (per-file logic, light judgment).** 5. Add `rowSx` state tinting to the three Orders modules (+ optionally ReceivedInvoices). 6. Add OrdersReceived status-filter Select; widen IssuedOrders/OrdersReceived `sortKey` coverage. 7. Adopt `hasLoadedOnce` skeleton-suppression in Offers/Invoices/IssuedOrders/OrdersReceived. 8. Search-aware empty states (Offers pattern) in Invoices/IssuedOrders/OrdersReceived.
**Batch 3 — Safety/feature bug fixes (need judgment, preserve data logic).** 9. Delete-button guards: add `!readOnly`/status checks to Offers + Invoices; add a guarded delete to IssuedOrderDetail for parity. 10. Verify create-success invalidation scope for Invoice/Order; widen only if they touch foreign domains.
**Batch 4 — Shared-component & layout refactors (largest, most judgment).** 11. Replace the three customer/supplier pickers with one `Autocomplete freeSolo` (consider a shared `CustomerPicker` kit component). 12. Move Invoices notes to RichEditor + wire DOMPurify/`cleanQuillHtml` on PDF + render paths. 13. Unify VAT rate/toggle layout on Offers' dual-field pattern. 14. Add KPI StatCard grids to IssuedOrders/OrdersReceived (decide metrics; consider an Offers variant). 15. Refactor InvoiceDetail's separate paid read-only branch into the single-form-`readOnly`-prop model; hoist guards to top-level. 16. Reduce Offers row actions to 4 (move Duplicate/Invalidate to overflow menu/detail).
## 5. Already Consistent — Do Not Touch
- **Shell & layout:** `PageEnter`, `PageHeader`, `Card` sectioning, `FilterBar`, header layout + `headerActionsSx`, StatusChip-in-header, mobile breakpoints.
- **Lists:** DataTable rendering, Pagination placement, StatusChip semantic colors, search-placeholder copy, draft-banner presence (by design).
- **Forms:** `<Field>` wrapper + error display, DateField, totals footer, basic-info grid, delivery/payment terms being Orders-only.
- **Line items:** description/detail multiline (rows=2, vertical resize), unit-price width `8rem`, per-row monospace total, mobile card metrics, dnd-kit drag, totals layout, per-rate VAT breakdown.
- **Cross-cutting:** success/error handling, `formatCurrency`, permission guards.
**Intentional differences to preserve:** Invoices' "Celkem k úhradě:" grand-total wording (payment context) and the supplier-vs-customer ("Dodavatel"/"Zákazník") terminology.
---
# Part 2 — Status/State Models + Draft Persistence (follow-up audit)
> Generated 2026-06-09 by a second multi-agent audit (status models + draft persistence).
## Summary
The five document types share the same conceptual lifecycle (draft → active → terminal/paid) but agree on almost nothing in implementation. State is stored two ways (raw `String(30)` vs Prisma enum), keyed in two languages (Czech for customer-orders, English for everything else), enforced with three transition styles, and rendered through **six separately-maintained label/color maps that have already drifted** (the same `issued` status is amber in the list, blue in the detail). Draft persistence works end-to-end only for Offers, is **half-wired for Issued Invoices (a banner promises a draft that is never written)**, and is absent everywhere else. The theme is **copy-paste divergence with no shared source of truth**.
## State-model comparison
| Document | Storage | Key language | #States | Transitions | Auto-transitions | Edit-lock | Color consistency |
| ------------------------------------ | -------------------------- | ------------------------------------------------------------ | ------- | -------------------------------- | ---------------------------------------- | -------------------------------------- | ------------------------------------------------------ |
| Offer (quotations) | `String(30)` def `active` | mixed (`active`/`ordered`/`invalidated`) | 3 | none (manual; `invalidateOffer`) | none | locked only when `invalidated` | **no label/color map** (tabs only) |
| Customer Order (orders) | `String(30)` def `prijata` | **Czech** (`prijata`/`v_realizaci`/`dokoncena`/`stornovana`) | 4 | `VALID_TRANSITIONS` | → project status sync | items lock in `dokoncena`/`stornovana` | consistent list↔detail |
| Issued Order (issued_orders) | **enum** def `draft` | English (`draft`/`sent`/`confirmed`/`completed`/`cancelled`) | 5 | `VALID_TRANSITIONS` | none | full lock outside `draft`/`sent` | consistent |
| Issued Invoice (invoices) | `String(30)` def `issued` | English (`issued`/`overdue`/`paid`) | 3 | `VALID_TRANSITIONS` | `markOverdueInvoices()` + auto paid_date | full lock in `paid` | **DRIFTED**: `issued`=warning in list, =info in detail |
| Received Invoice (received_invoices) | **enum** def `unpaid` | English (`unpaid`/`paid`) | 2 | implicit (no revert) | auto paid_date, month/year | locks in `paid` | consistent (list only) |
## Intentional (KEEP) vs Accidental (FIX)
**Domain-justified — keep:** different state counts per document (orders need 5, received invoices need 2); Offers' transition-free lifecycle (document it); the customer-order→project sync and invoice auto-overdue business rules; stricter edit-lock on terminal states.
**Accidental — fix:**
- Czech vs English status keys (customer-orders are the lone Czech holdout — legacy-PHP carryover).
- String vs enum storage (orders/quotations/invoices loose `String`; issued_orders/received_invoices typed enums).
- Divergent semantic color for the same status (`issued` = warning vs info).
- Six duplicated, drifting `STATUS_LABELS`/`STATUS_COLORS` maps; Offers has none.
- Duplicated draft-key constants (`boha_offer_draft` defined in two files).
- **Half-wired issued-invoice draft** (banner reads a key that's never written; `clearDraft` is dead code) — a live user-facing bug.
- Cross-record draft leakage (`boha_offer_draft` not record-scoped).
- Auto-transition logic placed in three different homes (service vs scheduled fn vs route handler).
- Arbitrary draft-banner visibility heuristics.
## Recommendations
- **Status keys → English** (4/5 already English; matches CLAUDE.md identifier convention). Rename customer-order DB values (`prijata``received`, `v_realizaci``in_progress`, `dokoncena``completed`, `stornovana``cancelled`) + the `ORDER_TO_PROJECT_STATUS` keys. **Needs DB migration + backfill.**
- **Storage → Prisma enums** for invoices/orders/quotations (match the other two). **Migration**, mechanical; do in the same migration as the rename.
- **One shared `src/admin/lib/documentStatus.ts`** exporting per-document `STATUS_LABELS` + `STATUS_COLORS` (Czech labels, MUI colors), consumed everywhere — kills all six duplicates, fixes the `issued` color drift in one place (→ `warning`), gives Offers a chip. Fold draft-key constants into a shared `DRAFT_KEYS`. **Frontend-only.**
- **Drafts → uniform or removed; never half-wired.** Recommended: a reusable `useDocumentDraft(key, { recordId })` hook (record-scoped, debounced autosave, restore-on-init, unified banner) applied to all create/edit forms; finish the stubbed invoice banner. Acceptable cheaper alternative: delete drafts entirely. Either way, fix the half-wired invoice banner. **Frontend-only.**
- **Transition placement → one home** (service layer, never the route). **Backend-only, no migration.**
## Effort + risk
- **Frontend-only (cheap, safe, do now):** shared `documentStatus.ts` (+ fix `issued` color drift, + Offers chip), fix the half-wired invoice draft, record-scoped `useDocumentDraft` hook + leakage guard + banner unification.
- **Backend, no migration (low risk, opportunistic):** centralize auto-transition logic into services; document Offers' transition-free lifecycle.
- **Backend + DB migration (higher risk, deferrable, do as ONE planned tested change):** Czech→English customer-order status rename (DB values + transition map + project-sync keys + frontend maps in lockstep, with `UPDATE` backfill); String→enum conversion for invoices/orders/quotations. **Not correctness blockers** (the String columns work today) — schedule deliberately.

View File

@@ -0,0 +1,680 @@
# MUI Migration — Phase 0: Theming Foundation — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stand up the MUI theming foundation — install MUI, build `theme.ts` (light+dark from existing tokens), wire the provider + `data-theme` color-scheme bridge + `ThemeContext` sync, apply the token-level palette refresh app-wide, and ship a dev-only `/ui-kit` route proving the theme on the first wrappers (`Button`/`Card`/`TextField`).
**Architecture:** MUI's CSS-variable theme is configured with `colorSchemeSelector: "[data-theme='%s']"` so it reads the same `data-theme` attribute `ThemeContext` already writes — one toggle drives both MUI and legacy CSS. A tiny sync component mirrors the attribute into MUI's JS color-scheme state. `ScopedCssBaseline` scopes MUI's reset to migrated subtrees (here, only `/ui-kit`); legacy pages keep `base.css`.
**Tech Stack:** React 18.3, Vite 8, TypeScript (strict), MUI v7 (`@mui/material`) + Emotion + MUI X v8 pickers, Vitest (node).
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§3, §4, §7 token-level layer, §8 Phase 0).
**Scope note:** This plan is Phase 0 ONLY. The rest of the `ui/` kit (Modal, Toast, Select, Switch, Tabs, DataTable, DatePicker), the AppShell (Phase 1), and the Vozidla pilot (Phase 2) are separate plans.
---
## File Structure
| File | Responsibility | Action |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------ |
| `package.json` | MUI/Emotion/X-pickers deps; `@types/react` reconciled to 18 | Modify |
| `src/admin/theme.ts` | The MUI theme: cssVariables + colorSchemeSelector, light/dark palettes, typography, shape, component overrides | Create |
| `src/admin/theme.test.ts` | Unit test asserting theme palette/typography/shape values | Create |
| `src/admin/ui/MuiProvider.tsx` | Wraps children in MUI `ThemeProvider` + renders the sync component | Create |
| `src/admin/ui/MuiColorSchemeSync.tsx` | Mirrors `useTheme()` (custom) → MUI `useColorScheme().setMode` | Create |
| `src/admin/ui/Button.tsx`, `Card.tsx`, `TextField.tsx` | Thin brand wrappers over MUI (stable app-facing API) | Create |
| `src/admin/ui/index.ts` | Barrel export for the kit | Create |
| `src/admin/pages/UiKit.tsx` | Dev-only showcase, wrapped in `ScopedCssBaseline` | Create |
| `src/admin/AdminApp.tsx` | Mount `MuiProvider`; add DEV-only `/ui-kit` route | Modify |
| `src/admin/variables.css` | Token-level palette refresh (canvas, chip tints) — app-wide | Modify |
---
## Task 1: Install dependencies and reconcile React types
**Files:**
- Modify: `package.json` (via npm)
- [ ] **Step 1: Install MUI + Emotion + X pickers**
Run (date-fns ^4 is already a dependency):
```bash
npm install @mui/material@^7 @emotion/react@^11 @emotion/styled@^11 @mui/x-date-pickers@^8
```
Expected: packages added to `dependencies`; no peer-dependency errors that block install.
- [ ] **Step 2: Reconcile `@types/react` to the React 18 runtime**
The repo runs `react@^18.3.1` but pins `@types/react@^19`. MUI's component types are sensitive to the `@types/react` major. Align the types to the runtime:
```bash
npm install -D @types/react@^18.3 @types/react-dom@^18.3
```
Expected: devDependencies now show `@types/react@^18.3.x`.
- [ ] **Step 3: Verify the project still type-checks and builds**
Run:
```bash
npx tsc -b --noEmit
npm run build:client
```
Expected: both succeed with no errors (this is the pre-change baseline; if `tsc` was already failing, capture that before proceeding).
- [ ] **Step 4: Commit**
```bash
git add package.json package-lock.json
git commit -m "build(mui): add MUI v7 + Emotion + X pickers; align @types/react to 18"
```
---
## Task 2: Create `theme.ts` with light/dark palettes (TDD)
**Files:**
- Create: `src/admin/theme.ts`
- Test: `src/admin/theme.test.ts`
Token values are taken from `src/admin/variables.css` (`[data-theme="light"]` / `[data-theme="dark"]`).
- [ ] **Step 1: Write the failing test**
Create `src/admin/theme.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import { theme } from "./theme";
describe("MUI theme", () => {
it("uses the brand fonts", () => {
expect(theme.typography.fontFamily).toContain("Plus Jakarta Sans");
expect(String(theme.typography.h1.fontFamily)).toContain("Urbanist");
});
it("maps the brand red per color scheme", () => {
expect(theme.colorSchemes.light.palette.primary.main).toBe("#c73030");
expect(theme.colorSchemes.dark.palette.primary.main).toBe("#d63031");
});
it("maps the refreshed light canvas + paper", () => {
expect(theme.colorSchemes.light.palette.background.default).toBe("#f4f3f1");
expect(theme.colorSchemes.light.palette.background.paper).toBe("#ffffff");
});
it("maps semantic colors per scheme", () => {
expect(theme.colorSchemes.light.palette.error.main).toBe("#b91c1c");
expect(theme.colorSchemes.dark.palette.success.main).toBe("#22c55e");
});
it("uses the base radius of 10", () => {
expect(theme.shape.borderRadius).toBe(10);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
npx vitest run src/admin/theme.test.ts
```
Expected: FAIL — `Cannot find module './theme'` (or `theme` is undefined).
- [ ] **Step 3: Implement `theme.ts` (palettes/typography/shape only — overrides come in Task 3)**
Create `src/admin/theme.ts`:
```ts
import { createTheme } from "@mui/material/styles";
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
const FONT_HEADING = "'Urbanist', sans-serif";
const FONT_MONO = "'DM Mono', Menlo, monospace";
export const theme = createTheme({
cssVariables: {
// Read the SAME attribute ThemeContext already writes (`data-theme`).
// The "%s" placeholder is required; value must start with "." or "[".
colorSchemeSelector: "[data-theme='%s']",
},
colorSchemes: {
light: {
palette: {
mode: "light",
primary: { main: "#c73030" },
success: { main: "#15803d" },
warning: { main: "#b45309" },
error: { main: "#b91c1c" },
info: { main: "#1d4ed8" },
background: { default: "#f4f3f1", paper: "#ffffff" },
text: { primary: "#1a1a1a", secondary: "#555555" },
divider: "rgba(0,0,0,0.1)",
},
},
dark: {
palette: {
mode: "dark",
primary: { main: "#d63031" },
success: { main: "#22c55e" },
warning: { main: "#f59e0b" },
error: { main: "#ef4444" },
info: { main: "#3b82f6" },
background: { default: "#0f0f0f", paper: "#1a1a1a" },
text: { primary: "#ffffff", secondary: "#a0a0a0" },
divider: "rgba(255,255,255,0.08)",
},
},
},
shape: { borderRadius: 10 },
typography: {
fontFamily: FONT_BODY,
h1: { fontFamily: FONT_HEADING, fontWeight: 800 },
h2: { fontFamily: FONT_HEADING, fontWeight: 800 },
h3: { fontFamily: FONT_HEADING, fontWeight: 800 },
h4: { fontFamily: FONT_HEADING, fontWeight: 700 },
h5: { fontFamily: FONT_HEADING, fontWeight: 700 },
h6: { fontFamily: FONT_HEADING, fontWeight: 700 },
button: { textTransform: "none", fontWeight: 600 },
},
});
// Re-exported for tasks/components that need the mono stack.
export const fonts = {
body: FONT_BODY,
heading: FONT_HEADING,
mono: FONT_MONO,
};
```
- [ ] **Step 4: Run the test to verify it passes**
Run:
```bash
npx vitest run src/admin/theme.test.ts
```
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
git add src/admin/theme.ts src/admin/theme.test.ts
git commit -m "feat(mui): theme.ts with light/dark palettes, brand fonts, data-theme selector"
```
---
## Task 3: Add component overrides (the "Soft-SaaS" defaults)
**Files:**
- Modify: `src/admin/theme.ts`
- Test: `src/admin/theme.test.ts`
- [ ] **Step 1: Add a failing assertion for the overrides**
Append to `src/admin/theme.test.ts` (inside the `describe`):
```ts
it("makes buttons pill-shaped and cards 16px by default", () => {
const btn = theme.components?.MuiButton?.styleOverrides?.root as any;
expect(btn?.borderRadius).toBe(999);
const card = theme.components?.MuiCard?.styleOverrides?.root as any;
expect(card?.borderRadius).toBe(16);
});
```
- [ ] **Step 2: Run to verify it fails**
Run:
```bash
npx vitest run src/admin/theme.test.ts
```
Expected: FAIL — `theme.components` is undefined / `borderRadius` is undefined.
- [ ] **Step 3: Add `components` to the `createTheme` call**
In `src/admin/theme.ts`, add a `components` key to the `createTheme({...})` object (after `typography`):
```ts
components: {
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: { borderRadius: 999, paddingInline: "0.95rem" },
containedPrimary: {
backgroundImage: "linear-gradient(135deg, #e23a3a, #c01f1f)",
boxShadow: "0 5px 14px rgba(214,48,49,0.32)",
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 16,
boxShadow:
"0 6px 20px rgba(20,20,40,0.06), 0 1px 2px rgba(0,0,0,0.03)",
},
},
},
MuiChip: {
styleOverrides: { root: { borderRadius: 999, fontWeight: 700 } },
},
},
```
- [ ] **Step 4: Run to verify it passes**
Run:
```bash
npx vitest run src/admin/theme.test.ts
```
Expected: PASS (6 tests).
- [ ] **Step 5: Commit**
```bash
git add src/admin/theme.ts src/admin/theme.test.ts
git commit -m "feat(mui): soft-SaaS component defaults (pill buttons, 16px cards, pill chips)"
```
---
## Task 4: MUI provider + color-scheme sync
**Files:**
- Create: `src/admin/ui/MuiColorSchemeSync.tsx`
- Create: `src/admin/ui/MuiProvider.tsx`
- Modify: `src/admin/AdminApp.tsx`
- [ ] **Step 1: Create the sync component**
Create `src/admin/ui/MuiColorSchemeSync.tsx`:
```tsx
import { useEffect } from "react";
import { useColorScheme } from "@mui/material/styles";
import { useTheme as useAppTheme } from "../../context/ThemeContext";
/**
* ThemeContext is the single owner of the `data-theme` attribute. This mirrors
* its value into MUI's JS color-scheme state so theme.applyStyles('dark') and
* useColorScheme().mode stay in sync with the attribute the CSS selector reads.
*/
export default function MuiColorSchemeSync() {
const { theme } = useAppTheme();
const { mode, setMode } = useColorScheme();
useEffect(() => {
if (mode !== theme) setMode(theme as "light" | "dark");
}, [theme, mode, setMode]);
return null;
}
```
- [ ] **Step 2: Create the provider wrapper**
Create `src/admin/ui/MuiProvider.tsx`:
```tsx
import type { ReactNode } from "react";
import { ThemeProvider } from "@mui/material/styles";
import { theme } from "../theme";
import MuiColorSchemeSync from "./MuiColorSchemeSync";
export default function MuiProvider({ children }: { children: ReactNode }) {
return (
<ThemeProvider theme={theme} defaultMode="dark">
<MuiColorSchemeSync />
{children}
</ThemeProvider>
);
}
```
- [ ] **Step 3: Mount the provider at the top of `AdminApp`**
In `src/admin/AdminApp.tsx`, add the import after the other component imports (e.g. after line 10):
```tsx
import MuiProvider from "./ui/MuiProvider";
```
Then wrap the existing returned tree. Change:
```tsx
return (
<AuthProvider>
```
to:
```tsx
return (
<MuiProvider>
<AuthProvider>
```
and change the matching closing tag at the end of the JSX:
```tsx
</AuthProvider>
);
```
to:
```tsx
</AuthProvider>
</MuiProvider>
);
```
- [ ] **Step 4: Verify the app builds and renders with no console errors**
Run:
```bash
npx tsc -b --noEmit
npm run build:client
```
Expected: both succeed. (The user runs the dev server separately — do not start it. Verification of live render happens via the `/ui-kit` route in Task 6.)
- [ ] **Step 5: Commit**
```bash
git add src/admin/ui/MuiProvider.tsx src/admin/ui/MuiColorSchemeSync.tsx src/admin/AdminApp.tsx
git commit -m "feat(mui): mount MUI provider + sync color scheme to data-theme"
```
---
## Task 5: First UI-kit wrappers + barrel
**Files:**
- Create: `src/admin/ui/Button.tsx`, `src/admin/ui/Card.tsx`, `src/admin/ui/TextField.tsx`, `src/admin/ui/index.ts`
- [ ] **Step 1: Create the wrappers**
Create `src/admin/ui/Button.tsx`:
```tsx
import MuiButton, { type ButtonProps } from "@mui/material/Button";
/** App Button: defaults to the brand primary contained style. */
export default function Button(props: ButtonProps) {
return <MuiButton variant="contained" color="primary" {...props} />;
}
```
Create `src/admin/ui/Card.tsx`:
```tsx
import MuiCard, { type CardProps } from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
export default function Card({ children, ...props }: CardProps) {
return (
<MuiCard {...props}>
<CardContent>{children}</CardContent>
</MuiCard>
);
}
```
Create `src/admin/ui/TextField.tsx`:
```tsx
import MuiTextField, { type TextFieldProps } from "@mui/material/TextField";
/** App TextField: small + outlined + full width by default. */
export default function TextField(props: TextFieldProps) {
return <MuiTextField size="small" variant="outlined" fullWidth {...props} />;
}
```
- [ ] **Step 2: Create the barrel**
Create `src/admin/ui/index.ts`:
```ts
export { default as MuiProvider } from "./MuiProvider";
export { default as Button } from "./Button";
export { default as Card } from "./Card";
export { default as TextField } from "./TextField";
```
- [ ] **Step 3: Verify type-check**
Run:
```bash
npx tsc -b --noEmit
```
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/admin/ui/Button.tsx src/admin/ui/Card.tsx src/admin/ui/TextField.tsx src/admin/ui/index.ts
git commit -m "feat(mui): first ui-kit wrappers (Button, Card, TextField) + barrel"
```
---
## Task 6: Dev-only `/ui-kit` showcase route
**Files:**
- Create: `src/admin/pages/UiKit.tsx`
- Modify: `src/admin/AdminApp.tsx`
- [ ] **Step 1: Create the showcase page**
Create `src/admin/pages/UiKit.tsx`:
```tsx
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip";
import { Button, Card, TextField } from "../ui";
import { useTheme } from "../../context/ThemeContext";
export default function UiKit() {
const { theme, toggleTheme } = useTheme();
return (
<ScopedCssBaseline>
<Box sx={{ p: 4, minHeight: "100vh", bgcolor: "background.default" }}>
<Stack direction="row" alignItems="center" spacing={2} mb={3}>
<Typography variant="h4">UI Kit</Typography>
<Button color="inherit" variant="outlined" onClick={toggleTheme}>
Theme: {theme}
</Button>
</Stack>
<Stack spacing={3} sx={{ maxWidth: 520 }}>
<Card>
<Typography variant="h6" gutterBottom>
Faktury
</Typography>
<Typography color="text.secondary" gutterBottom>
12 vystavených tento měsíc
</Typography>
<Stack direction="row" spacing={1} mb={2}>
<Chip label="Zaplaceno" color="success" size="small" />
<Chip label="Po splatnosti" color="error" size="small" />
</Stack>
<TextField
label="Hledat fakturu"
placeholder="Číslo nebo klient…"
/>
<Box mt={2}>
<Button>Nová faktura</Button>
</Box>
</Card>
</Stack>
</Box>
</ScopedCssBaseline>
);
}
```
- [ ] **Step 2: Register the route, DEV-only**
In `src/admin/AdminApp.tsx`, add the lazy import alongside the other lazy pages (after line 11's eager imports / with the `lazy(...)` block):
```tsx
const UiKit = lazy(() => import("./pages/UiKit"));
```
Then, inside `<Routes>`, add a sibling route to `login` (so the showcase renders without the shell), guarded so it is dropped from production builds:
```tsx
{
import.meta.env.DEV && <Route path="ui-kit" element={<UiKit />} />;
}
```
- [ ] **Step 3: Verify build (prod) excludes it and type-check passes**
Run:
```bash
npx tsc -b --noEmit
npm run build:client
```
Expected: both succeed. `import.meta.env.DEV` is `false` in the production build, so the route is tree-shaken.
- [ ] **Step 4: Manual + Playwright verification**
The user runs the dev server. Once it's up, verify at `/ui-kit`:
- Card, button (red gradient, pill), chips (tinted), and text field render in the Soft-SaaS style.
- Clicking **Theme: dark/light** flips `data-theme` and **both MUI components AND the page background** swap — proving the `colorSchemeSelector` bridge works.
- No new console errors.
Capture baseline screenshots with the available Playwright tooling (light + dark) and save under `docs/superpowers/visual-baselines/phase-0-ui-kit-{light,dark}.png`.
- [ ] **Step 5: Commit**
```bash
git add src/admin/pages/UiKit.tsx src/admin/AdminApp.tsx
git commit -m "feat(mui): dev-only /ui-kit showcase proving the theme + data-theme bridge"
```
---
## Task 7: Token-level palette refresh (app-wide)
**Files:**
- Modify: `src/admin/variables.css`
Per spec §7, the token-level palette refresh is applied app-wide so migrated and legacy pages share the refreshed palette. The only token change for Phase 0 is the light canvas (the new look's `#f4f3f1` vs current `#f5f4f2`); chip/structure changes are component-level (MUI) and need no token edit.
- [ ] **Step 1: Update the light canvas token**
In `src/admin/variables.css`, inside the `[data-theme="light"]` block, change:
```css
--bg-primary: #f5f4f2;
```
to:
```css
--bg-primary: #f4f3f1;
```
- [ ] **Step 2: Verify build**
Run:
```bash
npm run build:client
```
Expected: succeeds.
- [ ] **Step 3: Manual check**
With the dev server (user-run), confirm in light mode the app canvas is the refreshed `#f4f3f1` and nothing else shifted structurally. (This is the deliberate, minor app-wide color change noted in the spec — not a no-op.)
- [ ] **Step 4: Commit**
```bash
git add src/admin/variables.css
git commit -m "style(tokens): refresh light canvas to #f4f3f1 (two-layer refresh, token level)"
```
---
## Task 8: Phase-0 release verification
**Files:** none (verification + tag)
- [ ] **Step 1: Full type-check, unit tests, and build**
Run:
```bash
npx tsc -b --noEmit
npx vitest run src/admin/theme.test.ts
npm run build
```
Expected: `tsc` clean; 6 theme tests pass; server + client build succeed.
- [ ] **Step 2: Backend test suite still green (server untouched)**
Run:
```bash
npm test
```
Expected: existing `auth` + `numbering` suites pass (Phase 0 changed no server code).
- [ ] **Step 3: Definition-of-done checklist (manual, dev server)**
- `/ui-kit` renders Button/Card/TextField/Chip in the Soft-SaaS style in **both** themes.
- Theme toggle flips `data-theme` and swaps MUI + legacy colors together; MUI's `useColorScheme().mode` matches (no console warnings).
- No new console errors on any existing page (the provider mount + token refresh are non-breaking).
- Light canvas is `#f4f3f1`.
- Production build excludes the `/ui-kit` route.
- [ ] **Step 4: Tag/release per the project release process**
Follow `CLAUDE.md` "Release Process" (bump `package.json`, build, tag, deploy) when shipping Phase 0. No Prisma migration is involved (frontend-only).
---
## Self-review notes (for the controller)
- **Spec coverage:** Phase 0 items from spec §8 are all covered — deps + `@types/react` (Task 1), `theme.ts` light/dark (Tasks 23), provider + `ScopedCssBaseline` + `ThemeContext``setMode` sync (Tasks 4, 6), dev-only `/ui-kit` (Task 6), token-level refresh (Task 7). The full `ui/` kit, AppShell, and Vozidla are explicitly deferred to later plans (scope note).
- **Type consistency:** `theme` (named export) is used consistently in `theme.test.ts`, `MuiProvider.tsx`. `useTheme` refers to the app's `ThemeContext` (aliased `useAppTheme` in the sync component to avoid colliding with MUI's `useTheme`). Wrappers are default exports re-exported via the barrel.
- **Open risk to verify in Task 1/6:** exact MUI v7 API for `cssVariables.colorSchemeSelector` + `ThemeProvider` `defaultMode` — confirm against the installed version's docs; if `defaultMode` belongs on a different provider in the installed version, adjust in Task 4.

View File

@@ -0,0 +1,357 @@
# MUI Migration — Phase 1: AppShell — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Replace the admin chrome (`AdminLayout` + `Sidebar`) with a MUI `<AppShell>` in the Soft-SaaS look — a `Drawer`-based sidebar (permanent on desktop, temporary on mobile) with a white active "pill", a minimal topbar (hamburger + theme toggle), reusing the existing permission-filtered nav data and preserving all behavior (auth/2FA guards, logout scale+blur animation, ShortcutsHelp).
**Architecture:** The valuable, well-tested parts are **reused, not rewritten**: the `menuSections` nav data (icons, permissions, active-match rules) and the active/permission logic move into `navData.tsx`. `SidebarNav` renders that data with MUI `List`. `AppShell` provides the responsive `Drawer` + topbar + auth guards + the framer-motion logout wrapper, and renders `<Outlet/>`. `AdminLayout`/`Sidebar`/`layout.css` are deleted when `AppShell` lands.
**Tech Stack:** React 18.3, MUI v7 (`@mui/material`), Emotion, framer-motion (kept for logout), react-router v6, TypeScript strict.
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§5 AppShell, §7 aesthetic, §8 Phase 1, §9 animation).
**Authoritative gates:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — that's a vacuous solution file), `npm run build:client`, `npx vitest run`. Live visual verification (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
**Behavior parity checklist (must all be preserved from `AdminLayout.tsx` + `Sidebar.tsx`):**
- Auth: `loading` → spinner; `!isAuthenticated``<Navigate to="/login" replace/>`; 2FA-setup redirect (`user.require2FA && !user.totpEnabled && pathname !== "/"``/`).
- Logout: `handleLogout` sets a logout alert (`setLogoutAlert()`), closes the mobile drawer, and calls `logout()` after 400ms; the layout plays the framer-motion `{scale:1.5, opacity:0, filter:"blur(12px)"}` exit over 0.4s.
- Sidebar: permission-filtered sections/items (empty sections hidden); custom active matching (`matchPrefix`/`matchAlso`/`matchExclude`/`end`); company logo with light/dark variant + `onError` fallback to `/images/logo-{dark,light}.png`; user chip (avatar initial + name + `roleDisplay`); logout button; mobile open/close.
- `ShortcutsHelp` still rendered; `<Outlet/>` for page content.
---
## File Structure
| File | Responsibility | Action |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- |
| `src/admin/ui/navData.tsx` | `MenuItem`/`MenuSection` types, the `menuSections` array (moved verbatim from Sidebar), pure helpers `isItemActive(item, pathname)` + `hasItemPermission(item, hasPermission)` | Create (move) |
| `src/admin/ui/SidebarNav.tsx` | Renders `navData` with MUI `List`: logo header, sections, white-pill active items, user+logout footer | Create |
| `src/admin/ui/AppShell.tsx` | Responsive `Drawer` + topbar + auth guards + logout animation + `<Outlet/>` + `ShortcutsHelp` | Create |
| `src/admin/components/Sidebar.tsx` | (interim) import `menuSections`/helpers from `navData` instead of defining inline | Modify, then Delete |
| `src/admin/components/AdminLayout.tsx` | Replaced by `AppShell` | Delete |
| `src/admin/AdminApp.tsx` | Route uses `<AppShell/>` instead of `<AdminLayout/>`; remove `import "./layout.css"` | Modify |
| `src/admin/layout.css` | Chrome styling now in MUI | Delete |
Import paths from `src/admin/ui/` (same depth as `components/`): `../context/AuthContext`, `../../context/ThemeContext`, `../utils/api`, `../hooks/...`, `../theme`.
---
## Task 1: Extract nav data into `navData.tsx`
**Files:** Create `src/admin/ui/navData.tsx`; Modify `src/admin/components/Sidebar.tsx`.
- [ ] **Step 1: Create `src/admin/ui/navData.tsx`.** MOVE (cut) the following from `src/admin/components/Sidebar.tsx` **verbatim**: the `MenuItem` and `MenuSection` interfaces and the entire `menuSections` array (all 6 sections with their inline SVG icons — do not retype, move them unchanged). Then add the two pure helpers (extracted from Sidebar's inline closures), and export everything:
```tsx
import { type ReactNode } from "react";
export interface MenuItem {
path: string;
label: string;
end?: boolean;
permission?: string | string[];
matchPrefix?: string;
matchAlso?: string[];
matchExclude?: string[];
icon: ReactNode;
}
export interface MenuSection {
label: string;
items: MenuItem[];
}
export const menuSections: MenuSection[] = [
/* ... the 6 sections, MOVED verbatim from Sidebar.tsx ... */
];
/** Active-state matching (mirrors the original Sidebar logic, incl. matchAlso). */
export function isItemActive(item: MenuItem, pathname: string): boolean {
if (item.matchPrefix) {
let active = pathname.startsWith(item.matchPrefix);
if (active && item.matchExclude) {
active = !item.matchExclude.some((ex) => pathname.startsWith(ex));
}
if (!active && item.matchAlso) {
active = item.matchAlso.some((p) => pathname.startsWith(p));
}
return active;
}
if (item.matchAlso && item.matchAlso.some((p) => pathname.startsWith(p))) {
return true;
}
if (item.end) return pathname === item.path;
return pathname.startsWith(item.path);
}
/** Permission check: true if no permission, or any listed permission is held. */
export function hasItemPermission(
item: MenuItem,
hasPermission: (p: string) => boolean,
): boolean {
if (!item.permission) return true;
if (Array.isArray(item.permission)) {
return item.permission.some((p) => hasPermission(p));
}
return hasPermission(item.permission);
}
```
> Note: the original Sidebar computed `matchAlso` separately in the `NavLink` className callback. The consolidated `isItemActive` above folds `matchAlso` in so a single function is the source of truth. Preserve every section/item exactly.
- [ ] **Step 2: Update `Sidebar.tsx` to import from `navData`** (interim — Sidebar is deleted in Task 4). Remove the inline `MenuItem`/`MenuSection`/`menuSections` definitions and the inline `isItemActive`/`hasItemPermission`; import them from `../ui/navData`. In the `NavLink` `className` callback, replace the inline active+matchAlso logic with `isItemActive(item, location.pathname)`.
- [ ] **Step 3: Verify**`npx tsc -b --noEmit` clean; `npm run build:client` succeeds; `npx vitest run` (147 pass — no behavior change).
- [ ] **Step 4: Commit** `src/admin/ui/navData.tsx` + `src/admin/components/Sidebar.tsx` (msg: `refactor(mui): extract sidebar nav data + active/permission helpers to navData`; end with the `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
---
## Task 2: `SidebarNav.tsx` (MUI drawer content)
**Files:** Create `src/admin/ui/SidebarNav.tsx`.
Renders the permission-filtered nav with MUI. Props: `{ onNavigate?: () => void; onLogout: () => void }` (`onNavigate` closes the mobile drawer; `onLogout` is owned by `AppShell`).
- [ ] **Step 1: Create the component.** Requirements (write complete MUI code):
- `const { user, hasPermission } = useAuth();` (`../context/AuthContext`), `const { theme } = useTheme();` aliased from `../../context/ThemeContext`, `const location = useLocation();`.
- `visibleSections` = `menuSections` mapped to filter items by `hasItemPermission(item, hasPermission)`, dropping empty sections.
- Root: a flex column `Box`, `height: "100%"`, padding ~`1.5`.
- **Logo header:** `Box component="img"` with `src` = `/api/admin/company-settings/logo?variant=${theme === "dark" ? "dark" : "light"}` and `onError` falling back to `/images/logo-${theme}.png`; `sx={{ maxHeight: 40, maxWidth: "100%", objectFit: "contain" }}`.
- **Nav:** scrollable `Box` (`flex: 1, overflowY: "auto"`). For each section: a `Typography variant="caption"` subheader (uppercase, `letterSpacing: ".06em"`, `fontWeight: 700`, `color: "text.secondary"`, `fontSize: ".64rem"`, `px: 1.5`), then a `List disablePadding` of items. Each item:
```tsx
<ListItemButton
component={NavLink}
to={item.path}
end={item.end}
onClick={onNavigate}
selected={isItemActive(item, location.pathname)}
sx={{
borderRadius: 2,
mb: 0.25,
py: 0.6,
color: "text.secondary",
"& svg": { width: 18, height: 18 },
"&.Mui-selected, &.Mui-selected:hover": {
bgcolor: "background.paper",
color: "primary.main",
fontWeight: 700,
boxShadow: "0 2px 8px rgba(20,20,40,0.07)",
},
}}
>
<ListItemIcon sx={{ minWidth: 32, color: "inherit" }}>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.label}
primaryTypographyProps={{ fontSize: ".82rem", fontWeight: "inherit" }}
/>
</ListItemButton>
```
(Use MUI's `selected` prop for the active "pill" — do NOT rely on NavLink's default active class, because the active rule is custom.)
- **Footer:** top-bordered `Box` (`borderColor: "divider"`) with a user chip (MUI `Avatar` `bgcolor: "primary.main"` showing `user?.fullName?.charAt(0) || user?.username?.charAt(0) || "U"`, plus name + `roleDisplay` `Typography` with `noWrap`), and a logout `Button` (`color="inherit"`, `fullWidth`, the existing logout SVG as `startIcon`, label "Odhlásit se", `onClick={onLogout}`).
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds. (Component not yet mounted; visual check is later.)
- [ ] **Step 3: Commit** `src/admin/ui/SidebarNav.tsx` (msg: `feat(mui): SidebarNav — MUI List nav with white-pill active state`; + trailer).
---
## Task 3: `AppShell.tsx` (responsive layout + guards + animation)
**Files:** Create `src/admin/ui/AppShell.tsx`.
- [ ] **Step 1: Create the component** with this exact structure (fill the guard bodies from the parity checklist):
```tsx
import { useState, useCallback } from "react";
import { Outlet, Navigate, useLocation } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
import { useAuth } from "../context/AuthContext";
import { useTheme as useAppTheme } from "../../context/ThemeContext";
import { setLogoutAlert } from "../utils/api";
import SidebarNav from "./SidebarNav";
import ShortcutsHelp from "../components/ShortcutsHelp";
const DRAWER_WIDTH = 248;
export default function AppShell() {
const { isAuthenticated, loading, user, logout } = useAuth();
const { theme, toggleTheme } = useAppTheme();
const [mobileOpen, setMobileOpen] = useState(false);
const [loggingOut, setLoggingOut] = useState(false);
const location = useLocation();
const handleLogout = useCallback(() => {
setLoggingOut(true);
setMobileOpen(false);
setLogoutAlert();
setTimeout(() => logout(), 400);
}, [logout]);
if (loading) {
return (
<ScopedCssBaseline>
<Box
sx={{
display: "flex",
minHeight: "100vh",
alignItems: "center",
justifyContent: "center",
bgcolor: "background.default",
}}
>
<div className="admin-spinner" />
</Box>
</ScopedCssBaseline>
);
}
if (!isAuthenticated) return <Navigate to="/login" replace />;
if (user?.require2FA && !user?.totpEnabled && location.pathname !== "/") {
return <Navigate to="/" replace />;
}
return (
<ScopedCssBaseline>
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={
loggingOut
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
: { scale: 1, opacity: 1, filter: "none" }
}
transition={{
duration: loggingOut ? 0.4 : 0.25,
ease: [0.4, 0, 0.2, 1],
}}
style={{ minHeight: "100vh" }}
>
<Box
sx={{
display: "flex",
minHeight: "100vh",
bgcolor: "background.default",
}}
>
{/* Desktop permanent drawer */}
<Drawer
variant="permanent"
sx={{
display: { xs: "none", md: "block" },
width: DRAWER_WIDTH,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: DRAWER_WIDTH,
boxSizing: "border-box",
border: 0,
bgcolor: "background.default",
},
}}
>
<SidebarNav onLogout={handleLogout} />
</Drawer>
{/* Mobile temporary drawer */}
<Drawer
variant="temporary"
open={mobileOpen}
onClose={() => setMobileOpen(false)}
ModalProps={{ keepMounted: true }}
sx={{
display: { xs: "block", md: "none" },
"& .MuiDrawer-paper": {
width: DRAWER_WIDTH,
boxSizing: "border-box",
},
}}
>
<SidebarNav
onNavigate={() => setMobileOpen(false)}
onLogout={handleLogout}
/>
</Drawer>
{/* Main column */}
<Box
sx={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
}}
>
<Box
component="header"
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 2,
py: 1.5,
minHeight: 64,
}}
>
<IconButton
onClick={() => setMobileOpen(true)}
aria-label="Otevřít menu"
sx={{ display: { md: "none" } }}
>
{/* hamburger SVG (3 lines), 24x24, stroke currentColor */}
</IconButton>
<Box sx={{ flex: 1 }} />
<IconButton
onClick={toggleTheme}
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
>
{/* sun SVG when theme==='dark' is off / moon — keep the two SVGs from AdminLayout, render the one for the current theme */}
</IconButton>
</Box>
<Box component="main" sx={{ flex: 1, px: { xs: 2, md: 3 }, pb: 4 }}>
<Outlet />
</Box>
</Box>
</Box>
<ShortcutsHelp />
</motion.div>
</ScopedCssBaseline>
);
}
```
Fill the two `IconButton` SVGs from the existing `AdminLayout.tsx` (the hamburger 3-line icon; the sun/moon — render the moon when `theme === "dark"`, the sun when light, matching the current toggle's meaning). Keep the `admin-spinner` class for the loading state (it's defined in a CSS file that still exists in Phase 1).
- [ ] **Step 2: Verify** — `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
- [ ] **Step 3: Commit** `src/admin/ui/AppShell.tsx` (msg: `feat(mui): AppShell — responsive Drawer + topbar, guards + logout animation`; + trailer).
---
## Task 4: Wire `AppShell`, delete legacy chrome
**Files:** Modify `src/admin/AdminApp.tsx`; Delete `src/admin/components/AdminLayout.tsx`, `src/admin/components/Sidebar.tsx`, `src/admin/layout.css`.
- [ ] **Step 1: Swap the route.** In `src/admin/AdminApp.tsx`: change the import `import AdminLayout from "./components/AdminLayout";` → `import AppShell from "./ui/AppShell";`, and the route `<Route element={<AdminLayout />}>` → `<Route element={<AppShell />}>`. Remove the line `import "./layout.css";`.
- [ ] **Step 2: Delete** `src/admin/components/AdminLayout.tsx`, `src/admin/components/Sidebar.tsx`, and `src/admin/layout.css`. Then grep the repo for any remaining import of those modules and fix/remove (there should be none beyond AdminApp).
- [ ] **Step 3: Verify (full gates)** — `npx tsc -b --noEmit` clean; `npm run build` (server+client) succeeds and the prod bundle has no broken refs; `npx vitest run` (147 pass). Confirm `layout.css` is no longer imported anywhere.
- [ ] **Step 4: Commit** (msg: `feat(mui): adopt AppShell, remove AdminLayout/Sidebar/layout.css`; + trailer).
- [ ] **Step 5: MANUAL verification (USER, dev server) — deferred:** sidebar shows permission-filtered sections; active item is the white pill; navigation works + closes the mobile drawer; theme toggle works; logout plays the scale+blur animation; logo light/dark + fallback; responsive (permanent drawer ≥ md, hamburger + temporary drawer < md); no console errors; dark + light both correct.
---
## Self-review notes (for the controller)
- **Spec coverage:** Phase 1 (spec §8) — AppShell chrome via MUI Drawer + topbar (Tasks 24), reusing nav data/permissions (Task 1), preserving the logout animation + guards (Task 3), deleting `layout.css` (Task 4). Login stays OUT of this (it's a sibling route — Phase 3), untouched.
- **Type consistency:** `navData` exports `MenuItem`/`MenuSection`/`menuSections`/`isItemActive`/`hasItemPermission`; `SidebarNav` and (interim) `Sidebar` both consume them. `AppShell` props to `SidebarNav`: `onLogout` (required), `onNavigate` (optional).
- **Parity risks to watch in review:** the consolidated `isItemActive` must reproduce the original `matchAlso` behavior (the original applied `matchAlso` only in the NavLink callback) — the spec reviewer must diff active states for `/attendance/admin` (matchAlso `/attendance/create`,`/attendance/location`) and `/offers` (matchExclude). The `selected` prop must not double-apply with NavLink's own active class. `ScopedCssBaseline` wraps the shell so MUI reset applies; unmigrated page content inside `<Outlet/>` still uses its own CSS.
- **Deferred:** all live visual checks (no dev server in implementer scope).

View File

@@ -0,0 +1,395 @@
# MUI Migration — Phase 2: Data/Form Kit + Vozidla Pilot — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Build the data + form foundation components (`DataTable`, `Modal`, `ConfirmDialog`, field controls) and migrate the **Vozidla (Vehicles)** page fully onto them — the pilot that battle-tests the kit. Behavior stays identical; styling becomes MUI (Soft-SaaS shell + dense table).
**Architecture:** New wrappers in `src/admin/ui/` expose stable, app-friendly APIs (Czech labels) over MUI `Dialog`/`Table`/form controls, so pages import from `ui/` not `@mui`. `Modal`/`ConfirmDialog` preserve the existing `FormModal`/`ConfirmModal` prop shapes so page churn is minimal. `DataTable` is a small dense table that takes a `columns` config + `rows` + optional row actions. Vozidla is then rewritten to use them; its data hooks (`useQuery(vehicleListOptions)`, `useApiMutation`) are unchanged.
**Tech Stack:** React 18.3, MUI v7 (`@mui/material`), Emotion, framer-motion (page-entry animations kept), TanStack Query v5, TypeScript strict.
**Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md` (§5 kit, §5.1 DataTable, §7 dense-table aesthetic, §8 Phase 2).
**Authoritative gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (expect 148). Live visual check (dev server) is the USER's; implementers do build/typecheck/tests only and must NOT start the dev server.
**Reference (current Vozidla behavior to preserve):** `src/admin/pages/Vehicles.tsx` — list table (SPZ, Název, Značka/Model, Počáteční km, Aktuální km, Počet jízd, Stav, Akce) with `formatKm`; inactive rows dimmed; status badge toggles active/inactive via `toggleVehicle`; row actions edit/delete; add/edit `FormModal` with SPZ (uppercased)/Název (required) + Značka/Model/Počáteční km/aktivní checkbox + validation (`Zadejte SPZ`/`Zadejte název`); delete `ConfirmModal`; permission gate `vehicles.manage``<Forbidden/>`; empty state.
---
## File Structure
| File | Responsibility | Action |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ |
| `src/admin/ui/Modal.tsx` | MUI `Dialog` form modal; preserves FormModal API (`isOpen`/`onClose`/`onSubmit`/`title`/`subtitle`/`loading`/`submitDisabled`/`submitText`/`children`) | Create |
| `src/admin/ui/ConfirmDialog.tsx` | MUI `Dialog` confirm; preserves ConfirmModal API (`isOpen`/`onClose`/`onConfirm`/`title`/`message`/`confirmText`/`confirmVariant`/`loading`) | Create |
| `src/admin/ui/DataTable.tsx` | Dense MUI `Table` from a `columns` config + `rows`; right-aligned/mono numeric cells; row hover; optional `rowInactive` + actions column | Create |
| `src/admin/ui/Field.tsx` | Labeled field wrapper (label + required + error helper) for composing inputs; `SwitchField` for booleans | Create |
| `src/admin/ui/index.ts` | Export the new components | Modify |
| `src/admin/pages/Vehicles.tsx` | Rewrite to use the kit | Modify |
Import paths from `src/admin/ui/`: `../context/AlertContext`, `../theme`, etc. MUI via deep imports.
---
## Task 1: `Modal` + `ConfirmDialog` (MUI Dialog wrappers)
**Files:** Create `src/admin/ui/Modal.tsx`, `src/admin/ui/ConfirmDialog.tsx`; Modify `src/admin/ui/index.ts`.
- [ ] **Step 1: `Modal.tsx`** — MUI `Dialog` preserving the existing `FormModal` API:
```tsx
import type { ReactNode } from "react";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Typography from "@mui/material/Typography";
import MuiButton from "@mui/material/Button";
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
title: string;
subtitle?: string;
children: ReactNode;
loading?: boolean;
submitDisabled?: boolean;
submitText?: string;
cancelText?: string;
maxWidth?: "xs" | "sm" | "md" | "lg";
}
export default function Modal({
isOpen,
onClose,
onSubmit,
title,
subtitle,
children,
loading = false,
submitDisabled = false,
submitText = "Uložit",
cancelText = "Zrušit",
maxWidth = "sm",
}: ModalProps) {
return (
<Dialog
open={isOpen}
onClose={loading ? undefined : onClose}
fullWidth
maxWidth={maxWidth}
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
>
<DialogTitle sx={{ pb: subtitle ? 0.5 : 1.5 }}>
{title}
{subtitle && (
<Typography variant="body2" color="text.secondary">
{subtitle}
</Typography>
)}
</DialogTitle>
<DialogContent>{children}</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<MuiButton onClick={onClose} color="inherit" disabled={loading}>
{cancelText}
</MuiButton>
<MuiButton
onClick={onSubmit}
variant="contained"
disabled={loading || submitDisabled}
>
{loading ? "Ukládám…" : submitText}
</MuiButton>
</DialogActions>
</Dialog>
);
}
```
- [ ] **Step 2: `ConfirmDialog.tsx`** — MUI `Dialog` preserving the `ConfirmModal` API:
```tsx
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogActions from "@mui/material/DialogActions";
import MuiButton from "@mui/material/Button";
export interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
confirmVariant?: "primary" | "danger";
loading?: boolean;
}
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "Potvrdit",
cancelText = "Zrušit",
confirmVariant = "primary",
loading = false,
}: ConfirmDialogProps) {
return (
<Dialog
open={isOpen}
onClose={loading ? undefined : onClose}
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<DialogContentText>{message}</DialogContentText>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<MuiButton onClick={onClose} color="inherit" disabled={loading}>
{cancelText}
</MuiButton>
<MuiButton
onClick={onConfirm}
variant="contained"
color={confirmVariant === "danger" ? "error" : "primary"}
disabled={loading}
>
{loading ? "Pracuji…" : confirmText}
</MuiButton>
</DialogActions>
</Dialog>
);
}
```
- [ ] **Step 3:** add `export { default as Modal } from "./Modal";` and `export { default as ConfirmDialog } from "./ConfirmDialog";` to `src/admin/ui/index.ts`.
- [ ] **Step 4: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
- [ ] **Step 5: Commit** (msg `feat(mui): Modal + ConfirmDialog (MUI Dialog wrappers)`; + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
---
## Task 2: `DataTable` (dense MUI Table)
**Files:** Create `src/admin/ui/DataTable.tsx`; Modify `src/admin/ui/index.ts`.
- [ ] **Step 1: Create `DataTable.tsx`** — a generic dense table:
```tsx
import type { ReactNode } from "react";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Box from "@mui/material/Box";
export interface DataColumn<T> {
key: string;
header: string;
align?: "left" | "right";
mono?: boolean; // DM Mono numeric cell
render: (row: T) => ReactNode;
}
export interface DataTableProps<T> {
columns: DataColumn<T>[];
rows: T[];
rowKey: (row: T) => string | number;
rowInactive?: (row: T) => boolean;
empty?: ReactNode;
}
export default function DataTable<T>({
columns,
rows,
rowKey,
rowInactive,
empty,
}: DataTableProps<T>) {
if (rows.length === 0 && empty) return <>{empty}</>;
return (
<TableContainer sx={{ borderRadius: 2 }}>
<Table size="small" sx={{ "& td, & th": { borderColor: "divider" } }}>
<TableHead>
<TableRow>
{columns.map((c) => (
<TableCell
key={c.key}
align={c.align}
sx={{
textTransform: "uppercase",
letterSpacing: ".05em",
fontSize: ".64rem",
fontWeight: 700,
color: "text.secondary",
}}
>
{c.header}
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row) => (
<TableRow
key={rowKey(row)}
hover
sx={rowInactive?.(row) ? { opacity: 0.55 } : undefined}
>
{columns.map((c) => (
<TableCell
key={c.key}
align={c.align}
sx={{
fontSize: ".8rem",
...(c.mono ? { fontFamily: "'DM Mono', monospace" } : {}),
}}
>
{c.render(row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}
```
(Numeric cells: pass `align="right" mono` and render via the existing `formatKm`/`formatCurrency` — the table only styles, never re-formats.)
- [ ] **Step 2:** add `export { default as DataTable } from "./DataTable"; export type { DataColumn } from "./DataTable";` to `index.ts`.
- [ ] **Step 3: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
- [ ] **Step 4: Commit** (msg `feat(mui): DataTable — dense MUI table with columns config`; + trailer).
---
## Task 3: Field controls (`Field` + `SwitchField`)
**Files:** Create `src/admin/ui/Field.tsx`; Modify `src/admin/ui/index.ts`.
- [ ] **Step 1: Create `Field.tsx`** with a labeled wrapper and a boolean switch:
```tsx
import type { ReactNode } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import FormControlLabel from "@mui/material/FormControlLabel";
import Switch from "@mui/material/Switch";
export function Field({
label,
required,
error,
hint,
children,
}: {
label: string;
required?: boolean;
error?: string;
hint?: string;
children: ReactNode;
}) {
return (
<Box sx={{ mb: 2 }}>
<Typography
component="label"
variant="body2"
sx={{
display: "block",
mb: 0.5,
fontWeight: 600,
color: "text.secondary",
}}
>
{label}
{required && (
<Box component="span" sx={{ color: "error.main", ml: 0.25 }}>
*
</Box>
)}
</Typography>
{children}
{error ? (
<Typography variant="caption" color="error">
{error}
</Typography>
) : hint ? (
<Typography variant="caption" color="text.secondary">
{hint}
</Typography>
) : null}
</Box>
);
}
export function SwitchField({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<FormControlLabel
control={
<Switch
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
}
label={label}
/>
);
}
```
- [ ] **Step 2:** add `export { Field, SwitchField } from "./Field";` to `index.ts`. (`Button`, `Card`, `TextField` are already exported.)
- [ ] **Step 3: Verify** `npx tsc -b --noEmit` clean; `npm run build:client` succeeds.
- [ ] **Step 4: Commit** (msg `feat(mui): Field + SwitchField form controls`; + trailer).
---
## Task 4: Migrate `Vehicles.tsx` onto the kit
**Files:** Modify `src/admin/pages/Vehicles.tsx`.
- [ ] **Step 1: Rewrite the page** keeping ALL existing data logic (the `useQuery(vehicleListOptions)`, `saveVehicle`/`deleteVehicle`/`toggleVehicle` `useApiMutation`s, validation, handlers, permission gate) UNCHANGED, and swapping only the presentation:
- Permission gate + loading + empty state: keep behavior; render loading with the existing `admin-spinner` (still in base.css), or a MUI `CircularProgress` — keep `admin-spinner` for now.
- Page header: an MUI `Box` (flex, title `Typography variant="h4"` "Správa vozidel" + the kit `Button` "Přidat vozidlo" with the plus SVG). Keep the framer-motion entry animation if desired (optional).
- List: kit `Card` wrapping a `DataTable<Vehicle>` with columns: SPZ (`mono`), Název, Značka/Model (the existing `brand/model` join with `—` fallback), Počáteční km (`align right`, `mono`, `formatKm(...) + " km"`), Aktuální km (right/mono), Počet jízd (right/mono), Stav (a clickable MUI `Chip` colored `success`/`default` toggling `toggleActive`), Akce (edit + delete `IconButton`s with the existing SVGs). `rowInactive: (v) => !v.is_active`. `empty` = the existing empty-state block (can keep markup or render a simple MUI message + Button).
- Add/Edit modal: kit `Modal` (`isOpen={showModal}`, `onClose`, `onSubmit={handleSubmit}`, `title`, `loading={saving}`) containing `Field`-wrapped kit `TextField`s for SPZ (uppercase onChange)/Název/Značka/Model, a number `TextField` (type="number") for Počáteční km with the hint, and a `SwitchField` for `is_active`. Wire `errors`/`setErrors` exactly as today.
- Delete: kit `ConfirmDialog` (`isOpen={deleteConfirm.show}`, `onConfirm={handleDelete}`, `confirmVariant="danger"`, the existing message).
- Remove imports of the old `FormModal`/`ConfirmModal`/`FormField`; import from `../ui`.
- [ ] **Step 2: Verify**`npx tsc -b --noEmit` clean; `npm run build:client` succeeds; `npx vitest run` (148 pass — no test touches this page, but confirm nothing broke).
- [ ] **Step 3: Commit** (msg `feat(mui): migrate Vozidla (Vehicles) page onto MUI kit`; + trailer).
- [ ] **Step 4: MANUAL (USER, dev server) — deferred:** list renders (dense, mono numerics, inactive dimmed); status chip toggles active/inactive; add/edit modal validates (SPZ/Název required) + saves; SPZ uppercases; delete confirm works; empty state; permission gate; dark + light; no console errors.
---
## Task 5: Phase-2 verification + final review
- [ ] **Step 1:** `npx tsc -b --noEmit` clean; `npm run build` (server+client); `npx vitest run` (148).
- [ ] **Step 2:** Final whole-Phase-2 code review (kit components + Vozidla migration; behavior parity vs the original Vehicles.tsx).
- [ ] **Step 3:** finishing-a-development-branch (merge to master after the user's visual check).
---
## Self-review notes (for the controller)
- **Spec coverage:** §5 kit (Modal, ConfirmDialog, DataTable, field controls — Tasks 13), §5.1 dense DataTable (Task 2), §8 Phase 2 Vozidla pilot (Task 4). Pages still import only from `ui/`.
- **Parity risks:** Vozidla validation (`Zadejte SPZ`/`Zadejte název`) + SPZ uppercasing + the three mutations' invalidate keys (`["vehicles","trips"]`) must be preserved verbatim. The status-toggle was a button styled as a badge → now a clickable `Chip`; behavior (toggle active) identical. `Modal`'s `onClose` is disabled while `loading` (matches FormModal's guard).
- **No CSS file to delete:** Vozidla uses shared classes (forms/tables/buttons/components.css), not a `vehicles.css`; those shared files remain for not-yet-migrated pages. This page simply stops using them.
- **Deferred:** all live visual checks (no dev server in implementer scope).

View File

@@ -0,0 +1,362 @@
# MUI Migration — Phase 3: Kit Expansion — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development (or direct execution with gates). Steps use checkbox (`- [ ]`) syntax.
**Goal:** Build the reusable `src/admin/ui/` components the page migrations keep needing, so later phases are fast and consistent. No page is migrated in this phase; the dev-only `/ui-kit` route is extended to showcase + visually verify each new component.
**Why these (scout reuse counts):** `Select` ~34 pages, `Tabs` ~41 files, `StatusChip` ~34, plus paginated/sortable lists, dates, checkboxes, inline alerts.
**Tech Stack:** MUI v7 (`@mui/material`) + `@mui/x-date-pickers` v8 + `date-fns` (cs locale) + Emotion. TypeScript strict.
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check (`/ui-kit`) is the user's. No dev server started by implementers.
---
## File Structure
| File | Component(s) | API |
| ------------------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------- | --------- | ---------------------------------------- |
| `src/admin/ui/Select.tsx` | `Select` | `{ value, onChange(v), options?: {value,label}[], children?, ...TextFieldProps }` over `<TextField select>` — composes inside `Field` |
| `src/admin/ui/StatusChip.tsx` | `StatusChip` | `{ label, color?: "default" | "success" | "error" | "warning" | "info", onClick?, size?, ...ChipProps }` |
| `src/admin/ui/Tabs.tsx` | `Tabs`, `TabPanel` | `Tabs: { value, onChange(v), tabs: {value,label}[] }`; `TabPanel: { value, current, children }` |
| `src/admin/ui/Pagination.tsx` | `Pagination` | `{ page, pageCount, onChange(p) }` (renders nothing if `pageCount<=1`) |
| `src/admin/ui/Checkbox.tsx` | `CheckboxField` | `{ label, checked, onChange(b) }` (FormControlLabel + Checkbox) |
| `src/admin/ui/Alert.tsx` | `Alert` | `{ severity, children, onClose? }` (inline MUI Alert) |
| `src/admin/ui/DataTable.tsx` | extend | add `DataColumn.sortKey?`; `DataTableProps.{ sortBy?, sortDir?, onSort?(key) }`; render `TableSortLabel` in sortable headers |
| `src/admin/ui/DateField.tsx` | `DateField` | `{ value: string("yyyy-MM-dd" | ""), onChange(v: string), ...}`over MUI X`DatePicker`, format `dd.MM.yyyy` |
| `src/admin/ui/MuiProvider.tsx` | extend | wrap children in `LocalizationProvider` (`AdapterDateFns`, cs locale) so date pickers work app-wide |
| `src/admin/ui/index.ts` | extend | export all new components |
| `src/admin/pages/UiKit.tsx` | extend | showcase each new component in both themes |
Deferred (build when their page is migrated): `Autocomplete` (warehouse pickers), `FilterBar` layout helper.
---
## Task 1: `Select`, `StatusChip`, `CheckboxField`, `Alert` (simple wrappers)
**Files:** Create the four files; modify `index.ts`.
- [ ] **Step 1: `Select.tsx`**
```tsx
import MuiTextField, { type TextFieldProps } from "@mui/material/TextField";
import MenuItem from "@mui/material/MenuItem";
export interface SelectOption {
value: string | number;
label: string;
}
type Props = Omit<TextFieldProps, "onChange" | "select" | "value"> & {
value: string | number;
onChange: (value: string) => void;
options?: SelectOption[];
};
/** Dropdown styled like the kit TextField; composes inside <Field>. */
export default function Select({
value,
onChange,
options,
children,
...props
}: Props) {
return (
<MuiTextField
select
size="small"
variant="outlined"
fullWidth
value={value}
onChange={(e) => onChange(e.target.value)}
{...props}
>
{options
? options.map((o) => (
<MenuItem key={o.value} value={o.value}>
{o.label}
</MenuItem>
))
: children}
</MuiTextField>
);
}
```
- [ ] **Step 2: `StatusChip.tsx`**
```tsx
import Chip, { type ChipProps } from "@mui/material/Chip";
type Props = Omit<ChipProps, "color"> & {
color?: "default" | "success" | "error" | "warning" | "info";
};
/** Semantic status chip. Pass onClick to make it a toggle. */
export default function StatusChip({
color = "default",
size = "small",
onClick,
sx,
...props
}: Props) {
return (
<Chip
color={color}
size={size}
onClick={onClick}
sx={{ fontWeight: 600, ...(onClick ? { cursor: "pointer" } : {}), ...sx }}
{...props}
/>
);
}
```
- [ ] **Step 3: `Checkbox.tsx`**
```tsx
import FormControlLabel from "@mui/material/FormControlLabel";
import MuiCheckbox from "@mui/material/Checkbox";
export function CheckboxField({
label,
checked,
onChange,
}: {
label: React.ReactNode;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<FormControlLabel
control={
<MuiCheckbox
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
}
label={label}
/>
);
}
```
(Import `React` type or use `import type { ReactNode } from "react"` and type `label: ReactNode`.)
- [ ] **Step 4: `Alert.tsx`**
```tsx
import type { ReactNode } from "react";
import MuiAlert from "@mui/material/Alert";
export default function Alert({
severity = "info",
children,
onClose,
}: {
severity?: "success" | "error" | "warning" | "info";
children: ReactNode;
onClose?: () => void;
}) {
return (
<MuiAlert severity={severity} onClose={onClose} sx={{ borderRadius: 2 }}>
{children}
</MuiAlert>
);
}
```
- [ ] **Step 5:** export from `index.ts`: `Select` (+ `type SelectOption`), `StatusChip`, `{ CheckboxField }`, `Alert`.
- [ ] **Step 6: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — Select, StatusChip, CheckboxField, Alert`; + trailer).
---
## Task 2: `Tabs` + `TabPanel`, `Pagination`
**Files:** Create `Tabs.tsx`, `Pagination.tsx`; modify `index.ts`.
- [ ] **Step 1: `Tabs.tsx`**
```tsx
import type { ReactNode } from "react";
import MuiTabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import Box from "@mui/material/Box";
export interface TabDef {
value: string;
label: string;
}
export function Tabs({
value,
onChange,
tabs,
}: {
value: string;
onChange: (v: string) => void;
tabs: TabDef[];
}) {
return (
<MuiTabs
value={value}
onChange={(_, v) => onChange(v)}
sx={{
borderBottom: 1,
borderColor: "divider",
minHeight: 44,
"& .MuiTab-root": { textTransform: "none", fontWeight: 600 },
}}
>
{tabs.map((t) => (
<Tab key={t.value} value={t.value} label={t.label} />
))}
</MuiTabs>
);
}
export function TabPanel({
value,
current,
children,
}: {
value: string;
current: string;
children: ReactNode;
}) {
if (value !== current) return null;
return <Box sx={{ pt: 2.5 }}>{children}</Box>;
}
```
- [ ] **Step 2: `Pagination.tsx`**
```tsx
import Box from "@mui/material/Box";
import MuiPagination from "@mui/material/Pagination";
export default function Pagination({
page,
pageCount,
onChange,
}: {
page: number;
pageCount: number;
onChange: (p: number) => void;
}) {
if (pageCount <= 1) return null;
return (
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
<MuiPagination
page={page}
count={pageCount}
onChange={(_, p) => onChange(p)}
color="primary"
shape="rounded"
/>
</Box>
);
}
```
- [ ] **Step 3:** export `{ Tabs, TabPanel }` (+ `type TabDef`) and `Pagination` from `index.ts`.
- [ ] **Step 4: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — Tabs/TabPanel + Pagination`; + trailer).
---
## Task 3: Sortable `DataTable`
**Files:** Modify `src/admin/ui/DataTable.tsx`.
- [ ] **Step 1:** Extend the types:
- `DataColumn<T>`: add `sortKey?: string;`
- `DataTableProps<T>`: add `sortBy?: string; sortDir?: "asc" | "desc"; onSort?: (key: string) => void;`
- [ ] **Step 2:** In the header cell render, if `c.sortKey && onSort` is set, wrap the header in `TableSortLabel`:
```tsx
import TableSortLabel from "@mui/material/TableSortLabel";
// ...header cell:
{
c.sortKey && onSort ? (
<TableSortLabel
active={sortBy === c.sortKey}
direction={sortBy === c.sortKey ? (sortDir ?? "asc") : "asc"}
onClick={() => onSort(c.sortKey!)}
>
{c.header}
</TableSortLabel>
) : (
c.header
);
}
```
Non-sortable columns (no `sortKey`) render the plain header (unchanged). The existing `bold`/`mono`/`align`/`rowInactive`/`empty` behavior is untouched.
- [ ] **Step 3: Verify** `tsc -b` clean, `build:client` OK, `vitest run` (148). **Commit** (`feat(mui): kit — sortable DataTable headers (TableSortLabel)`; + trailer).
---
## Task 4: `DateField` + LocalizationProvider
**Files:** Create `src/admin/ui/DateField.tsx`; modify `MuiProvider.tsx`, `index.ts`.
- [ ] **Step 1: Add `LocalizationProvider` to `MuiProvider.tsx`** wrapping its children, so date pickers work everywhere:
```tsx
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3";
import { cs } from "date-fns/locale";
// inside MuiProvider, wrap {children} (and the sync) :
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={cs}>
<MuiColorSchemeSync />
{children}
</LocalizationProvider>;
```
> Verify the exact adapter import path for the installed `@mui/x-date-pickers@8` against its docs (it may be `@mui/x-date-pickers/AdapterDateFns` rather than `...V3`). Pick the one that resolves; report which.
- [ ] **Step 2: `DateField.tsx`** — string-in / string-out (`yyyy-MM-dd`) to match existing form state:
```tsx
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { parseISO, format, isValid } from "date-fns";
export default function DateField({
value,
onChange,
...props
}: { value: string; onChange: (v: string) => void } & Record<string, unknown>) {
const dateValue = value ? parseISO(value) : null;
return (
<DatePicker
value={dateValue && isValid(dateValue) ? dateValue : null}
onChange={(d) => onChange(d && isValid(d) ? format(d, "yyyy-MM-dd") : "")}
format="dd.MM.yyyy"
slotProps={{ textField: { size: "small", fullWidth: true } }}
{...props}
/>
);
}
```
- [ ] **Step 3:** export `DateField` from `index.ts`.
- [ ] **Step 4: Verify** `tsc -b` clean, `build:client` OK. **Commit** (`feat(mui): kit — DateField over MUI X + cs LocalizationProvider`; + trailer).
---
## Task 5: Extend `/ui-kit` showcase + final review
**Files:** Modify `src/admin/pages/UiKit.tsx`.
- [ ] **Step 1:** Add a section rendering each new component with sample data, in the existing `ScopedCssBaseline` page: a `Select` (3 options), `StatusChip`s (each color), `Tabs`+`TabPanel` (2 tabs), `Pagination` (page 2 of 5), `CheckboxField`, `Alert` (one of each severity), a sortable `DataTable` (3 columns, one `sortKey`, local sort state), and a `DateField`.
- [ ] **Step 2: Verify** `tsc -b` clean, `npm run build` (server+client) OK, `npx vitest run` (148). **Commit** (`feat(mui): showcase new kit components in /ui-kit`; + trailer).
- [ ] **Step 3:** Final review of all new components (parallel/adversarial), then finishing-a-development-branch after the user's `/ui-kit` visual check.
---
## Self-review notes
- All new components compose with the existing `Field` (Select/DateField are inputs; Field provides the label) and follow the size=small/outlined/fullWidth defaults.
- `DataTable` sort is **additive** — existing callers (Vehicles) are unaffected (no `sortKey`/`onSort` → plain headers).
- `DateField` is string-in/string-out so it drops into existing form state (`start_date: ""`), and uses cs locale + `dd.MM.yyyy` (matching the date convention in CLAUDE.md).
- Deferred: `Autocomplete`, `FilterBar` — built when warehouse/list pages are migrated.

View File

@@ -0,0 +1,67 @@
# MUI Migration — Phase 4: Users (Uživatelé) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Migrate `src/admin/pages/Users.tsx` onto the MUI kit, mirroring the already-migrated `Vehicles.tsx`, preserving ALL data logic. Presentation only changes.
**Tech Stack:** MUI v7 kit (`src/admin/ui/`): `Button`, `Card`, `DataTable`, `Modal`, `ConfirmDialog`, `Field`, `TextField`, `SwitchField`, **`Select`** (new), **`StatusChip`** (new). MUI `Avatar`/`IconButton` used directly (as Vehicles uses `IconButton`).
**Reference:** `src/admin/pages/Vehicles.tsx` is the canonical migrated page — copy its structure (Box + motion.div header, `Button startIcon`, `Card` + `DataTable`, `Modal` with `Field`/`TextField`, `ConfirmDialog`). The Users data layer already exists; do not change it.
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check (dev server) is the user's.
---
## Behavior to preserve VERBATIM (from the scout)
- **Three mutations + invalidation:** `USER_INVALIDATE = ["users","trips","attendance","leave-requests","leave","projects"]`. `saveUser` (PUT if `editingUser` else POST), `deleteUser` (DELETE), `toggleUser` (PUT `{id,is_active}`). Keep all `onSuccess` messages.
- **Self-edit AuthContext sync (critical):** in `saveUser.onSuccess`, when `editingUser && currentUser && Number(editingUser.id)===Number(currentUser.id)`, call `updateUser({ username, email, fullName: \`${first_name} ${last_name}\`.trim() })`. Then `setShowModal(false); setEditingUser(null);`then`await new Promise(r=>setTimeout(r,300))` **(intentional delay — keep it)** before the success toast.
- **Auth guard:** `const { user: currentUser, updateUser, hasPermission } = useAuth();``if (!hasPermission("users.view")) return <Forbidden/>;`
- **Self-protection:** the delete action is **hidden** and the status toggle is **disabled** when `user.id === currentUser?.id`. These reference the live `currentUser` — keep them in the column render closures.
- **Password:** required only on create (`!editingUser`); empty string on edit means "keep existing".
- **Role dropdown:** options from `roleListOptions()` query; default to `roles[0]?.id` on create. `role_id` is `number|string` in form state.
- **`FormData` shape:** `{ username, email, password, first_name, last_name, role_id, is_active }`.
---
## Task 1: Migrate `Users.tsx`
**Files:** Modify `src/admin/pages/Users.tsx` (read it first; it's the source of truth for the logic above).
- [ ] **Step 1: Read** `src/admin/pages/Users.tsx` fully and `src/admin/pages/Vehicles.tsx` (the template). Keep ALL hooks, mutations, state, handlers, and the guard exactly; change only the rendered JSX.
- [ ] **Step 2: Imports** — from `../ui`: `Button, Card, DataTable, Modal, ConfirmDialog, Field, TextField, SwitchField, Select, StatusChip, type DataColumn`. From `@mui/material`: `Box, Typography, Avatar, IconButton`. Keep `motion` (framer-motion), `useQuery`, `roleListOptions`, `useApiMutation`, `useAlert`, `useAuth`, `Forbidden`. **Remove** old imports: `FormModal`, `ConfirmModal`, `FormField`.
- [ ] **Step 3: Page shell** — mirror Vehicles: outer `Box`; `motion.div` header (`Typography variant="h4"` "Správa uživatelů" + `Button startIcon={PlusIcon}` "Přidat uživatele"); `motion.div` + `Card` wrapping the `DataTable`. Loading state keeps the `admin-spinner` markup.
- [ ] **Step 4: DataTable columns** (`DataColumn<User>[]`, defined inside the component so they close over `currentUser`/handlers):
- **Uživatel:** composite render — MUI `Avatar` (initial of `full_name`/`username`, `bgcolor:"primary.main"`) + name (`Typography`) + `@username` (`Typography variant="caption" color="text.secondary"`).
- **Email:** `render: (u) => u.email`.
- **Role:** `StatusChip` colored by role — map `"admin"``color="warning"`, else `"default"`; label = `roleDisplay`/role name.
- **Stav:** clickable `StatusChip` (`color={u.is_active?"success":"default"}`, `onClick``toggleActive(u)`), but **non-clickable / no onClick when `u.id===currentUser?.id`** (self-protection).
- **Akce:** `IconButton` edit (always); `IconButton` delete (`color="error"`) **only when `u.id !== currentUser?.id`**. Use the same edit/delete SVGs as Vehicles.
- [ ] **Step 5: Add/Edit `Modal`**`Field` + kit `TextField` for username/email/first_name/last_name; password `TextField type="password"` (label notes optional on edit); **`Select`** (inside a `Field` label="Role") for `role_id` with `options` from the roles query (`value: String(r.id), label: r.display_name||r.name`) — note `Select` is string-based, so store/compare `role_id` as string or `String(...)` at the boundary; `SwitchField` for `is_active`. Add a local `errors` state + validation in `handleSubmit` (required: username, email, first_name, last_name; password required when `!editingUser`) mirroring Vehicles, before `saveUser.mutateAsync`.
- [ ] **Step 6: Delete `ConfirmDialog`** — drop-in replacement for `ConfirmModal` (title "Smazat uživatele", message with the user's name, `confirmVariant="danger"`).
- [ ] **Step 7: Verify** `npx tsc -b --noEmit` clean; `npm run build` OK; `npx vitest run` (148). Drop all `admin-*` class names.
- [ ] **Step 8: Commit** (`feat(mui): migrate Users (Uživatelé) page onto MUI kit`; + `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer).
- [ ] **Step 9: MANUAL (USER, dev server) — deferred:** list renders (avatar/role/status); status toggle works + is disabled for self; delete hidden for self; add (password required) + edit (password optional, role Select) save; self-edit updates the sidebar name; validation; dark+light; no console errors.
---
## Task 2: Review + finish
- [ ] Spec-compliance review (esp. the self-edit AuthContext sync, self-protection, password-on-create-only, role Select string boundary, USER_INVALIDATE) then code-quality review.
- [ ] finishing-a-development-branch after the user's visual check.
---
## Self-review notes
- The role `Select` is string-based (per the Phase 3 review fix); convert `role_id` at the boundary (`String(r.id)` for options/value; the server accepts the string or coerce on submit if the API needs a number — check how the existing code sends it and match).
- Self-protection guards and the self-edit `updateUser` sync are the highest-risk items — the spec reviewer must confirm both survive the column-render refactor.
- No `users.css` exists; Users uses shared classes — nothing to delete, it just stops using them.

View File

@@ -0,0 +1,85 @@
# MUI Migration — Phase 5: Projects (Projekty) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Migrate `src/admin/pages/Projects.tsx` onto the MUI kit. It's a **paginated, server-sorted list** with a create modal (customer/user `Select`s, `DateField`s, multiline notes) and a delete confirm that has an extra "delete files from disk" checkbox. Mirror `Vehicles.tsx`/`Users.tsx`; preserve ALL data logic.
**Prereq kit tweak (Task 1):** `ConfirmDialog` needs to accept optional extra content below the message (for the delete-files checkbox), since `message` is a string.
**Tech Stack:** MUI v7 kit (`src/admin/ui/`): `Button, Card, DataTable` (now sortable), `Pagination`, `Modal, ConfirmDialog, Field, TextField, Select, DateField, StatusChip, CheckboxField, type DataColumn`. MUI `Box/Typography/IconButton` direct.
**Reference:** `Vehicles.tsx` + `Users.tsx` (migrated). The Projects data layer is authoritative — keep it.
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run` (148). Visual check is the user's.
---
## Behavior to preserve VERBATIM (from the scout)
- **List state:** `const { sort, order, handleSort, activeSort } = useTableSort("project_number")`; `search`/`page` state; **reset `setPage(1)` when search changes**. `usePaginatedQuery<Project>(projectListOptions({ search, sort, order, page }))``{ items, pagination, isPending, isFetching }`.
- **isFetching dimming:** the list Card gets `sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}`.
- **Create-only** (edit navigates to the `/projects/:id` detail route — NOT a modal). `createForm = { name, customer_id, responsible_user_id, status: "aktivni", start_date, end_date, notes }`, `createErrors`, `creating`. Dependent queries enabled only while the modal is open: `offerCustomersOptions()`, `userListOptions("projects.view")` (filter to `is_active`), `projectNextNumberOptions()`. `createMutation`: POST `/projects`, `invalidate: ["projects","orders","offers","invoices","attendance"]`, onSuccess close + "Projekt byl vytvořen". **Validation: only `name` required ("Zadejte název projektu").**
- **Delete:** `deleteTarget`/`deleteFiles` state. `deleteMutation`: DELETE `/projects/:id`, `invalidate: ["projects","warehouse","orders","offers","invoices","attendance"]`, called as `mutateAsync({ id, delete_files: deleteFiles })`, onSuccess toast `data?.message || "Projekt byl smazán"` + reset.
- **Permissions:** `projects.view``<Forbidden/>`; `projects.create` hides the add button; `projects.delete` hides the per-row delete; delete also hidden when `p.order_id` (order-linked projects can't be manually deleted).
- **Status constants:** `STATUS_LABELS = { aktivni:"Aktivní", dokonceny:"Dokončený", zruseny:"Zrušený" }`. Map to `StatusChip` colors: `aktivni``success`, `dokonceny``info`, `zruseny``default`.
- **Next number:** `nextNumberQuery.data?.number ?? nextNumberQuery.data?.next_number ?? "…"`.
---
## Task 1: Extend `ConfirmDialog` with optional extra content
**Files:** Modify `src/admin/ui/ConfirmDialog.tsx`.
- [ ] **Step 1:** Add `children?: ReactNode` to `ConfirmDialogProps` (import `type { ReactNode }`). Render it in `DialogContent` BELOW the message (live — not frozen, so the checkbox stays interactive):
```tsx
<DialogContent>
<DialogContentText>{shown.message}</DialogContentText>
{children}
</DialogContent>
```
- [ ] **Step 2:** Verify `tsc -b` clean; `build:client` OK; existing callers (Vozidla, Users) unaffected (no `children` → unchanged). **Commit** (`feat(mui): ConfirmDialog optional children (extra content below message)`; + trailer).
---
## Task 2: Migrate `Projects.tsx`
**Files:** Modify `src/admin/pages/Projects.tsx` (read it first — authoritative for the logic above).
- [ ] **Step 1: Read** `src/admin/pages/Projects.tsx` fully + `Users.tsx`/`Vehicles.tsx`. Keep ALL hooks/queries/mutations/handlers/permission-guards/state. Change only presentation.
- [ ] **Step 2: Imports** — from `../ui`: `Button, Card, DataTable, Pagination, Modal, ConfirmDialog, Field, TextField, Select, DateField, StatusChip, CheckboxField, type DataColumn`. MUI `Box, Typography, IconButton` direct. Keep `motion`, `useNavigate`, the query options, `useApiMutation`, `useAlert`, `useAuth`, `Forbidden`. Remove `FormModal`/`ConfirmModal`/`FormField`/`AdminDatePicker`/legacy `Pagination` imports.
- [ ] **Step 3: Page shell** — Box + motion.div header (`Typography variant="h4"` "Projekty" + `Button startIcon={PlusIcon}` "Nový projekt", hidden without `projects.create`). A search `TextField` (no `Field` wrapper) that sets search + `setPage(1)`.
- [ ] **Step 4: List**`Card` (with the `isFetching` opacity sx) wrapping a sortable `DataTable<Project>`:
- Wire sort: `sortBy={sort} sortDir={order} onSort={handleSort}`. Give sortable columns a `sortKey` (e.g. `project_number`, `name` — match the server's accepted sort keys from the original `SortIcon` usage).
- Columns: project number (`mono`, `sortKey`), name (`sortKey`), customer, responsible user, status (`StatusChip` per the color map), linked order number (if `order_id`), start/end dates (`mono`, via the existing date formatter), actions — a detail link/`IconButton` that `navigate(\`/projects/${p.id}\`)`for edit, and a delete`IconButton color="error"`shown only when`hasPermission("projects.delete") && !p.order_id`.
- Below the table: `<Pagination page={page} pageCount={pagination?.totalPages ?? 1} onChange={setPage} />` (use the actual page-count field from `pagination` — check the shape).
- [ ] **Step 5: Create `Modal`** (`isOpen={showCreate}`, `loading={creating}`, title "Nový projekt", subtitle/hint with the next number): `Field`+`TextField` for name (required, error from `createErrors`); `Select` (in a `Field`) for customer (`offerCustomersOptions`) and responsible user (active users) — options `{ value: String(id), label }`, convert at the boundary; `Select` for status (the STATUS_LABELS options); two `DateField`s for start/end (string in/out — matches `createForm.start_date`); `TextField multiline minRows={3}` for notes. Validation: only name required.
- [ ] **Step 6: Delete `ConfirmDialog`** — title "Smazat projekt", message with the project name, `confirmVariant="danger"`, and as **children** a `CheckboxField` "Smazat i soubory z disku" bound to `deleteFiles`. `onConfirm` calls the existing delete handler (`mutateAsync({ id, delete_files: deleteFiles })`).
- [ ] **Step 7: Verify** `tsc -b` clean; `npm run build` OK; `npx vitest run` (148). Drop `admin-*` classes. (Projects has no own CSS file; it uses shared classes.)
- [ ] **Step 8: Commit** (`feat(mui): migrate Projects (Projekty) page onto MUI kit`; + trailer).
- [ ] **Step 9: MANUAL (USER) — deferred:** list renders + sorts (click headers) + paginates + dims while fetching; create modal (customer/user/status selects, date pickers, notes; name-required validation; next number shown) saves; delete confirm with the files checkbox works; order-linked projects show no delete; permission gating; dark+light.
---
## Task 3: Review + finish
- [ ] Spec-compliance review (server sort/pagination wiring, the create `invalidate` arrays, name-only validation, delete `{id, delete_files}` body + the checkbox, permission/order-link gating, status color map, the Select string boundaries) → code-quality review.
- [ ] finishing-a-development-branch after the user's visual check.
---
## Self-review notes
- This is the first page to exercise the **sortable DataTable** + **Pagination** + **DateField** + **Select** together — confirm the sort keys match what the server accepts and the pagination page-count field name is correct (read the query options / a working paginated page).
- `Select` is string-based; convert customer/user/status ids at the form boundary (`String(...)` for options/value; send what the server expects).
- The delete-files checkbox rides in `ConfirmDialog` `children` (Task 1) — it's live (not frozen), so it toggles while open; it resets via the caller on close.
- Edit is a route navigation (`/projects/:id`), NOT a modal — don't add an edit modal.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,891 @@
# Bulk Plan Creation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Assign one project/category to many employees over a date range in a single action ("Hromadné přiřazení" on /plan-work).
**Architecture:** A new `POST /api/admin/plan/entries/bulk` endpoint drives a `bulkCreateEntries` service that, per employee, computes the eligible days in the range (weekday filter + Feature A's under-cap rule), groups contiguous eligible days into runs, and creates one range `work_plan_entries` row per run by **reusing `createEntry`**. The frontend adds a presentational `BulkPlanModal` (mirroring the single-create form + the existing bulk-attendance employee picker) wired from `PlanWork`, with a summary alert and `["plan"]` invalidation. No DB migration.
**Tech Stack:** Fastify 5 + Prisma (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (server-side, real test DB). Spec: `docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md`. Builds on Feature A (multi-record plan cells, v2.0.5).
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`. The frontend has no component-test harness (server-side tests only), so Task 2 gates on `tsc -b` + `build`.
---
## File Structure
**Backend**
- `src/schemas/plan.schema.ts` — add `BulkPlanEntrySchema`.
- `src/services/plan.service.ts` — add `bulkCreateEntries(...)` (reuses `createEntry`, `assertNotPastDate`, `assertActiveCategory`, `toDateOnly`, `MAX_RECORDS_PER_CELL`).
- `src/routes/admin/plan.ts` — add `POST /entries/bulk`.
**Frontend**
- `src/admin/components/BulkPlanModal.tsx` — new presentational modal.
- `src/admin/hooks/usePlanWork.ts` — add a `bulkCreate` mutation.
- `src/admin/pages/PlanWork.tsx` — toolbar button + bulk form state + toggles + submit wiring.
**Tests**
- `src/__tests__/plan.test.ts``bulkCreateEntries` service tests.
---
## Task 1: Backend — bulk endpoint + service
**Files:**
- Modify: `src/schemas/plan.schema.ts`
- Modify: `src/services/plan.service.ts`
- Modify: `src/routes/admin/plan.ts`
- Test: `src/__tests__/plan.test.ts`
- [ ] **Step 1: Write the failing service tests**
Add this block to the end of `src/__tests__/plan.test.ts` (before the final HTTP describe block is fine; anywhere at top level). It imports `bulkCreateEntries` — add it to the existing `import { ... } from "../services/plan.service"` list at the top of the file.
```ts
// ---------------------------------------------------------------------------
// bulkCreateEntries
// ---------------------------------------------------------------------------
describe("plan.service.bulkCreateEntries", () => {
it("creates one continuous range per employee when weekends are included", async () => {
// 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one
// range 06-01..06-03 covering all 3 days.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-wknd`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(1);
expect(res.data.created_days).toBe(3);
expect(res.data.skipped_days).toBe(0);
expect(res.data.users).toBe(1);
});
it("splits into per-work-week ranges and skips weekends when excluded", async () => {
// 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue.
// Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-05",
include_weekends: false,
project_id: null,
category: "work",
note: `${N}bulk-week`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(3);
expect(res.data.skipped_days).toBe(0);
});
it("skips days already at the cap and splits the range around them", async () => {
// Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on
// bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips
// 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on.
for (let i = 0; i < 3; i++) {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2096-07-02"),
date_to: new Date("2096-07-02"),
category: "work",
note: `${N}cap${i}`,
created_by: adminUserId,
},
});
}
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-07-01",
date_to: "2096-07-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-cap`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(2);
expect(res.data.skipped_days).toBe(1);
});
it("aggregates across multiple employees", async () => {
const res = await bulkCreateEntries(
{
user_ids: [adminUserId, noPermUserId],
date_from: "2096-08-03", // Friday
date_to: "2096-08-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-multi`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.users).toBe(2);
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(2);
});
it("rejects empty user_ids, past dates, bad range, and inactive category", async () => {
const empty = await bulkCreateEntries(
{
user_ids: [],
date_from: "2096-06-01",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in empty && empty.status).toBe(400);
const past = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2000-01-01",
date_to: "2000-01-02",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in past && past.status).toBe(403);
const badRange = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-10",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in badRange && badRange.status).toBe(400);
const badCat = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "__nope__",
note: `${N}x`,
},
adminUserId,
);
expect("error" in badCat && badCat.status).toBe(400);
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npx vitest run src/__tests__/plan.test.ts`
Expected: FAIL — `bulkCreateEntries` is not exported / not a function.
- [ ] **Step 3: Implement `bulkCreateEntries` in `plan.service.ts`**
Add at the end of `src/services/plan.service.ts` (after `listOverrides`):
```ts
// ---------------------------------------------------------------------------
// Bulk entry creation
// ---------------------------------------------------------------------------
/**
* Bulk-assign a project/category to many employees over a date range. For each
* employee, walk the range, keep the days that pass the weekday filter
* (weekends excluded unless include_weekends) AND are under the per-cell cap,
* group contiguous kept days into runs, and create one range entry per run by
* reusing createEntry (so cap / past-date / category validation stays DRY).
*
* Days that pass the weekday filter but are already at the cap are counted in
* skipped_days (and split the range). Weekend days, when excluded, are out of
* scope and are NOT counted as skipped.
*/
export async function bulkCreateEntries(
input: {
user_ids: number[];
date_from: string;
date_to: string;
include_weekends: boolean;
project_id?: number | null;
category: string;
note: string;
},
actorUserId: number,
): Promise<
Result<{
created_entries: number;
created_days: number;
skipped_days: number;
users: number;
}>
> {
if (input.user_ids.length === 0) {
return { error: "Vyberte alespoň jednoho zaměstnance", status: 400 };
}
if (input.date_to < input.date_from) {
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
}
const lockFrom = assertNotPastDate(input.date_from, false);
if (lockFrom) return lockFrom;
const lockTo = assertNotPastDate(input.date_to, false);
if (lockTo) return lockTo;
const catErr = await assertActiveCategory(input.category);
if (catErr) return catErr;
const from = toDateOnly(input.date_from);
const to = toDateOnly(input.date_to);
// The day list for the whole range (UTC midnights), built once.
const days: Date[] = [];
for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
days.push(new Date(d));
}
let createdEntries = 0;
let createdDays = 0;
let skippedDays = 0;
for (const userId of input.user_ids) {
// Per-day cap counts from this user's existing active entries.
const existing = await prisma.work_plan_entries.findMany({
where: {
user_id: userId,
is_deleted: false,
date_from: { lte: to },
date_to: { gte: from },
},
select: { date_from: true, date_to: true },
});
const countFor = (day: Date): number =>
existing.filter((e) => e.date_from <= day && e.date_to >= day).length;
// Eligibility per day (weekday filter + under-cap). Tally cap skips.
const eligible: boolean[] = [];
for (const day of days) {
const dow = day.getUTCDay(); // 0 = Sun, 6 = Sat
const isWeekend = dow === 0 || dow === 6;
if (isWeekend && !input.include_weekends) {
eligible.push(false);
continue;
}
if (countFor(day) >= MAX_RECORDS_PER_CELL) {
skippedDays++;
eligible.push(false);
continue;
}
eligible.push(true);
}
// Group contiguous eligible days into runs; create one range per run.
let runStart = -1;
for (let i = 0; i <= days.length; i++) {
const ok = i < days.length && eligible[i];
if (ok && runStart === -1) {
runStart = i;
} else if (!ok && runStart !== -1) {
const segFrom = days[runStart].toISOString().slice(0, 10);
const segTo = days[i - 1].toISOString().slice(0, 10);
const runDays = i - runStart;
const res = await createEntry(
{
user_id: userId,
date_from: segFrom,
date_to: segTo,
project_id: input.project_id ?? null,
category: input.category,
note: input.note,
},
actorUserId,
false,
);
if ("data" in res) {
createdEntries++;
createdDays += runDays;
} else {
// Rare race (e.g. the cap filled concurrently). Treat as skipped.
skippedDays += runDays;
}
runStart = -1;
}
}
}
return {
data: {
created_entries: createdEntries,
created_days: createdDays,
skipped_days: skippedDays,
users: input.user_ids.length,
},
oldData: null,
};
}
```
- [ ] **Step 4: Run the service tests to verify they pass**
Run: `npx vitest run src/__tests__/plan.test.ts`
Expected: PASS (including the 5 new bulk tests).
- [ ] **Step 5: Add `BulkPlanEntrySchema` to `plan.schema.ts`**
Append to `src/schemas/plan.schema.ts`:
```ts
export const BulkPlanEntrySchema = z
.object({
user_ids: z
.array(intFromForm)
.min(1, "Vyberte alespoň jednoho zaměstnance"),
date_from: isoDate,
date_to: isoDate,
include_weekends: z
.union([z.boolean(), z.string()])
.transform((v) => v === true || v === "true" || v === "1")
.default(false),
project_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.nullish(),
category: planCategoryEnum.default("work"),
note: z.string().max(500, "Maximálně 500 znaků").default(""),
})
.refine((v) => v.date_to >= v.date_from, {
message: "Datum do musí být stejné nebo po datumu od",
path: ["date_to"],
});
```
- [ ] **Step 6: Add the `POST /entries/bulk` route**
In `src/routes/admin/plan.ts`: add `bulkCreateEntries` to the existing import from `../../services/plan.service` and `BulkPlanEntrySchema` to the import from `../../schemas/plan.schema`. Then add this route immediately after the `POST /entries` handler (after its closing `);`, before `PATCH /entries/:id`):
```ts
// --- POST /plan/entries/bulk ---
app.post(
"/entries/bulk",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
const body = parseBody(BulkPlanEntrySchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const result = await bulkCreateEntries(
body.data!,
request.authData!.userId,
);
if ("error" in result) return error(reply, result.error, result.status);
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "work_plan_entry",
description: `Hromadné přiřazení: ${body.data!.category}${result.data.users} zaměstnanců, ${body.data!.date_from}${body.data!.date_to} (${result.data.created_days} dní)`,
});
return success(reply, result.data, 201, "Hromadné přiřazení dokončeno");
},
);
```
- [ ] **Step 7: Add an HTTP route test**
Add to the `describe("HTTP /api/admin/plan", ...)` block in `src/__tests__/plan.test.ts`:
```ts
it("POST /entries/bulk requires attendance.manage", async () => {
const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, {
user_ids: [adminUserId],
date_from: "2095-06-01",
date_to: "2095-06-01",
include_weekends: true,
category: "work",
note: `${N}bulk-forbidden`,
});
expect(res.statusCode).toBe(403);
});
it("POST /entries/bulk creates entries for an admin and returns a summary", async () => {
const res = await authPost("/api/admin/plan/entries/bulk", adminToken, {
user_ids: [adminUserId],
date_from: "2095-06-05", // Sunday; weekends on → 1 day
date_to: "2095-06-05",
include_weekends: true,
category: "work",
note: `${N}bulk-http`,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.created_days).toBe(1);
expect(body.data.users).toBe(1);
});
```
- [ ] **Step 8: Gate**
Run: `npx tsc -b --noEmit` (exit 0), then `npx vitest run` (all pass).
- [ ] **Step 9: Commit**
```bash
git add src/schemas/plan.schema.ts src/services/plan.service.ts src/routes/admin/plan.ts src/__tests__/plan.test.ts
git commit -m "feat(plan): bulk entry creation endpoint + service"
```
---
## Task 2: Frontend — bulk modal + wiring
**Files:**
- Create: `src/admin/components/BulkPlanModal.tsx`
- Modify: `src/admin/hooks/usePlanWork.ts`
- Modify: `src/admin/pages/PlanWork.tsx`
Frontend-only; gate on `tsc -b` + `build` (no component-test harness). Mirrors `BulkAttendanceModal.tsx` (presentational; parent owns state + toggles + submit).
- [ ] **Step 1: Create `BulkPlanModal.tsx`**
```tsx
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import {
Modal,
Field,
DateField,
Select,
TextField,
CheckboxField,
} from "../ui";
import type { PlanUser, PlanCategory } from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects";
export interface BulkPlanForm {
date_from: string;
date_to: string;
is_range: boolean;
user_ids: string[];
include_weekends: boolean;
project_id: string;
category: string;
note: string;
}
interface Props {
isOpen: boolean;
onClose: () => void;
form: BulkPlanForm;
setForm: (form: BulkPlanForm) => void;
users: PlanUser[];
projects: Project[];
categories: PlanCategory[];
onSubmit: () => void;
submitting: boolean;
toggleUser: (userId: number) => void;
toggleAllUsers: () => void;
}
export default function BulkPlanModal({
isOpen,
onClose,
form,
setForm,
users,
projects,
categories,
onSubmit,
submitting,
toggleUser,
toggleAllUsers,
}: Props) {
const activeCategories = categories.filter((c) => c.is_active);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Hromadné přiřazení"
maxWidth="lg"
loading={submitting}
onSubmit={onSubmit}
submitText="Vytvořit"
submitDisabled={form.user_ids.length === 0}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.25, mb: 2 }}
>
Vytvoří záznamy pro vybrané dny u zvolených zaměstnanců. Dny, které
mají 3 záznamy, se přeskočí.
</Typography>
<Field label="Datum od">
<DateField
value={form.date_from}
onChange={(val) => setForm({ ...form, date_from: val })}
/>
</Field>
<Field label="Rozsah dnů">
<CheckboxField
label="Více dní"
checked={form.is_range}
onChange={(checked) => setForm({ ...form, is_range: checked })}
/>
</Field>
{form.is_range && (
<Field label="Datum do">
<DateField
value={form.date_to}
onChange={(val) => setForm({ ...form, date_to: val })}
/>
</Field>
)}
<Field label="Víkendy">
<CheckboxField
label="Zahrnout víkendy"
checked={form.include_weekends}
onChange={(checked) =>
setForm({ ...form, include_weekends: checked })
}
/>
</Field>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 0.5 }}>
<Typography
component="span"
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Zaměstnanci
</Typography>
<Typography
component="button"
type="button"
onClick={toggleAllUsers}
variant="caption"
sx={{
background: "none",
border: "none",
p: 0,
cursor: "pointer",
fontWeight: 500,
color: "primary.main",
}}
>
{form.user_ids.length === users.length
? "Odznačit vše"
: "Vybrat vše"}
</Typography>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 0.25,
maxHeight: 200,
overflowY: "auto",
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
border: 1,
borderColor: "divider",
}}
>
{users.map((u) => (
<CheckboxField
key={u.id}
label={u.full_name}
checked={form.user_ids.includes(String(u.id))}
onChange={() => toggleUser(u.id)}
/>
))}
</Box>
<Typography variant="caption" color="text.secondary">
Vybráno: {form.user_ids.length} z {users.length}
</Typography>
</Box>
<Field label="Projekt">
<Select
value={form.project_id}
onChange={(val) => setForm({ ...form, project_id: val })}
options={[
{ value: "", label: "— bez projektu —" },
...projects.map((p) => ({ value: String(p.id), label: p.name })),
]}
/>
</Field>
<Field label="Kategorie">
<Select
value={form.category}
onChange={(val) => setForm({ ...form, category: val })}
options={activeCategories.map((c) => ({
value: c.key,
label: c.label,
}))}
/>
</Field>
<Field label="Text poznámky (volitelný)">
<TextField
multiline
minRows={2}
value={form.note}
onChange={(e) => setForm({ ...form, note: e.target.value })}
inputProps={{ maxLength: 500 }}
/>
</Field>
</Modal>
);
}
```
- [ ] **Step 2: Add a `bulkCreate` mutation to `usePlanWork.ts`**
After the `deleteOverride` mutation definition (before the `rollbackMutation` function), add:
```ts
const bulkCreate = useMutation({
mutationFn: (body: any) =>
apiCall("/api/admin/plan/entries/bulk", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
}),
onSuccess: () => {
// No optimistic patch — bulk can touch many cells; just invalidate and
// let the grid refetch the authoritative state.
invalidate();
},
});
```
Add `bulkCreate` to the hook's return object (next to `createEntry`, etc.).
- [ ] **Step 3: Wire the bulk modal into `PlanWork.tsx`**
(a) Add imports near the other component imports:
```ts
import BulkPlanModal, { type BulkPlanForm } from "../components/BulkPlanModal";
```
(b) Pull `bulkCreate` out of the `usePlanWork(...)` destructure (alongside `createEntry`, etc.).
(c) Add bulk state + a default form (place near the other `useState` declarations, after `catModalOpen`):
```ts
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkSubmitting, setBulkSubmitting] = useState(false);
const [bulkForm, setBulkForm] = useState<BulkPlanForm>(() => {
const today = todayIsoLocal();
return {
date_from: today,
date_to: today,
is_range: false,
user_ids: [],
include_weekends: false,
project_id: "",
category: "work",
note: "",
};
});
const planUsers = grid?.users ?? [];
const toggleBulkUser = useCallback((userId: number) => {
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.includes(String(userId))
? prev.user_ids.filter((u) => u !== String(userId))
: [...prev.user_ids, String(userId)],
}));
}, []);
const toggleAllBulkUsers = useCallback(() => {
const allIds = (grid?.users ?? []).map((u) => String(u.id));
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.length === allIds.length ? [] : allIds,
}));
}, [grid]);
const submitBulk = useCallback(async () => {
if (bulkForm.user_ids.length === 0) return;
setBulkSubmitting(true);
try {
const data: any = await bulkCreate.mutateAsync({
user_ids: bulkForm.user_ids.map(Number),
date_from: bulkForm.date_from,
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
include_weekends: bulkForm.include_weekends,
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
category: bulkForm.category,
note: bulkForm.note,
});
const skipped =
data?.skipped_days > 0
? ` ${data.skipped_days} dní přeskočeno (limit 3 na den).`
: "";
alert.success(
`Vytvořeno ${data?.created_days ?? 0} záznamů pro ${data?.users ?? 0} zaměstnanců.${skipped}`,
);
setBulkOpen(false);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
} finally {
setBulkSubmitting(false);
}
}, [bulkForm, bulkCreate, alert]);
```
(d) Add the toolbar button next to "Správa kategorií" (inside the same `canEdit` region of the toolbar `<Box>`):
```tsx
{
canEdit && (
<Button
variant="outlined"
color="inherit"
onClick={() => setBulkOpen(true)}
>
Hromadné přiřazení
</Button>
);
}
```
(e) Render the modal alongside `<PlanCategoriesModal ... />`:
```tsx
<BulkPlanModal
isOpen={bulkOpen}
onClose={() => setBulkOpen(false)}
form={bulkForm}
setForm={setBulkForm}
users={planUsers}
projects={projects}
categories={categories}
onSubmit={submitBulk}
submitting={bulkSubmitting}
toggleUser={toggleBulkUser}
toggleAllUsers={toggleAllBulkUsers}
/>
```
- [ ] **Step 4: Gate + Chrome check**
```bash
npx tsc -b --noEmit
npm run build
```
Then in Chrome on `/plan-work` (dev server already running — do not start it): click "Hromadné přiřazení", select 2 employees, a date range with "Více dní", leave weekends off, pick a project + category, submit; confirm the grid fills only MonFri across the selected people, the summary alert shows counts, and re-running on a day already at 3 records skips it.
- [ ] **Step 5: Commit**
```bash
git add src/admin/components/BulkPlanModal.tsx src/admin/hooks/usePlanWork.ts src/admin/pages/PlanWork.tsx
git commit -m "feat(plan): bulk assignment modal on /plan-work"
```
---
## Task 3: Release v2.0.6
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Bump version** to `"version": "2.0.6"` in `package.json`.
- [ ] **Step 2: Full gate**
```bash
npx tsc -b --noEmit
npx vitest run
npm run build
```
Expected: tsc exit 0, vitest all pass, build success.
- [ ] **Step 3: Merge to master + commit + tag**
```bash
git checkout master
git merge --ff-only feat/bulk-plan-creation
git add package.json
git commit -m "chore(release): v2.0.6 — bulk plan creation"
git tag -a v2.0.6 -m "v2.0.6 — bulk plan creation"
```
(If implementation happened directly on master, skip the checkout/merge.)
- [ ] **Step 4: Push to Gitea**
```bash
git push origin master
git push origin v2.0.6
```
- [ ] **Step 5: Build tarball + deploy to production (REQUIRES explicit user confirmation)**
```bash
tar -czf app-ts-2.0.6.tar.gz dist dist-client prisma package.json package-lock.json scripts
scp app-ts-2.0.6.tar.gz boha_admin@192.168.50.100:/tmp/
ssh boha_admin@192.168.50.100 'set -e; cd /var/www/app-ts && rm -rf dist dist-client prisma scripts package.json package-lock.json && tar -xzf /tmp/app-ts-2.0.6.tar.gz && npm install --omit=dev && npx prisma migrate deploy && pm2 restart app-ts --update-env'
```
No migration ships in this release → `prisma migrate deploy` reports "No pending migrations".
- [ ] **Step 6: Health check**
```bash
ssh boha_admin@192.168.50.100 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/api/admin/session'
```
Expected: `200` then `401`. Confirm pm2 shows `app-ts` v2.0.6 online.
---
## Notes for the implementer
- **Reuse, don't re-validate:** `bulkCreateEntries` calls `createEntry`, which already enforces the cap, past-date, category, and range checks. Don't duplicate those.
- **`skipped_days` semantics:** only weekday-eligible days blocked by the cap count as skipped. Weekend days (when excluded) are out of scope, not skipped.
- **No DB migration.** Don't run any prisma migrate command.
- **Do not start the dev server** — the user runs it. Use the running instance for the Chrome check.
- **Do not deploy to production without explicit confirmation** (Task 3, Steps 56).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,792 @@
# Odin Multi-Conversation Chat — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Turn the single-thread Odin assistant into a multi-conversation chat (top-tabs, rename/delete, auto-title) on the `/odin` page.
**Architecture:** New `ai_conversations` table; `ai_chat_messages` gains `conversation_id` (FK, cascade). Conversation-scoped service + routes replace the single `/history` endpoints. A new `OdinChat` component (+ focused sub-components) replaces `DashAssistant`, keeping all proven chat/extract/save logic but scoped to the active conversation.
**Tech Stack:** Fastify 5, Prisma 6 (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (real DB).
**Spec:** `docs/superpowers/specs/2026-06-08-odin-conversations-design.md`.
---
## File map
| File | Responsibility |
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `prisma/schema.prisma` | + `ai_conversations`; `ai_chat_messages.conversation_id` + relation + index swap |
| `prisma/migrations/20260608140000_add_ai_conversations/migration.sql` | create table, add column, backfill, enforce FK |
| `src/services/ai.service.ts` | replace `getChatHistory/appendChatMessages/clearChatHistory` with conversation-scoped fns |
| `src/schemas/ai.schema.ts` | + `AiCreateConversationSchema`, `AiRenameConversationSchema`; keep `AiHistoryAppendSchema` (reused for append) |
| `src/routes/admin/ai.ts` | replace `/history` routes with `/conversations*` routes |
| `src/__tests__/ai.test.ts` | replace history tests with conversation tests |
| `src/admin/lib/queries/ai.ts` | + conversation query options/types; remove `aiHistoryOptions` |
| `src/admin/components/odin/OdinTabs.tsx` | conversation tabs + new + rename/delete menu |
| `src/admin/components/odin/OdinThread.tsx` | message list + avatar + empty/busy states |
| `src/admin/components/odin/InvoiceReviewCard.tsx` | editable extracted-invoice card |
| `src/admin/components/odin/OdinComposer.tsx` | staged chips + attach + input + send |
| `src/admin/components/odin/OdinChat.tsx` | orchestrator: queries, state, the ported logic |
| `src/admin/pages/Odin.tsx` | render `OdinChat` (was `DashAssistant`) |
| `src/admin/components/dashboard/DashAssistant.tsx` | **deleted** |
**Controller note (migration):** Tasks that touch the DB require the dev server stopped. After Task 1's files are written, the **controller** runs: ask user to stop dev server → `npx prisma generate``npx prisma migrate deploy` (applies to dev `app` DB) → resume. Backend tsc is red between Task 1 and Task 2 (data model changes before the service is updated) — that is expected; Task 2 restores green.
---
## Task 1: Data model + migration
**Files:**
- Modify: `prisma/schema.prisma`
- Create: `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`
- [ ] **Step 1: Edit `prisma/schema.prisma`** — replace the existing `ai_chat_messages` model and add `ai_conversations` above it:
```prisma
model ai_conversations {
id Int @id @default(autoincrement())
user_id Int
title String @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
messages ai_chat_messages[]
@@index([user_id, id], map: "idx_ai_conv_user")
}
model ai_chat_messages {
id Int @id @default(autoincrement())
user_id Int
conversation_id Int
role String @db.VarChar(20)
content String @db.Text
created_at DateTime @default(now()) @db.Timestamp(0)
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
@@index([conversation_id, id], map: "idx_ai_chat_conv")
}
```
- [ ] **Step 2: Write the migration SQL** at `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`:
```sql
-- Multi-conversation Odin: conversations table + conversation_id on messages.
CREATE TABLE `ai_conversations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`user_id` INTEGER NOT NULL,
`title` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`),
INDEX `idx_ai_conv_user` (`user_id`, `id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `ai_chat_messages`
ADD COLUMN `conversation_id` INTEGER NULL AFTER `user_id`;
-- Backfill: one conversation per user who has messages ("keep as first conversation").
INSERT INTO `ai_conversations` (`user_id`, `title`, `created_at`, `updated_at`)
SELECT `user_id`, 'Konverzace', NOW(), NOW()
FROM `ai_chat_messages`
GROUP BY `user_id`;
UPDATE `ai_chat_messages` m
JOIN `ai_conversations` c ON c.`user_id` = m.`user_id`
SET m.`conversation_id` = c.`id`;
-- Enforce: drop old per-user index, NOT NULL, FK (cascade), per-conversation index.
ALTER TABLE `ai_chat_messages`
DROP INDEX `idx_ai_chat_user`,
MODIFY `conversation_id` INTEGER NOT NULL,
ADD CONSTRAINT `fk_ai_chat_conv` FOREIGN KEY (`conversation_id`)
REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE,
ADD INDEX `idx_ai_chat_conv` (`conversation_id`, `id`);
```
- [ ] **Step 3: Controller applies the migration** (not the implementer):
- Ask user to stop the dev server (Prisma engine DLL is locked on Windows otherwise).
- `npx prisma generate` — expect "Generated Prisma Client".
- `npx prisma migrate deploy` — expect "Applying migration `20260608140000_add_ai_conversations`" → "successfully applied".
- [ ] **Step 4: Commit**
```bash
git add prisma/schema.prisma prisma/migrations/20260608140000_add_ai_conversations
git commit -m "feat(odin): ai_conversations table + conversation_id on messages"
```
---
## Task 2: Backend — conversation service, schemas, routes
**Files:**
- Modify: `src/services/ai.service.ts`
- Modify: `src/schemas/ai.schema.ts`
- Modify: `src/routes/admin/ai.ts`
- [ ] **Step 1: Replace the chat-history block in `ai.service.ts`.** Delete `getChatHistory`, `appendChatMessages`, `clearChatHistory` and the `HISTORY_LIMIT` const, and put this in their place (keep the `StoredChatMessage` interface):
```ts
// ── Conversations (server-side, per user) ─────────────────────────────────
export interface StoredChatMessage {
role: string;
content: string;
created_at: Date;
}
export interface ConversationSummary {
id: number;
title: string;
updated_at: Date;
}
const DEFAULT_CONVERSATION_TITLE = "Nová konverzace";
const MESSAGE_LIMIT = 200; // cap a single thread read
export async function listConversations(
userId: number,
): Promise<ConversationSummary[]> {
return prisma.ai_conversations.findMany({
where: { user_id: userId },
orderBy: { id: "asc" }, // stable tab order
select: { id: true, title: true, updated_at: true },
});
}
export async function createConversation(
userId: number,
title?: string,
): Promise<ConversationSummary> {
return prisma.ai_conversations.create({
data: {
user_id: userId,
title: title?.trim() || DEFAULT_CONVERSATION_TITLE,
},
select: { id: true, title: true, updated_at: true },
});
}
/** Ownership-checked fetch; null when not found / not owned. */
async function ownConversation(userId: number, convId: number) {
return prisma.ai_conversations.findFirst({
where: { id: convId, user_id: userId },
select: { id: true, title: true },
});
}
export async function getConversationMessages(
userId: number,
convId: number,
): Promise<{ data: StoredChatMessage[] } | { error: string; status: number }> {
if (!(await ownConversation(userId, convId)))
return { error: "Konverzace nenalezena", status: 404 };
const rows = await prisma.ai_chat_messages.findMany({
where: { conversation_id: convId },
orderBy: { id: "desc" },
take: MESSAGE_LIMIT,
select: { role: true, content: true, created_at: true },
});
return { data: rows.reverse() };
}
export async function appendConversationMessages(
userId: number,
convId: number,
messages: { role: string; content: string }[],
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
const conv = await ownConversation(userId, convId);
if (!conv) return { error: "Konverzace nenalezena", status: 404 };
if (messages.length > 0) {
await prisma.ai_chat_messages.createMany({
data: messages.map((m) => ({
user_id: userId,
conversation_id: convId,
role: m.role,
content: m.content,
})),
});
}
// Auto-title from the first user message (only while still the default), and
// always bump updated_at.
const firstUser = messages.find((m) => m.role === "user");
const data: { updated_at: Date; title?: string } = { updated_at: new Date() };
if (conv.title === DEFAULT_CONVERSATION_TITLE && firstUser) {
const t = firstUser.content.replace(/\s+/g, " ").trim().slice(0, 60);
if (t) data.title = t;
}
await prisma.ai_conversations.update({ where: { id: convId }, data });
return { data: { ok: true } };
}
export async function renameConversation(
userId: number,
convId: number,
title: string,
): Promise<{ data: ConversationSummary } | { error: string; status: number }> {
if (!(await ownConversation(userId, convId)))
return { error: "Konverzace nenalezena", status: 404 };
const updated = await prisma.ai_conversations.update({
where: { id: convId },
data: { title: title.trim() || DEFAULT_CONVERSATION_TITLE },
select: { id: true, title: true, updated_at: true },
});
return { data: updated };
}
export async function deleteConversation(
userId: number,
convId: number,
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
if (!(await ownConversation(userId, convId)))
return { error: "Konverzace nenalezena", status: 404 };
await prisma.ai_conversations.delete({ where: { id: convId } }); // cascade
return { data: { ok: true } };
}
```
- [ ] **Step 2: Schemas** — in `src/schemas/ai.schema.ts`, keep `AiHistoryAppendSchema` (reused for append) and add:
```ts
export const AiCreateConversationSchema = z.object({
title: z.string().max(255).optional(),
});
export const AiRenameConversationSchema = z.object({
title: z.string().min(1, "Název je povinný").max(255),
});
```
- [ ] **Step 3: Routes** — in `src/routes/admin/ai.ts`: update imports (drop `getChatHistory/appendChatMessages/clearChatHistory`, add the new fns; add `AiCreateConversationSchema, AiRenameConversationSchema`; import `parseId` from `"../../schemas/common"`). Delete the three `/history` route blocks and append these (all `requirePermission("ai.use")`):
```ts
// GET /conversations — this user's conversations (stable order)
app.get(
"/conversations",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const conversations = await listConversations(request.authData!.userId);
return success(reply, { conversations });
},
);
// POST /conversations — create
app.post(
"/conversations",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const body = parseBody(AiCreateConversationSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const conversation = await createConversation(
request.authData!.userId,
body.data.title,
);
return success(reply, { conversation });
},
);
// GET /conversations/:id/messages
app.get(
"/conversations/:id/messages",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const result = await getConversationMessages(request.authData!.userId, id);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, { messages: result.data });
},
);
// POST /conversations/:id/messages — append turns
app.post(
"/conversations/:id/messages",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(AiHistoryAppendSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const result = await appendConversationMessages(
request.authData!.userId,
id,
body.data.messages,
);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, result.data);
},
);
// PATCH /conversations/:id — rename
app.patch(
"/conversations/:id",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const body = parseBody(AiRenameConversationSchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const result = await renameConversation(
request.authData!.userId,
id,
body.data.title,
);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, { conversation: result.data });
},
);
// DELETE /conversations/:id
app.delete(
"/conversations/:id",
{ preHandler: requirePermission("ai.use") },
async (request: FastifyRequest, reply: FastifyReply) => {
const id = parseId((request.params as { id: string }).id, reply);
if (id === null) return;
const result = await deleteConversation(request.authData!.userId, id);
if ("error" in result) return error(reply, result.error, result.status);
return success(reply, result.data);
},
);
```
- [ ] **Step 4: Gate**
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success.
- [ ] **Step 5: Commit**
```bash
git add src/services/ai.service.ts src/schemas/ai.schema.ts src/routes/admin/ai.ts
git commit -m "feat(odin): conversation-scoped service + routes (replace /history)"
```
---
## Task 3: Backend tests
**Files:**
- Modify: `src/__tests__/ai.test.ts`
- [ ] **Step 1: Replace the imports + cleanup markers.** Swap the history-fn imports for the conversation fns, and update the top-of-file constants/cleanup:
```ts
import {
computeCostUsd,
recordUsage,
getMonthSpendUsd,
getBudgetUsd,
assertBudgetAvailable,
isConfigured,
listConversations,
createConversation,
getConversationMessages,
appendConversationMessages,
renameConversation,
deleteConversation,
} from "../services/ai.service";
const KIND = "test_ai";
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
const CONV_UID_B = 999999992;
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
```
Update the top-level cleanup (`afterAll` and the conversation `beforeEach`) to delete by these. Deleting a conversation cascades its messages:
```ts
afterAll(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
});
await prisma.ai_conversations.deleteMany({
where: { title: { contains: HIST_MARKER } },
});
});
```
- [ ] **Step 2: Replace the `describe("ai.service chat history", …)` block** with conversation service tests:
```ts
describe("ai.service conversations", () => {
beforeEach(async () => {
await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
});
});
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
const conv = await createConversation(CONV_UID_A);
expect(conv.title).toBe("Nová konverzace");
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "Kolik je hodin v Praze?" },
{ role: "assistant", content: "Nevím, ale…" },
]);
const list = await listConversations(CONV_UID_A);
expect(list).toHaveLength(1);
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
expect("data" in msgs && msgs.data.map((m) => m.content)).toEqual([
"Kolik je hodin v Praze?",
"Nevím, ale…",
]);
});
it("does not overwrite a renamed title on later appends", async () => {
const conv = await createConversation(CONV_UID_A);
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "tohle by nemělo přepsat název" },
]);
const list = await listConversations(CONV_UID_A);
expect(list[0].title).toBe("Moje téma");
});
it("isolates conversations per user (404 across owners)", async () => {
const a = await createConversation(CONV_UID_A);
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
const read = await getConversationMessages(CONV_UID_B, a.id);
expect("error" in read && read.status).toBe(404);
const ren = await renameConversation(CONV_UID_B, a.id, "hack");
expect("error" in ren && ren.status).toBe(404);
const del = await deleteConversation(CONV_UID_B, a.id);
expect("error" in del && del.status).toBe(404);
});
it("cascade-deletes messages with the conversation", async () => {
const conv = await createConversation(CONV_UID_A);
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "x" },
]);
await deleteConversation(CONV_UID_A, conv.id);
const remaining = await prisma.ai_chat_messages.count({
where: { conversation_id: conv.id },
});
expect(remaining).toBe(0);
});
});
```
- [ ] **Step 3: Replace the HTTP history tests** (the `/history` `it(...)` blocks) with conversation HTTP tests inside the existing `describe("HTTP /api/admin/ai", …)`:
```ts
it("GET /conversations requires ai.use", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
});
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
const created = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} t` },
});
expect(created.statusCode).toBe(200);
const id = created.json().data.conversation.id as number;
const app2 = await app.inject({
method: "POST",
url: `/api/admin/ai/conversations/${id}/messages`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [{ role: "user", content: "ahoj" }] },
});
expect(app2.statusCode).toBe(200);
const msgs = await app.inject({
method: "GET",
url: `/api/admin/ai/conversations/${id}/messages`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(
msgs.json().data.messages.map((m: { content: string }) => m.content),
).toContain("ahoj");
const renamed = await app.inject({
method: "PATCH",
url: `/api/admin/ai/conversations/${id}`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} renamed` },
});
expect(renamed.json().data.conversation.title).toBe(`${HIST_MARKER} renamed`);
const del = await app.inject({
method: "DELETE",
url: `/api/admin/ai/conversations/${id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(del.statusCode).toBe(200);
});
it("GET messages of a non-existent conversation → 404", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations/999999000/messages",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
});
```
- [ ] **Step 4: Gate**
Run: `npx vitest run src/__tests__/ai.test.ts` — expect all passing. Then `npx vitest run` — expect the whole suite green.
- [ ] **Step 5: Commit**
```bash
git add src/__tests__/ai.test.ts
git commit -m "test(odin): conversation CRUD, isolation, auto-title, cascade"
```
---
## Task 4: Frontend queries + types
**Files:**
- Modify: `src/admin/lib/queries/ai.ts`
- [ ] **Step 1: Add conversation options.** Keep everything (incl. `aiHistoryOptions` for now — it's removed in Task 6 alongside its only consumer `DashAssistant.tsx`, so every task stays green). Add:
```ts
export interface Conversation {
id: number;
title: string;
updated_at: string;
}
export const aiConversationsOptions = () =>
queryOptions({
queryKey: ["ai", "conversations"],
queryFn: () =>
jsonQuery<{ conversations: Conversation[] }>(
"/api/admin/ai/conversations",
),
staleTime: 30_000,
});
export const aiConversationMessagesOptions = (id: number) =>
queryOptions({
queryKey: ["ai", "conversation", id, "messages"],
queryFn: () =>
jsonQuery<{ messages: StoredChatMessage[] }>(
`/api/admin/ai/conversations/${id}/messages`,
),
// Re-read the DB on each mount/selection (no stale-cache resurrection).
staleTime: Infinity,
gcTime: 0,
});
```
- [ ] **Step 2: Gate**
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (we kept `aiHistoryOptions`, so `DashAssistant.tsx` still compiles).
- [ ] **Step 3: Commit**
```bash
git add src/admin/lib/queries/ai.ts
git commit -m "feat(odin): conversation query options"
```
---
## Task 5: Frontend presentational components
**Files:**
- Create: `src/admin/components/odin/OdinTabs.tsx`
- Create: `src/admin/components/odin/OdinThread.tsx`
- Create: `src/admin/components/odin/InvoiceReviewCard.tsx`
- Create: `src/admin/components/odin/OdinComposer.tsx`
These are **props-driven** (no data fetching). Port presentational details (bubble styling, the `Typography`-color fix, review-card fields, staged chips) from the current `src/admin/components/dashboard/DashAssistant.tsx`, which the implementer should read first.
- [ ] **Step 1: Shared types** — define at the top of `OdinChat.tsx` (Task 6) and import where needed, OR duplicate the tiny `ChatTurn` shape. Use these exact shapes across files:
```ts
export interface ChatTurn {
role: "user" | "assistant";
content: string;
}
export interface ReviewInvoice {
uid: string;
supplier_name: string;
invoice_number: string;
amount: string;
currency: string;
vat_rate: string;
issue_date: string;
due_date: string;
description: string;
file_name: string;
file: File;
}
export type EditableField =
| "supplier_name"
| "invoice_number"
| "amount"
| "currency"
| "vat_rate"
| "issue_date"
| "due_date"
| "description";
export interface StagedFile {
id: string;
file: File;
}
```
Put these in a new `src/admin/components/odin/types.ts` and import from there in every odin file.
- [ ] **Step 2: `OdinTabs.tsx`** — props and behavior:
```tsx
interface OdinTabsProps {
conversations: { id: number; title: string }[];
activeId: number | null;
busy: boolean;
onSelect: (id: number) => void;
onNew: () => void;
onRename: (id: number, title: string) => void;
onDelete: (id: number) => void;
}
```
Render a horizontally-scrollable row (`Box` with `overflowX: "auto"`, `display: flex`, `gap`). Each conversation is a clickable pill (`Button` size small; active = `contained`, else `outlined color="inherit"`). After the pills, a `+` `Button` calls `onNew`. The **active** pill shows a small `⋮` `IconButton` opening an `@mui/material` `Menu` with "Přejmenovat" and "Smazat". "Přejmenovat" opens the kit `Modal` containing a `TextField` (prefilled with current title) → on submit calls `onRename`. "Smazat" opens the kit `ConfirmDialog` → on confirm calls `onDelete`. All actions disabled while `busy`.
- [ ] **Step 3: `OdinThread.tsx`** — props:
```tsx
interface OdinThreadProps {
turns: ChatTurn[];
busy: boolean;
loading: boolean;
threadRef: React.RefObject<HTMLDivElement>;
}
```
Render the scrolling message area (`flex: 1, minHeight: 0, overflowY: "auto"`). Assistant turns left with a small circular Odin avatar (`Box` 28px, `borderRadius: "50%"`, `bgcolor: "primary.main"`, white "O"); user turns right-aligned `primary.main` bubble. **Message colour MUST be on the `Typography`** (`color: user ? "common.white" : "text.primary"`) — GlobalStyles pins `p` to text.secondary. Empty state (no turns, not busy, not loading): centered Odin avatar + "Dobrý den, jsem Odin. Zeptejte se, nebo přiložte fakturu (PDF) k importu." When `loading`: "Načítám…". When `busy`: a `CircularProgress size={14}` + "Pracuji…" row.
- [ ] **Step 4: `InvoiceReviewCard.tsx`** — props:
```tsx
interface InvoiceReviewCardProps {
inv: ReviewInvoice;
busy: boolean;
onChange: (uid: string, field: EditableField, value: string) => void;
onSave: (inv: ReviewInvoice) => void;
onDiscard: (uid: string) => void;
}
```
Port the card body from `DashAssistant.tsx` verbatim (the `Chip "Faktura"` + filename header, the 2-col grid of `TextField`s including **"Částka s DPH"**, "Datum splatnosti", "Popis", and the Uložit / Zahodit buttons). Replace `patch(...)``onChange(...)`, `saveInvoice``onSave`, the discard filter→`onDiscard(inv.uid)`.
- [ ] **Step 5: `OdinComposer.tsx`** — props:
```tsx
interface OdinComposerProps {
input: string;
attachments: StagedFile[];
busy: boolean;
canSubmit: boolean;
fileRef: React.RefObject<HTMLInputElement>;
onInput: (v: string) => void;
onFiles: (files: FileList | null) => void;
onRemoveAttachment: (id: string) => void;
onSubmit: () => void;
}
```
Port the staged-attachment chips + the composer row (hidden file `input`, "Přiložit" button, `TextField` with Enter-to-submit, "Odeslat" button) from `DashAssistant.tsx`. Disable per `busy` / `!canSubmit`.
- [ ] **Step 6: Gate**
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (these compile independently of the orchestrator). If a component is unused at this point, that is fine — Task 6 wires them.
- [ ] **Step 7: Commit**
```bash
git add src/admin/components/odin
git commit -m "feat(odin): tabs, thread, review-card, composer components"
```
---
## Task 6: OdinChat orchestrator + page + delete DashAssistant
**Files:**
- Create: `src/admin/components/odin/OdinChat.tsx`
- Modify: `src/admin/pages/Odin.tsx`
- Modify: `src/admin/lib/queries/ai.ts` (remove `aiHistoryOptions`)
- Delete: `src/admin/components/dashboard/DashAssistant.tsx`
- [ ] **Step 1: Write `OdinChat.tsx`.** It owns all state + queries + the ported submit/extract/save logic, scoped to the active conversation. Port the helper fns (`summaryNote`, `nextUid`, `plural`, `MODEL_CONTEXT`, `MAX_STORE`) and the **submit / saveInvoice / onFiles / persist** logic from `DashAssistant.tsx` with these adaptations:
- **Queries:** `usage` (unchanged), `aiConversationsOptions()`, and `aiConversationMessagesOptions(activeId)` (enabled when `activeId != null`).
- **State:** `activeId: number | null`, plus `input`, `busy`, `turns`, `review`, `attachments`, `fileRef`, `threadRef`.
- **Ensure a conversation exists:** when the conversations query resolves: if empty, `POST /conversations` to create a default and `setActiveId(new.id)` + invalidate `["ai","conversations"]`; else if `activeId == null`, `setActiveId(list[0].id)`.
- **Seed per conversation:** a `seededId = useRef<number|null>(null)`. Effect keyed on `[activeId, messagesData]`: when `messagesData` for the active id arrives and `seededId.current !== activeId`, set `seededId.current = activeId`, `setTurns(messagesData.messages.map(m => ({role:m.role, content:m.content})))`, `setReview([])`.
- **Switch tab (`onSelect`):** `setActiveId(id); setTurns([]); setReview([]); seededId.current = null;` (force re-seed; the messages query for the new id loads and seeds).
- **`persist(msgs)`** posts to `/api/admin/ai/conversations/${activeId}/messages` (was `/history`); best-effort + `console.error` on failure.
- **`submit`:** identical to `DashAssistant.submit` EXCEPT it first ensures `activeId` (if null, await-create one and use it); marks `seededId.current = activeId`; on success persists via the conversation endpoint; on error rolls back + keeps input/attachments. Gate `canSubmit` on `!conversationsPending && activeId != null && !busy && (input.trim() || attachments.length)`.
- **`onNew`:** `POST /conversations` → select it (`setActiveId`, clear turns/review, `seededId.current=null`) + invalidate `["ai","conversations"]`.
- **`onRename(id, title)`:** `PATCH /conversations/:id` → invalidate `["ai","conversations"]`.
- **`onDelete(id)`:** `DELETE /conversations/:id` → invalidate `["ai","conversations"]`; if `id === activeId`, pick another conversation from the (refetched) list or null.
- **Layout:** a full-height shell — `Box sx={{ height: "calc(100dvh - 140px)", display: "flex", flexDirection: "column" }}` containing: header (`Odin` + budget `Utraceno: $x / $y`), `<OdinTabs … />`, `<OdinThread … />` (flex 1), the review list (`review.map(inv => <InvoiceReviewCard … />)` in a bounded `maxHeight: "35vh", overflowY: "auto"` box), and `<OdinComposer … />`.
- **Self-hide:** keep `if (usage && usage.configured === false) return null;`.
Reference the exact bodies of `submit`, `saveInvoice`, `onFiles`, `summaryNote`, `nextUid` in the current `DashAssistant.tsx` (lines ~52333) — copy them, applying only the adaptations above.
- [ ] **Step 2: Update `src/admin/pages/Odin.tsx`** — swap the import and render:
```tsx
import OdinChat from "../components/odin/OdinChat";
// …
<Box sx={{ maxWidth: 1100, mx: "auto" }}>
<OdinChat />
</Box>;
```
- [ ] **Step 3: Delete `src/admin/components/dashboard/DashAssistant.tsx`** and remove the now-orphaned `aiHistoryOptions` from `src/admin/lib/queries/ai.ts` (DashAssistant was its only consumer).
```bash
git rm src/admin/components/dashboard/DashAssistant.tsx
```
- [ ] **Step 4: Gate**
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success. Run: `npx vitest run` — expect the full suite green.
- [ ] **Step 5: Commit**
```bash
git add src/admin/components/odin/OdinChat.tsx src/admin/pages/Odin.tsx src/admin/lib/queries/ai.ts
git commit -m "feat(odin): OdinChat orchestrator; render on /odin; remove DashAssistant"
```
---
## Final verification (controller)
- `npx tsc -b --noEmit` = 0, `npm run build` = success, `npx vitest run` = all green.
- Manual (user/controller): create a conversation, send a chat, attach a PDF → review → save, rename a tab, delete a tab, reload (history persists per conversation), switch tabs (each keeps its own thread).
- Adversarial review pass (workflow) before merge/release.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,188 @@
# 2026-06-10 — Audit fix pass #1: all verified CRITICAL/HIGH findings
Source: `REVIEW_FINDINGS.md` (2026-06-09 full audit, 304 files). Scope of this pass:
the 19 verified per-file **HIGH** findings + the project-level **CRITICAL** dependency
finding. The two project-level HIGH _structural refactors_ (service-layer extraction for
6 route files; PDF template dedup) are explicitly **out of scope** — they are
multi-thousand-line refactors that deserve their own pass.
Execution: subagent-driven development — one implementer per task (sequential, shared
test DB forbids parallel test runs), spec-compliance review then code-quality review
after each. No commits until the user asks. Final full gates:
`npx tsc -b --noEmit` · `npx vitest run` · `npm run lint` · `npm run build`.
---
## Task H — Dependencies (project-level CRITICAL) — done inline by controller
- Remove `concurrently` from devDependencies (unused; carries both critical advisories
GHSA-w7jw-789q-3m8p / shell-quote CVSS 8.1).
- Add `"overrides": { "@hono/node-server": "^1.19.13" }` (clears the 3 moderate
advisories from the prisma → @prisma/dev chain; do NOT take npm's Prisma 6 downgrade).
- Remove deprecated stub types `@types/dompurify`, `@types/bcryptjs`; move `@types/jsdom`
to devDependencies.
- Add `qrcode` + `@types/qrcode` (needed by Task B to replace the CSP-blocked external
QR service).
- `npm install`, verify `npm audit` (criticals+moderates gone), typecheck.
## Task A — Attendance create/edit broken since 519edce (top-5 #1)
Findings (both verified HIGH):
- `src/admin/pages/AttendanceCreate.tsx: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" would reach
createAttendance which does `new Date('08:00')` → Invalid Date → 500; 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.
- `src/admin/pages/AttendanceAdmin.tsx:407-442` (logic in `useAttendanceAdmin.ts`) —
create/edit modal submits send combined `YYYY-MM-DDTHH:MM:00` datetimes for
arrival/departure/break, but since 519edce 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(...)`); additionally `break_start`/`break_end` are absent from
CreateAttendanceSchema, so breaks are silently stripped on create.
Direction: first `git show 519edce` to understand the intent; the schema contradicts
both client and service, so the fix is to make the schemas validate the combined
local-datetime wire format the client sends and the service consumes (lenient helper in
`src/schemas/common.ts` per repo convention), add `break_start`/`break_end` to the create
schema, and rewrite AttendanceCreate.tsx's submit to combine date+time and omit empty
optionals. TDD: route-level regression tests (work record with times+breaks, overnight,
leave record without times, update) in `src/__tests__/`.
## Task B — 2FA: backup-code login, enrollment QR, dashboard status (top-5 #2)
Findings (all verified HIGH):
- `src/admin/context/AuthContext.tsx:263-272` — backup-code login broken: verify2FA
always POSTs to /login/totp with the code as totp_code plus an isBackup flag the server
ignores; TotpVerifySchema requires exactly 6 digits so 8-char backup codes always 400.
The real endpoint POST /api/admin/totp/backup-verify (expects backup_code) is never
called anywhere in the frontend — lockout for any user who lost their authenticator.
- `src/admin/components/dashboard/DashProfile.tsx:556` — QR `<img>` from api.qrserver.com
is blocked by the production CSP (img-src 'self' data: blob: + osm tiles), so prod 2FA
enrollment shows a broken image. (Also a privacy bug: the TOTP secret is sent to a
third-party URL.) Fix: generate the QR locally with the `qrcode` package
(`QRCode.toDataURL(otpauthUrl)`) — data: is already CSP-allowed.
- `src/admin/pages/Dashboard.tsx:89-91` — totpEnabled derived from require2FAOptions()
which returns the COMPANY-WIDE require_2fa policy flag, not the user's enrollment; the
per-user endpoint (totp.ts:190-197) is never queried. Wrong "2FA Aktivní/Neaktivní" and
wrong button shown.
Direction: read `src/routes/admin/totp.ts` first; wire Login/AuthContext so a backup code
goes to backup-verify and completes the login-token flow. If the server endpoint doesn't
fit the loginToken flow, extend it minimally (single-use code consumption + logAudit
preserved). Server test for backup-verify login path if server is touched.
## Task C — Invoice/issued-order edit integrity (top-5 #3)
Findings (all verified HIGH):
- `src/admin/pages/InvoiceDetail.tsx:728` — edit hydration overwrites each item's
persisted per-line VAT with the invoice-level rate (`vat_rate: Number(inv.vat_rate) || 21`)
though invoice_items.vat_rate is a real per-item column — re-saving a mixed-rate invoice
silently corrupts per-line VAT in the DB; `|| 21` also turns legitimate 0% into 21%
(same falsy-zero at line 689).
- `src/admin/pages/InvoiceDetail.tsx:1891-1902` — editing "Text fakturace (na PDF)" is
silently lost: payload includes billing_text but UpdateInvoiceSchema
(src/schemas/invoices.schema.ts:46-67) has no billing_text field, so Zod strips it
(updateInvoice supports it via strFields). Add the field to the schema + server test.
- `src/admin/pages/IssuedOrderDetail.tsx:606-611` — hydration falsy-fallbacks:
`quantity: Number(it.quantity) || 1` turns saved 0 into 1; `vat_rate: Number(it.vat_rate)
|| Number(d.vat_rate) || 21` turns a saved 0% line into 21% — displayed wrong and
silently persisted on next save.
- `src/admin/pages/IssuedOrderDetail.tsx:826-839` — handleStatusChange never mirrors the
new status into form.status, so StatusChip, the `editable` flag and the delete-button
gate use the stale status after every transition.
Use Number.isFinite-style coercion that respects 0 (e.g. `const n = Number(x);
Number.isFinite(n) ? n : fallback`).
## Task D — Trips: truncated lists/totals + dropped vehicle edit (top-5 #4a)
Findings (all verified HIGH):
- `src/admin/lib/queries/trips.ts:52-63` — tripListOptions consumes the paginated
endpoint via plain jsonQuery, discarding pagination meta; Trips.tsx uses default limit
25 and TripsAdmin perPage:100 (server hard cap) — both silently truncate with no
pagination controls.
- `src/admin/lib/queries/trips.ts:95-103` — tripHistoryOptions sends no page/per_page →
25 rows; TripsHistory.tsx computes monthly km/business totals from the truncated rows.
- `src/admin/pages/Trips.tsx:324-336` — stat cards computed from the 25-row page;
header labels stats "current month" though the query has no month filter.
- `src/admin/pages/TripsAdmin.tsx:295,665-676` — edit modal sends vehicle_id but
UpdateTripSchema (src/schemas/trips.schema.ts:23-31) has no vehicle_id and the PUT
handler never applies it — vehicle change silently dropped. Add to schema
(nullableIntIdFromForm) + apply in route + audit + server test.
- `src/admin/pages/TripsHistory.tsx:98-104,121-128` — same 25-row truncation for the
month view and its stat cards.
Direction for totals: prefer a server-side aggregate (the repo already has per-currency
totals endpoints for invoices/orders as the pattern) over fetching all pages client-side;
lists get real pagination using the existing `usePaginatedQuery`/`Pagination` kit pieces
used elsewhere. Keep the month-filter semantics of TripsHistory correct (totals must
cover the WHOLE month, not one page).
## Task E — orders.service.ts: PDF blob in every response (top-5 #4b)
Finding (verified HIGH): `src/services/orders.service.ts:154-176,196-216,228-263,570,683`
— attachment_data (full PDF blob, Bytes) is fetched on every query with no select/omit
and spread into API responses by enrichOrder/getOrder; getOrderTotals loads every blob
for the whole filtered set just to sum totals; updateOrder/deleteOrder pre-reads pull the
blob to check status/number. A dedicated /:id/attachment endpoint already exists.
Exclude the blob from list/detail/totals/pre-reads (keep has_attachment-style boolean if
the FE needs it — check FE usage), keep the attachment endpoint working, extend
`src/__tests__/orders-list.test.ts` to assert attachment_data is absent.
## Task F — Warehouse: unit enum 400s + dead attachment download
Findings (both verified HIGH):
- `src/admin/pages/WarehouseItemDetail.tsx:451-462` — "Jednotka" is free-text but the
server requires UnitEnum ("ks","m","kg","bal","sada","m2","m3","l") — any other value
400s create/update. Make it a Select over the enum values (single source: mirror the
server enum list).
- `src/admin/pages/WarehouseReceiptDetail.tsx:251-259` — attachment link targets
GET /warehouse/receipts/:id/attachments/:attachmentId which does not exist (only POST
upload + DELETE are registered in warehouse.ts), and a plain <a href> sends no
Authorization header anyway. Add the GET download route (requirePermission, correct
content-type/disposition) + authenticated blob download in the UI (fetch via apiFetch →
object URL, like other binary downloads in the repo). Server test for the new route.
## Task G — Small fixes
- `.claude/hooks/block-env.js:10-11` (verified HIGH) — block decision never takes
effect: 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 on stderr). Fix: exit 2 with the
reason on stderr.
- `src/admin/pages/Projects.tsx:153-154` (verified HIGH) — create modal submits
start_date/end_date initialized to "" which isoDateString.nullish() rejects → creating
a project without filling BOTH dates always 400s. Send null/omit when empty.
---
## Status
- [x] H — dependencies (controller, inline) — criticals+moderates cleared; only the
2 known-mitigated quill lows remain
- [x] A — attendance create/edit — dateTimeString helpers, break fields, rewritten
AttendanceCreate payload; 6 regression tests
- [x] B — 2FA — backup-verify wired (+ remember-me parity server-side), local QR via
qrcode lib, per-user totp status; 6 tests
- [x] C — invoice/issued-order edit integrity — numberOr (shared in formatters.ts),
billing_text in UpdateInvoiceSchema, status mirror; 2 tests
- [x] D — trips — paginated queries + Pagination UI on 3 pages, GET /trips/stats
(buildTripsWhere shared), vehicle_id on PUT + both-vehicle odometer recompute,
print rebuilt (sync window.open, escaped string template); 7 tests
- [x] E — orders attachment_data excluded from all non-binary reads (incl.
numbering/invoices/orders-pdf callers); 4 tests
- [x] F — warehouse unit Select + GET receipt attachment route + authenticated blob
download; RFC 5987 content-disposition helper (also fixes received-invoices +
orders Czech-filename 500); 6 tests
- [x] G — block-env hook exit 2/stderr (verified empirically), Projects empty dates
→ null
- [x] Final gates: tsc -b clean · vitest 30 files / 342 tests green · lint 0 errors ·
build OK. Whole-diff 3-lens review: see final report.

View File

@@ -0,0 +1,450 @@
# Plán prací (Work Schedule) Module Design
**Date:** 2026-06-05
**Status:** Approved
**Module:** Work schedule / Rozpis prací for boha-app-ts
---
## Overview
A new module under the "Docházka" sidebar section that lets a foreman plan
each employee's work on a per-day basis, weeks or months in advance, and lets
every employee see what they (and the team) are supposed to be doing on any
given day.
The reference for the UX is `simon/plan_boha.pdf` — a hand-rolled Excel grid
with employees as columns and dates as rows, each cell holding a free-text
task description. The module replaces that spreadsheet with a live, audited,
permission-aware grid in the existing admin app.
A plan entry is **what should happen**. It is intentionally separate from
attendance (which records **what actually happened**). The two are not
auto-linked.
---
## Requirements
### Core
- Plan entries are stored as **date ranges** (e.g. "PLC upgrade VW USA"
from 8.6. to 26.6.), with an optional **per-day override** for the rare
case where one day in a range is different (e.g. "Volno po noční").
- Each entry has:
- `user_id` — the employee this plan is for.
- `date_from` / `date_to` — inclusive range.
- `project_id`**optional** FK to the existing `projects` table.
- `category` — enum (see below), drives color-coding and reports.
- `note` — required free-text task description (max 500 chars).
- The project dropdown is populated from the existing `projects` table.
No new "task" or "sub-project" entity — the note is the human-readable
task description.
- A cell can be empty (unassigned / not planned). An empty cell is also
clickable for admins — opens the create-entry modal.
- A `work_plan_overrides` row covers a single `(user, date)` and takes
precedence over the range that would otherwise cover that day.
### Effective-cell resolution (runtime)
To compute the cell for user `U` on date `D`:
1. Look up `work_plan_overrides` for `(U, D)`. If present, use it.
2. Otherwise, look up `work_plan_entries` where `user_id = U` and
`date_from <= D <= date_to`. If multiple match, return the
**last-created** one and write a warning to the audit log.
3. Otherwise, the cell is empty.
### Editing rules
- **Lock past, free future.** Cells with `shift_date < today` are
read-only. Admins can override with `?force=1` on the API; the
override is recorded in the audit log.
- Today and future are always editable for users with `attendance.manage`.
- The modal offers three actions when editing a cell inside an existing
range:
- "Upravit celý rozsah" — edit the range.
- "Upravit pouze tento den" — creates an override.
- "Zrušit přiřazení tohoto dne" — creates an override with
`category = 'other'` and empty note, leaving the cell empty for
that day only.
### Categories
A new enum `plan_category` for color-coding and reports:
| Value | Czech label | Cell color hint |
| ------------- | -------------- | --------------- |
| `work` | Práce | blue |
| `preparation` | Příprava | teal |
| `travel` | Cesta / Montáž | amber |
| `leave` | Dovolená | green |
| `sick` | Nemoc | red |
| `training` | Školení | purple |
| `other` | Jiné | gray |
Plan overrides for leave/sick are **informational only**. They do not
create or modify `leave_requests` or `attendance` rows. Employees still
file leave through the existing `Žádosti` flow.
### Visibility & permissions
- **Single sidebar entry** under Docházka: `Plán prací``/attendance/plan`.
- The sidebar entry is gated by `attendance.manage || attendance.record`
(mirrors the existing `Žádosti` entry).
- The page is **one** (`PlanWork.tsx`):
- If `useAuth().hasPermission('attendance.manage')` → editor mode
(grid + edit modal + create/edit/delete affordances).
- Otherwise → view-only mode (same grid, read-only detail modal).
- All endpoints require `attendance.manage` for writes, and either
`attendance.manage` or `attendance.record` for reads. Employees
with `attendance.record` are scoped server-side to their own
`user_id` for any direct row read; the grid endpoint handles the
scoping transparently.
- Admin role bypasses all permission checks (existing behavior).
### Grid
- Default view is **week** (T23, T24, T25 … matches the PDF's week
numbering). A toggle at the top switches to **month** view.
- The grid is `employees × dates`. Columns are ordered by
role/team first, then alphabetically by name.
- Columns are users with the `attendance.record` permission (which
includes admin). A "Aktivní" toggle in the toolbar lets the planner
hide inactive users.
- Each cell shows: a colored chip for the category, the project name
(if any) in bold, and the note underneath. Empty cells are blank.
- Weekend cells have a subtle yellow background tint to match the
PDF's "So 6.6. ★" rows.
- Read-only employees see the same grid, but cells are non-clickable
for editing. A click opens a read-only detail modal showing project,
category, note, "Vytvořil: <user> · <date>" (the entry's
`created_by`), and (if the cell is part of a range) "Patří do
rozsahu: <date_from> <date_to>".
### Audit
Every create/update/delete of `work_plan_entries` and
`work_plan_overrides` writes a row to `audit_logs` with
`entity_type = 'work_plan_entry' | 'work_plan_override'` and full
`old_values` / `new_values` JSON, following the same pattern as
`attendance` and `projects`. The `?force=1` past-edit override is
recorded in the description field.
### Non-goals (out of scope for v1)
- No automatic link to attendance (plan stays independent).
- No time-slot granularity (full days only).
- No shift / payroll calculation.
- No drag-fill on the grid.
- No "task list" entity — projects and free text only.
- No notification/email when a plan is changed.
- No browser tests (the codebase has none; manual verification in dev).
---
## Architecture
### Module placement
- New module under the **Docházka** sidebar section.
- Reuses the existing `attendance.shift_date` concept (date column on
the `attendance` table) — the planner lives next to the calendar it
informs.
- No "administrativa" module exists; the sidebar "Administrativa"
section is the commercial back-office (offers, orders, invoices,
projects, customers) and is not the right home for HR-style planning.
### File layout
```
src/
├── routes/admin/plan.ts # Fastify routes
├── services/plan.service.ts # Business logic
├── schemas/plan.schema.ts # Zod validation
└── __tests__/plan.test.ts # Vitest + Supertest tests
src/admin/
├── pages/PlanWork.tsx # The single page (editor or view-only)
├── hooks/usePlanWork.ts # State, modal, mutations
├── lib/queries/plan.ts # React Query queryOptions
└── components/
├── PlanGrid.tsx # Week/month grid (shared by both modes)
├── PlanCellModal.tsx # Edit modal (create / edit-range / override)
├── PlanCellDetailModal.tsx # Read-only detail modal
├── PlanRangeChips.tsx # Cell content chip with category color
└── plan.css # Module-specific styles only
```
The new CSS file holds only category color helpers and grid-specific
overrides. All form/modal/table styling reuses existing classes
(`.admin-table`, `.admin-table-sticky`, `FormField`, `FormModal`,
`ConfirmModal`, `AdminDatePicker`, etc.) — no new design tokens.
### React Query keys
Per the project's "invalidate the full domain" convention:
- `['plan', 'grid', { dateFrom, dateTo, view }]`
- `['plan', 'entries', { userId, dateFrom, dateTo }]`
- `['plan', 'overrides', { userId, dateFrom, dateTo }]`
- `['plan', 'users']`
A mutation on any plan row invalidates `['plan']` (covers all four
sub-queries by prefix match).
---
## Database Schema
### New enum
```prisma
enum plan_category {
work
preparation
travel
leave
sick
training
other
}
```
### New table: `work_plan_entries`
| Column | Type | Notes |
| ------------ | ------------------------ | ------------------------------------------------------- |
| `id` | int PK autoincrement | |
| `user_id` | int FK → `users(id)` | NOT NULL, indexed |
| `date_from` | Date | NOT NULL — first day of the range |
| `date_to` | Date | NOT NULL — last day of the range, must be ≥ `date_from` |
| `project_id` | int? FK → `projects(id)` | nullable — unlinked text entries are allowed |
| `category` | enum `plan_category` | NOT NULL, default `'work'` |
| `note` | varchar(500) | NOT NULL — free-text task description |
| `created_by` | int FK → `users(id)` | who entered it |
| `created_at` | DateTime | Prisma default `now()` |
| `updated_at` | DateTime | Prisma default `now()` |
| `is_deleted` | bool? default false | soft-delete |
Indexes:
- `(user_id, date_from)`
- `(user_id, date_to)`
- `(project_id)`
- `(date_from, date_to)` — for cross-user queries like
"what's planned for project X this month?"
### New table: `work_plan_overrides`
| Column | Type | Notes |
| ------------ | ------------------------ | ------------------------------------------------- |
| `id` | int PK autoincrement | |
| `user_id` | int FK → `users(id)` | NOT NULL, indexed |
| `shift_date` | Date | NOT NULL — the one day being overridden |
| `project_id` | int? FK → `projects(id)` | nullable |
| `category` | enum `plan_category` | NOT NULL — usually `'leave'`, `'sick'`, `'other'` |
| `note` | varchar(500) | NOT NULL |
| `created_by` | int FK → `users(id)` | |
| `created_at` | DateTime | |
| `updated_at` | DateTime | |
| `is_deleted` | bool? default false | soft-delete |
Indexes:
- **Unique** `(user_id, shift_date)` — exactly one non-deleted
override per user-day. MySQL unique indexes don't ignore
soft-deleted rows, so the service layer enforces "at most one
active override per `(user_id, shift_date)`" in application code
(the database unique constraint exists as a final safety net and
may be temporarily violated by soft-deleted rows, which is
acceptable). When restoring a soft-deleted override, the service
re-validates that no active override exists for the same day.
- `(shift_date)` — for cross-user "what's overridden today" queries.
### Read semantics (recap)
For user `U` and date `D`, the "effective" cell is:
1. Override `(U, D)` if it exists and is not soft-deleted.
2. Otherwise the latest `work_plan_entry` whose range covers `D`
for user `U` and is not soft-deleted. If multiple match, pick
the latest by `created_at` and write a warning to `audit_logs`.
3. Otherwise, empty.
---
## API Surface
REST endpoints under `/api/admin/plan`. All routes use the existing
Zod-validated, `requirePermission`, `success`/`error` helpers, and
`logAudit` pattern. Czech error messages, following project convention.
| Method | Path | Permission | Body / Query | Returns |
| ------ | --------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- |
| GET | `/plan/grid` | `attendance.manage` OR `attendance.record` | `?date_from=&date_to=&view=week\|month` (range ≤ 92 days) | `{ days, users, cells }` — effective cells ready to render |
| GET | `/plan/entries` | `attendance.manage` OR `attendance.record` (employee scoped to self) | `?user_id=&date_from=&date_to=` | raw `work_plan_entries` rows in range |
| GET | `/plan/overrides` | `attendance.manage` OR `attendance.record` (employee scoped to self) | `?user_id=&date_from=&date_to=` | raw `work_plan_overrides` rows in range |
| GET | `/plan/users` | `attendance.manage` OR `attendance.record` | — | users with `attendance.record`, ordered by role/team then name |
| POST | `/plan/entries` | `attendance.manage` | `{ user_id, date_from, date_to, project_id?, category, note }` | created entry |
| PATCH | `/plan/entries/:id` | `attendance.manage` | partial of create body | updated entry |
| DELETE | `/plan/entries/:id` | `attendance.manage` | `?force=1` to bypass past-date lock | `{ ok: true }` (soft-delete) |
| POST | `/plan/overrides` | `attendance.manage` | `{ user_id, shift_date, project_id?, category, note }` | created override |
| PATCH | `/plan/overrides/:id` | `attendance.manage` | partial of create body | updated override |
| DELETE | `/plan/overrides/:id` | `attendance.manage` | `?force=1` | `{ ok: true }` (soft-delete) |
| GET | `/plan/audit` | `settings.audit` | `?entity_type=&entity_id=` | audit log entries for a row |
### Past-date lock
Server-enforced. POST/PATCH/DELETE with any `shift_date` / `date_from`
in the past returns 403 with a Czech error message:
```
Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.
```
With `?force=1` and `attendance.manage`, the operation succeeds and
the audit log records `description = 'force-edit past date'`.
### Employee scoping
`GET /plan/entries` and `GET /plan/overrides` accept `?user_id=`. For
a user with only `attendance.record`, the service silently overrides
the query to scope to `user_id = auth.user.id`. The grid endpoint
returns the same data either way (employees see the full grid in
view-only mode).
---
## Frontend Detail
### `PlanWork.tsx`
- Mounts and calls `useAuth()`. If `hasPermission('attendance.manage')`
→ editor state. Otherwise → view-only state.
- Renders the toolbar (view toggle, date navigator, active-toggle,
print) and `PlanGrid`.
- Owns the modal state machine for `PlanCellModal` (editor) or
`PlanCellDetailModal` (view).
### `PlanGrid.tsx`
- Pure presentational. Takes `users`, `days`, `cells` as props.
- Renders a `<table>` with sticky first column (date) and sticky
header (employee names).
- Each cell is a `<button>` (admin) or `<div>` (view-only) with the
colored chip + note.
- Calls `onCellClick(userId, date)` for the parent to open the modal.
- Reuses `.admin-table` and `.admin-table-sticky` from existing
stylesheets.
### `PlanCellModal.tsx` (editor)
Three modes, decided by what the user clicked:
- **Create** — empty cell. Fields: `user_id` (locked to clicked user,
can be switched), `date_from` (default = clicked date), `date_to`
(default = clicked date; "Jeden den" / "Rozsah dnů" toggle),
`project_id` (dropdown, populated from `projects`, nullable),
`category` (select), `note` (textarea, required).
- **Edit range** — cell is part of an existing range. Loads the
full range into the form. "Upravit celý rozsah" button is the
primary action.
- **Edit day in range** — cell is part of a range. Three buttons:
"Upravit celý rozsah" / "Upravit pouze tento den" /
"Zrušit přiřazení tohoto dne".
- **Edit single-day override** — cell is itself a `work_plan_overrides`
row. Standard edit form.
- Bottom: delete button (with `ConfirmModal`).
### `PlanCellDetailModal.tsx` (view-only)
Read-only. Shows project, category, note, "Vytvořil: <full name of
`created_by`> · <`created_at` in local Czech time>", and (if the
cell is part of a range) "Patří do rozsahu: <date_from> <date_to>".
For override cells, the "Vytvořil" line refers to the override's own
`created_by` (not the parent range's). No edit, no delete.
### `usePlanWork.ts`
Owns:
- `view: 'week' | 'month'`, `anchorDate`, `filterActive`
- `effectiveCells` from `['plan', 'grid']` query
- `entries` and `overrides` for the cell being edited
- `modalState: { mode, userId, date, entryId?, overrideId? }`
- `createEntry`, `updateEntry`, `deleteEntry`,
`createOverride`, `updateOverride`, `deleteOverride`
React Query mutations that invalidate `['plan']`.
---
## Migration
Single new migration:
`prisma/migrations/<timestamp>_add_work_plan/`
- `migration.sql`:
1. `CREATE TABLE work_plan_entries (...)` with columns and indexes.
2. `CREATE TABLE work_plan_overrides (...)` with columns and indexes.
3. `CREATE TYPE plan_category` (or in-table enum, per Prisma's
MySQL output).
No data migration. No `seed.ts` change. No new permission keys.
---
## Testing
`src/__tests__/plan.test.ts` — Vitest + Supertest against the real
test database (`.env.test`):
1. **Effective-cell resolution** — range + override returns the
override for that day, the range for the other days, `null`
for uncovered days.
2. **Past-date lock** — POST with `date_from < today` returns 403
for users without `attendance.manage`; allowed with `?force=1`
for users with `attendance.manage`; audit log records the force.
3. **Employee self-view scoping** — employee with `attendance.record`
calling `GET /plan/entries?user_id=<other>` is scoped to self.
4. **Soft-delete + is_deleted** — deleted rows are excluded from
`/plan/grid` but kept in `/plan/audit`.
5. **Project FK nullability** — entries with `project_id = null`
and `category = 'leave'` are valid and render correctly.
6. **Range overlap** — two ranges for the same `(user, day)` does
not error; the latest one wins; a warning is logged.
7. **Audit log on every write** — POST/PATCH/DELETE each insert
exactly one `audit_logs` row with `entity_type`,
`old_values`, `new_values`.
---
## Release Plan
1. Bump `package.json` version (minor, since new module).
2. `npm run build` — confirm clean compile.
3. Commit + push to Gitea.
4. Tarball + deploy to production via the existing release script
(per `CLAUDE.md` "Release Process" section).
5. `npx prisma migrate deploy` on prod.
6. `pm2 restart app-ts --update-env`.
7. Smoke test: log in, open Docházka → Plán prací, add a one-day
entry for yourself, reload, confirm it shows.
The deployment follows the same pattern as the warehouse module
release (see commit `5233db2` and the `2026-05-29-warehouse-module`
spec for reference).
---
## Open items / future work
- Month view polish if the toggle proves popular.
- Drag-fill on the grid (out of scope for v1).
- Optional tie-in to attendance: pre-fill `notes` on
`AttendanceCreate` from today's plan (read-only hint). Not
pursued in v1.
- "Patří do rozsahu" link in the detail modal: clickable
jump to the range's full detail.
- Per-team filtering (if a "team" concept is added to `users` later).

View File

@@ -0,0 +1,249 @@
# CSS → MUI Migration — Design Spec
**Date:** 2026-06-06
**Status:** Approved design, pending implementation plan
**Topic:** Replace hand-written CSS with MUI (Material UI), themed to the existing brand, with a modern "Soft-SaaS + dense tables" look, rolled out incrementally.
---
## 1. Goal
Migrate the admin frontend off its ~7,100 lines of hand-written CSS onto **MUI (Material UI)** as the styling system, while simultaneously delivering a **modern visual refresh**. Dark/light theming and the existing design tokens are preserved (they already exist and work). The migration is **incremental and always shippable** — never a frozen big-bang.
This is explicitly a **styling + component-layer migration**. Application behavior, data flow, API contracts, and business logic remain identical (one deliberate exception: modal accessibility — see §5 / §11). Backend code is not touched.
---
## 2. Current state (audit findings, 2026-06-06)
The premise "we want dark/light mode" is largely **already met**. The audit found:
- **19 CSS files, ~7,101 lines** under `src/admin/`. Largest: `components.css` (1,224), `plan.css` (1,152), `offers.css` (641), `layout.css` (582), `dashboard.css` (525), `forms.css` (510), `attendance.css` (455), `base.css` (411). Smallest: `responsive.css` (6 lines, effectively an empty placeholder). The token file `variables.css` (164) and `plan.css` are special-cased in §6.
- **A complete CSS-variable token system** in `variables.css`: spacing scale, colors, radii, motion durations, fonts, safe-area insets.
- **A full dark + light theme** via `[data-theme="dark"]` / `[data-theme="light"]`. The attribute is owned and written by **`src/context/ThemeContext.tsx`** (`useTheme()` / `toggleTheme()`, which calls `document.documentElement.setAttribute("data-theme", …)`). `Sidebar.tsx`, `AdminLayout.tsx`, and `Login.tsx` only **consume** it via `useTheme()`.
- **Distinctive fonts already chosen:** Urbanist (headings), Plus Jakarta Sans (body), DM Mono (mono).
- **Stack:** React 18.3, Vite 8, TypeScript (strict), TanStack Query v5, **framer-motion** (used broadly — see §9: modals, toasts, the AdminLayout logout transition, dashboard cards, Login), react-datepicker, react-quill-new, Leaflet, @dnd-kit. No Tailwind / MUI / Emotion direct deps; no PostCSS config (PostCSS ships only transitively under Vite).
- **Type skew (pre-existing):** `react`/`react-dom` are `^18.3.1` but `@types/react`/`@types/react-dom` are `^19.x`. MUI's component types are sensitive to the `@types/react` major version — see §11 / §14.
**Implication:** the migration is not about adding theming; it's about replacing the CSS plumbing and modernizing the look while carrying the existing tokens forward.
---
## 3. Locked decisions
| Decision | Choice |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| Goal | Both — re-plumb CSS **and** modern visual refresh |
| Approach | Component library (not a utility framework) |
| Library | **MUI (Material UI)** — target **v7** + **MUI X v8** (pickers), themed to the brand |
| Look | **"Soft-SaaS shell (B) + dense data tables (D)"** — airy modern chrome, dense efficient tables |
| Theming | Existing CSS-var tokens become the source; one `data-theme` attribute drives both MUI and legacy CSS (mechanism verified against MUI v7 docs — §4.1) |
| Roll-out | Incremental, foundation-first; each phase is one or more normal releases |
| Pilot page | **Vozidla** (vehicles) — focused CRUD pilot |
| Heavy tables | MUI `Table` styled dense, reusing existing `useTableSort` + `usePaginatedQuery` hooks (no DataGrid, no paid license) |
---
## 4. Architecture
The tokens become the **single shared palette** that feeds both worlds during the migration, so migrated and unmigrated pages stay on the same colors/typography. Structure modernizes per page (see §7 for the token-level vs component-level split).
```
Tokens (colors, fonts, radii, motion, dark/light) ← refreshed once, early
│ (identical palette feeds both sides)
├───────────────► legacy CSS ── unmigrated pages ┐
│ ├─ coexist, same palette
└──► MUI theme (createTheme + colorSchemes) ──────────────┘
Foundation UI kit (src/admin/ui/*) ──► migrated pages
```
### 4.1 Theming bridge (`src/admin/theme.ts`)
- One `theme.ts` builds the MUI theme from the tokens:
- **Palette:** `primary` = brand red (`#d63031` dark / `#c73030` light), plus `success`/`warning`/`error`/`info` mapped to existing semantic tokens.
- **Typography:** Urbanist for `h1h6`, Plus Jakarta Sans for body, DM Mono for numeric/monospace contexts.
- **Shape/spacing:** `borderRadius` and spacing from the token scale.
- **`components` overrides:** the Soft-SaaS defaults (rounded cards, pill buttons, soft shadows, tinted chips) baked in so every MUI component is on-brand by default.
- **Dark/light keeps the existing toggle (verified against MUI v7 docs).** MUI's CSS theme variables support a custom color-scheme selector that targets the existing attribute exactly:
```ts
createTheme({
cssVariables: { colorSchemeSelector: "[data-theme='%s']" },
colorSchemes: { light: true, dark: true },
// …palette/typography/components
});
```
This emits `[data-theme="light"]{…}` and `[data-theme="dark"]{…}`, matching the attribute `ThemeContext` already writes. One attribute drives MUI _and_ legacy CSS → no second toggle, 1:1 dark parity.
- **One attribute owner.** `ThemeContext` remains the sole writer of `data-theme` on `<html>`. Its `toggleTheme()` must **also call MUI's `setMode()`** so MUI's internal color-scheme state stays in sync with the attribute. Do **not** also enable MUI's `InitColorSchemeScript` attribute-writing (avoid two writers). Wired in Phase 0.
### 4.2 Coexistence (the safety mechanism)
- Use MUI's **`ScopedCssBaseline`** around migrated page subtrees only, so MUI's CSS reset applies _inside_ migrated pages without disturbing unmigrated ones still on `base.css`. No global reset until teardown.
- The `CssVarsProvider`/`ThemeProvider` must sit **above** every `ScopedCssBaseline`, and `data-theme` must live on a common ancestor (`<html>`, as today) so the `[data-theme='%s']` selector resolves inside scoped subtrees.
- Styling engine: MUI default (**Emotion**). SPA only (Vite, no SSR), so no SSR-cache complexity.
---
## 5. Foundation UI kit (`src/admin/ui/`)
**Principle: pages never import `@mui` directly.** A thin wrapper kit bakes in brand defaults and **keeps the current component APIs and Czech labels**, so migrating a page is mostly swapping imports, not rewriting logic, and tuning a default touches one file, not 57.
| Today | → Foundation kit (`src/admin/ui/`) | MUI base |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| `FormModal` / `ConfirmModal` | `<Modal>` — preserves **FormModal's** full prop set (`submitDisabled`, `subtitle`, `loading`); ConfirmModal needs only `title`/`message`/`type`/`confirmVariant`/`loading` | `Dialog` |
| `.admin-table*` + `useTableSort` + `usePaginatedQuery` | **`<DataTable>`** (the dense "D" look) | `Table` |
| `AdminDatePicker` (react-datepicker) | `<DatePicker>` — `cs` locale, `dd.MM.yyyy` | MUI X Pickers (free) |
| `.admin-btn` | `<Button>` (pill / red-gradient default) | `Button` |
| `.admin-form-*` inputs/selects | `<TextField>` / `<Select>` / `<Switch>` | MUI form controls |
| `AlertContext` toasts | `<Toast>` — keeps `useAlert()` API | `Snackbar` |
| `AdminLayout` sidebar/topbar | `<AppShell>` — the soft-SaaS shell | `Box` / `Drawer` |
| KPI tiles | `<KpiCard>` / `<Card>` | `Card` |
| Tabs | `<Tabs>` | `Tabs` |
**Modal behavior change (deliberate a11y gain — not a 1:1 match).** Today's modals use `useModalLock` (a ref-counted body-scroll lock, used in **8 places**, including non-`FormModal` spots like Dashboard, OfferDetail, ReceivedInvoices, WarehouseItemDetail) plus `role="dialog"`, but have **no focus trap, no initial-focus, no return-focus**. MUI `Dialog` adds scroll-lock _and_ a focus trap + return-focus. This is an intentional improvement; it is called out so it isn't a surprise. **Audit all 8 `useModalLock` call sites** — those not backed by `FormModal`/`ConfirmModal` either migrate to `<Modal>` or keep `useModalLock` until their page migrates. Verify stacked/nested modal scroll-lock still works.
### 5.1 Dense `<DataTable>` (centerpiece)
MUI `Table` styled to the compact "D" look — uppercase micro headers, **DM Mono right-aligned numerics**, tinted status `Chip`s, subtle row hover — wired to the **existing `useTableSort` (column sort) and `usePaginatedQuery` (list + pagination) hooks**. The current `.admin-table-responsive` horizontal-scroll wrapper carries over as MUI's `TableContainer`.
- **Numbers stay formatted by the existing utilities.** Numeric/currency cells continue to render through `src/admin/utils/formatters.ts` (`formatCurrency` cs-CZ + ``/``, `formatKm`, `czechPlural`). `<DataTable>` only _styles_ (DM Mono, right-align); it never re-formats values.
- A mobile card-view variant for the densest tables is a future enhancement (out of scope for the first pass).
### 5.2 Date pickers
Swap `AdminDatePicker` → MUI X Date Pickers (free **Community** tier; only Range Pickers are Pro, which we don't need) with the `date-fns` adapter and **`cs` locale**. `AdminDatePicker` is used in **three variants**, all covered by Community:
| Current (react-datepicker) | → MUI X | Format (date-fns tokens) |
| -------------------------- | ---------------------------------- | ------------------------ |
| date | `DatePicker` | `dd.MM.yyyy` |
| `showMonthYearPicker` | `DatePicker` with month/year views | `MM/yyyy` |
| `showTimeSelectOnly` | `TimePicker` | `HH:mm` |
> **Format-token correction:** date-fns tokens are case-sensitive — `MM` = month, `mm` = minutes, `yyyy` = calendar year (`rrrr` = local-week-year, wrong). The format is **`dd.MM.yyyy`**, not `dd.mm.rrrr`. Apply via `LocalizationProvider` `dateFormats` (e.g. `{ keyboardDate: "dd.MM.yyyy" }`).
Retires a custom component and the `react-datepicker` dependency; MUI's responsive picker handles touch/mobile.
---
## 6. CSS end-state (explicit)
1. **Gone — ~90% of files (~80% of lines).** All per-feature CSS files deleted as their pages migrate: `buttons`, `forms`, `tables`, `invoices`, `offers`, `attendance`, `dashboard`, `warehouse`, `login`, `settings`, `filemanager`, `pagination`, `datepicker`, `layout`, `base`, `components`, **`responsive`** (the 6-line placeholder). What they expressed becomes the MUI theme + `sx`/`styled`.
2. **Tokens move, don't die.** `variables.css` is rewritten into `theme.ts` (TypeScript). With MUI's CSS-variable mode, MUI emits those tokens as CSS custom properties at runtime — so the hand-written `variables.css` file is retired, but the values live on in TS as the single source.
3. **Stays — a small residue** for the genuinely-bespoke widgets MUI does not provide:
- **Plan/gantt grid** (`plan.css`, ~1,152 lines, slimmed) — kept as a custom component with a trimmed stylesheet (largest survivor; ~16% of today's lines, hence "~80% of lines removed" not 90%).
- **Quill rich-text editor** and **Leaflet map** — kept, with small theming CSS.
- These surviving stylesheets reference the **MUI-emitted CSS variables**, so they stay in sync with the theme and dark/light.
**Stated precisely:** custom React components stay (their logic is the value); standard ones are re-skinned via MUI and lose their custom CSS; styling comes from the MUI theme; the only custom CSS remaining is the slim stylesheets for the 3 bespoke widgets, which still read the MUI theme tokens. Literally-zero custom CSS would require rebuilding the plan grid from MUI primitives — explicitly **out of scope** (YAGNI).
---
## 7. Aesthetic spec ("Soft-SaaS + dense", validated 2026-06-06)
The refresh is delivered in **two layers**, which is how it reconciles with the "shared palette" coexistence model:
- **Token-level (palette) — applies app-wide, early.** These are small refinements to the existing tokens (e.g. light canvas `#f5f4f2#f4f3f1`, refined status-chip tints). They are updated once in `variables.css`/`theme.ts` in Phase 0, so **both** legacy and migrated pages share the refreshed palette. This means Phase 0 _does_ shift colors slightly app-wide — it is **not** visually a no-op — but layout/structure is unchanged.
- **Component-level (the "modern look") — per page, via MUI.** The distinctive new look (red-gradient pill buttons, 16px soft-shadow rounded cards, the soft-SaaS shell, dense tables) is delivered through MUI component styling as each page migrates. Unmigrated pages keep their current structure on the refreshed palette.
So §4's "coexist" promise is **same palette/typography, not pixel-identical structure**.
Concrete values implementers must reproduce (light shown):
- **Canvas:** `#f4f3f1`. **Surfaces/cards:** `#ffffff`, radius **16px**, soft shadow `0 6px 20px rgba(20,20,40,.06), 0 1px 2px rgba(0,0,0,.03)`.
- **Sidebar:** transparent on canvas; active item = white pill with soft shadow + red text/icon.
- **Top bar:** Urbanist 800 page title; pill search field; circular icon buttons; gradient avatar; primary action = red-gradient pill button.
- **Primary button:** `linear-gradient(135deg,#e23a3a,#c01f1f)`, radius `999px`, shadow `0 5px 14px rgba(214,48,49,.32)`.
- **Status chips:** pill, soft tinted — paid `#eafaf0`/`#2b8a3e`, overdue `#fdecec`/`#c92a2a`, draft `#fbf3dd`/`#92760b`.
- **Dense table:** padding ~`.5rem 1rem`; headers `.64rem` uppercase, letter-spacing `.05em`, muted; **DM Mono** right-aligned numeric cells; row hover `#faf9f7`; row borders `#f5f3f1`; container is a 16px soft card.
- **Fonts:** Urbanist 600800 (headings), Plus Jakarta Sans 400700 (body), DM Mono 400500 (figures).
**Dark mode.** Several §7 values are new and have no current dark counterpart, so dark is **authored in `theme.ts` during Phase 0** by deriving from these light values + the existing dark surfaces (canvas `#0a0a0a`/`#0f0f0f`, glass borders `rgba(255,255,255,.08)`, dark chip tints), not literally "mirrored."
**Visual references** (persisted, gitignored): `.superpowers/brainstorm/9839-1780760811/content/` — `aesthetic-directions.html`, `refined-hybrid-faktury.html`.
---
## 8. Migration phasing
Each phase is **one or more** normal `v1.x` releases (realistic total: ~a dozen releases). Each migrated page deletes its own CSS file.
- **Phase 0 — Foundation.** Install MUI v7 + Emotion + MUI X v8 Pickers; reconcile `@types/react` with the React 18 runtime (§14); write `theme.ts` from tokens **and apply the token-level palette refresh app-wide** (§7); author the dark palette; wire `ScopedCssBaseline` + the `ThemeContext`↔`setMode()` sync; build the `src/admin/ui/` kit; add the **dev-only** `/ui-kit` showcase route (guarded by `import.meta.env.DEV`, tree-shaken from prod). Layout/structure unchanged; colors shift slightly app-wide. Deletes no per-page CSS yet.
- **Phase 1 — `<AppShell>` (chrome).** Migrate sidebar/topbar/theme-toggle to the soft-SaaS shell. Every page _inside the AdminLayout route_ sits in the new frame; unmigrated content still uses its own CSS inside it. Retires most of `layout.css`. **Login is NOT touched here** — it is a sibling route outside `AdminLayout` (`AdminApp.tsx:100`) with its own full-screen layout; see Phase 3.
- **Phase 2 — Pilot CRUD page (Vozidla / vehicles).** Full end-to-end: list, dense `<DataTable>`, filters, create/edit `<Modal>`. Battle-tests and tunes the foundation. Deletes that page's CSS.
- **Phase 3 — Simple CRUD + Login.** Uživatelé (users), Projects, Settings, and **Login** (migrated standalone — keeps its own full-screen layout, theme toggle, and 2FA two-step + shake/animate-out flow; deletes `login.css`).
- **Phase 4 — List-heavy** (Offers, Invoices lists). Hammers `<DataTable>`. Invoice/offer **on-screen detail** (Quill, the detail page) re-themed, not rebuilt. (Generated PDFs are out of scope — §13.)
- **Phase 5 — Attendance & Leave/Trips** (grids + forms).
- **Phase 6 — Warehouse, then Plan + Dashboard polish.**
- **Phase 7 — Teardown.** Delete leftover legacy CSS; retire `variables.css` into `theme.ts`; only the slim bespoke-widget CSS remains.
At no point is the app frozen or un-shippable.
---
## 9. Bespoke components & animation
**Bespoke components stay custom, re-themed (their logic is the value):**
- **Plan/gantt grid** (`plan.css`) — keep the grid; repaint from tokens; wrap its controls in the kit.
- **Leaflet maps** — keep; align container/control styling to the theme.
- **Quill rich-text** (`react-quill-new`) — keep; wrap as `<RichTextEditor>`; theme the toolbar/editor. **Server-side DOMPurify + `cleanQuillHtml` sanitization on the PDF and render paths is unaffected and must remain.**
- **@dnd-kit** drag-drop — keep (behavior, not styling).
**Animation (framer-motion).** framer-motion is used in ~40 files, not just modals. Decision: **keep framer-motion** for the animations MUI does not cover — the **AdminLayout logout transition** (scale + blur + fade), **dashboard card entrances**, **Login** transitions, and plan interactions. MUI's own transitions replace framer-motion **only** inside the components that become MUI (`Dialog` for modals, `Snackbar` for toasts). When migrating `AdminLayout`→`<AppShell>` and the dashboard, **consciously re-create or drop** their framer-motion animations (don't lose them silently). `useReducedMotion` continues to gate bespoke animations; MUI honors `prefers-reduced-motion` via its theme transitions.
---
## 10. Testing & verification
This is a UI restyle, so behavior must stay identical (except the deliberate modal a11y gain, §5).
- **Backend/API tests** (`auth`, `numbering`) unaffected; run each phase to confirm the server is untouched — they stay green.
- **Per-page manual verification checklist** (primary gate): every CRUD action, filter, sort, pagination, and modal works as before; **modal focus order / ESC / backdrop-close** behave; **dark and light** both render; **mobile + desktop** (responsive parity, §11); **zero new console errors**; `tsc --noEmit` and `vite build` pass.
- **`/ui-kit` dev route:** renders every foundation component in both themes — instant visual QA when tuning the theme; living reference for page migration. Dev-only (stripped from prod).
- **Playwright smoke + before/after screenshots:** a thin per-page script loads the page, runs the core flow, captures light+dark screenshots to diff against the pre-migration baseline. (Playwright is already available in the environment.)
---
## 11. Risks & mitigations
| Risk | Mitigation |
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Bundle/runtime cost (Emotion) | Measure after Phase 0; tree-shake; acceptable for an internal admin app |
| MUI reset clashing with legacy `base.css` mid-migration | `ScopedCssBaseline` around migrated subtrees; provider above it; migrate the shell early |
| Dark/light drift between MUI and legacy CSS | One `data-theme` attribute (owned by `ThemeContext`) + `setMode()` sync drives both |
| **Modal behavior change** (focus trap, return-focus) | Intentional a11y gain; audit the 8 `useModalLock` sites; add focus/ESC/backdrop to the per-page checklist |
| **Responsive parity** — 72 media queries live in the CSS files being deleted | Re-express each page's breakpoints via MUI theme breakpoints / responsive `sx` **when that page migrates** — part of per-page DoD; review high-MQ files (`layout`, `components`, `plan`) explicitly |
| **`@types/react` 19 vs React 18.3 skew** | Reconcile types to the React 18 runtime in Phase 0 before building the kit, so the `tsc --noEmit` gate isn't blocked by pre-existing skew |
| Czech dates in MUI X Pickers | `date-fns` `cs` locale, **`dd.MM.yyyy`** / `MM/yyyy` / `HH:mm` — verified in Phase 0 |
| Czech number/currency formatting | Tables keep `formatters.ts` (`formatCurrency`, `formatKm`); DataTable only styles |
| **Per-phase rollback** | Each phase is a self-contained, independently-revertable PR (page migration + its CSS deletion together). Coexistence means reverting one migrated page doesn't affect others. **Phase 1 (AppShell) is the highest-blast-radius phase** — ship behind extra verification; revert = `git revert` the phase PR + redeploy |
| Scope creep over a long migration | Strict phase discipline; **restyle only — never refactor logic in the same PR** |
---
## 12. Definition of done
- **Per page:** rendered via the `ui/` kit; its CSS file deleted; all interactions verified; **its media queries re-expressed via MUI breakpoints/`sx`**; modal focus/ESC/backdrop verified; dark + light + mobile + desktop pass; `tsc` + `vite build` green; no new console errors; shipped.
- **Overall:** every page migrated; all per-feature CSS deleted (incl. `responsive.css`); `variables.css` retired into `theme.ts`; only the slim bespoke-widget CSS remains; the dev-only `/ui-kit` route reflects the full theme (and stays stripped from prod).
---
## 13. Out of scope / deferred
- **Puppeteer PDF templates** (`src/routes/admin/invoices-pdf.ts`, `offers-pdf.ts`, `orders-pdf.ts`) use their **own self-contained inline `<style>` + print fonts**, independent of the admin stylesheet — **NOT** part of this migration and not aligned to the MUI theme. (DOMPurify + `cleanQuillHtml` sanitization on those paths is unaffected.) Phase 4's "detail re-themed" refers to the **on-screen** detail page only.
- Rebuilding the plan/gantt grid from MUI primitives (kept custom-but-themed).
- Mobile card-view table variant (future enhancement).
- Any backend, API, or business-logic change.
- Replacing Leaflet, Quill, or @dnd-kit (kept).
---
## 14. Open items to confirm during planning (Phase 0)
- Confirm **MUI v7** + **MUI X v8** as the pinned versions and re-verify the `cssVariables.colorSchemeSelector` + `colorSchemes` API against the installed version's docs.
- **Reconcile `@types/react`** with the React 18.3 runtime (or deliberately validate the chosen MUI version against React-18-runtime-with-React-19-types) before committing the kit.
- Verify each of the three MUI X picker variants under the `date-fns` `cs` adapter: `DatePicker` (date), `DatePicker` month/year views (replacing `showMonthYearPicker`), `TimePicker` (replacing `showTimeSelectOnly`).- Audit the 8 `useModalLock` call sites and decide migrate-now vs keep-until-page-migrates for each.
- Bundle-size budget/target to check against after Phase 0.
> **Note:** `CLAUDE.md` references a hook named `useListData` that does not exist (the real hook is `usePaginatedQuery`); this spec uses the correct name. Correcting `CLAUDE.md` is advisable but outside this spec's scope.

View File

@@ -0,0 +1,190 @@
# Design — AI assistant, Phase 1 (chat + invoice import + budget cap)
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation plan
**Phase 1 of 2.** Phase 2 (the AI querying system data via read-tools) is a separate later cycle — see "Deferred: Phase 2" at the end.
---
## Goal
Add the app's first AI capability: an **admin-only chat assistant embedded on the
dashboard** where you can talk to Claude and, crucially, **attach received-invoice
PDFs that the AI reads and imports into Přijaté faktury** (extract → you confirm →
save). All AI usage is bounded by an **admin-editable monthly budget (default
$50)** that hard-stops calls when exhausted.
## Decisions (locked in brainstorming)
- **Scope (Phase 1):** chat + invoice import + budget cap. **No system-data
access** (the AI cannot query your invoices/projects/attendance — that's Phase 2).
- **Placement:** a `DashAssistant` widget on the **dashboard, at the top under the
welcome text** — not a separate page, not a floating bubble.
- **Access:** **admins only**, via a new `ai.use` permission.
- **Model:** **Claude Sonnet 4.6** (strong vision, low cost).
- **Save flow:** extract → **you confirm/edit → save** (never silent auto-save).
- **Budget:** **$50/month**, resets each calendar month, **admin-editable in
Settings**; AI calls are **blocked** once the month's spend reaches the budget.
- **Chat:** **non-streaming** (request → full reply) and **in-browser history
only** (not persisted server-side) for v1 — both are easy later upgrades.
## Non-goals / out of scope (Phase 1)
- No system-data query tools / function-calling (Phase 2).
- No server-side conversation persistence, no streaming.
- No new invoice storage path — reuses `POST /received-invoices` verbatim.
---
## Architecture
Greenfield AI integration; the rest of the app is unaffected and works unchanged
if `ANTHROPIC_API_KEY` is absent (the assistant widget simply doesn't render and
the AI routes return a clear "not configured" error).
- **Dependency:** `@anthropic-ai/sdk`.
- **Env:** `ANTHROPIC_API_KEY` (required for the feature). Optional
`AI_MONTHLY_BUDGET_USD` default seed (the live value lives in the DB, below).
- **`src/services/ai.service.ts`** — wraps the SDK, owns the model choice + system
prompt + cost accounting. Two operations:
- `chat(messages)``client.messages.create({ model: "claude-sonnet-4-6", … })`,
non-streaming, returns `{ reply: string, usage }`.
- `extractInvoice(pdfBuffer, fileName)``client.messages.create` with the PDF as
a `document` content block + `output_config.format` JSON-schema for the
received-invoice fields, returns `{ fields, usage }`.
- Both call the spend tracker to record usage **and** are gated by the budget
guard (below) BEFORE the API call.
- Isolated so tests can stub the Anthropic call (no real API in CI).
## Spend cap (the $50/month budget)
- **New table `ai_usage`** (migration): `id`, `user_id?`, `kind` ("chat" |
"extract"), `model`, `input_tokens`, `output_tokens`, `cost_usd`
`@db.Decimal(10,6)`, `created_at @db.Timestamp(0)`, with an index on
`created_at`.
- **Cost map** (in `ai.service.ts`): model → `{ inputPerToken, outputPerToken }`.
Sonnet 4.6 = $3 / $15 per 1M → `0.000003` / `0.000015` per token. `cost_usd =
in*inP + out*outP`.
- **Budget value:** a new `ai_monthly_budget_usd Decimal @default(50)` column on
`company_settings` (the existing app-config singleton). Admin-editable in
Settings.
- **Budget guard `assertBudgetAvailable()`:** sum `cost_usd` for rows where
`created_at >= startOfMonth(now)`; if `>= ai_monthly_budget_usd`, return
`{ error: "Měsíční rozpočet AI byl vyčerpán", status: 402 }`. Called at the top
of every AI route BEFORE the Claude call.
- **Usage read:** `GET /api/admin/ai/usage` → `{ month_spend_usd, budget_usd,
remaining_usd }` for the chat's budget indicator and the Settings page.
## Backend routes — `src/routes/admin/ai.ts`
All guarded by `requirePermission("ai.use")`.
- `POST /api/admin/ai/chat` — body `{ messages: {role, content}[] }`. Budget guard →
`ai.service.chat` → log usage → `success(reply: string, remaining_usd)`.
- `POST /api/admin/ai/extract-invoices` — multipart PDF files (≤ `MAX_UPLOAD_SIZE`,
PDF/image only). Budget guard → `extractInvoice` per file → returns the extracted
fields per file (NOT saved) + usage. The files are **not** persisted here.
- `GET /api/admin/ai/usage` — current-month spend + budget + remaining.
If `ANTHROPIC_API_KEY` is unset, the POST routes return
`{ error: "AI není nakonfigurováno", status: 503 }`.
## Invoice import flow (extract → confirm → save)
1. Admin attaches one or more invoice PDFs in the dashboard chat (optionally with a
text note).
2. Frontend posts the files to `POST /ai/extract-invoices`; the AI returns the
extracted fields for each.
3. The chat renders an **editable review card per invoice** (dodavatel, číslo
faktury, částka, měna, sazba DPH, datum vystavení/splatnosti, poznámka),
pre-filled with the AI's values, plus a duplicate hint if `invoice_number` +
`supplier_name` already exists.
4. Admin glances/edits → **"Uložit"** (per card) or **"Uložit vše"**.
5. The frontend submits **the original File + the confirmed metadata** to the
**existing `POST /api/admin/received-invoices`** (multipart) — same NAS storage,
same server-side VAT recompute, same audit. The AI never writes invoices
directly. (The PDF is uploaded twice — once to extract, once to save — which is
fine for invoice-sized files and avoids any server-side temp-file handling.)
## Frontend
- **`src/admin/components/dashboard/DashAssistant.tsx`** — the chat widget:
message list, input box, attach-file button, and a small budget indicator
("Rozpočet: $X.XX / $50"). Non-streaming; conversation in component state only.
Renders the invoice review cards inline (reusing the received-invoice field
inputs / kit components).
- **`src/admin/pages/Dashboard.tsx`** — render `<DashAssistant />` at the top,
directly under the welcome text, **only when** the user is admin / has `ai.use`
(and only when AI is configured — a `GET /ai/usage` 503 hides it gracefully).
- **`src/admin/lib/queries/ai.ts`** — React Query options/mutations for chat,
extract, and usage. On a successful invoice save, invalidate `["received-invoices"]`.
- **Settings** — an admin field to set the monthly budget (writes
`company_settings.ai_monthly_budget_usd`).
## Permissions & migration
One Prisma migration:
- Create `ai_usage` table.
- Add `ai_monthly_budget_usd` to `company_settings` (default 50).
- Insert the `ai.use` permission and grant it to the **admin** role (explicit
`INSERT`s in `migration.sql`, per the project's migration policy).
(The admin role bypasses permission checks via `roleName === "admin"`, but the
explicit `ai.use` permission lets the access be widened later without code changes
and documents intent.)
## Security / privacy
- **Phase 1 has no tools**, so the AI cannot take actions on your data — its only
effect is the human-confirmed invoice save. A malicious/auto-generated invoice
PDF therefore can't trigger anything (the injection-to-action surface arrives in
Phase 2 and is designed there).
- Attached PDFs are sent to the Anthropic API to be read. The API key lives in
`.env` (server-side only), never exposed to the browser.
- Standard sanitization already applies on the received-invoice render/PDF paths;
AI-extracted text flows through the same validated `received_invoices` create.
## Testing
Server-side, real test DB, **Anthropic call stubbed** (no real API in CI):
- **Spend tracker:** `cost_usd` computed correctly from tokens for Sonnet 4.6;
monthly sum only counts the current month.
- **Budget guard:** under budget → allowed; at/over budget → `402` with the Czech
message; the guard reads the DB budget value.
- **Permissions:** all `/ai/*` routes return `403` without `ai.use`.
- **Not-configured:** with no `ANTHROPIC_API_KEY`, POST routes return `503`.
- **extract field-mapping:** given a stubbed AI response, the route returns the
expected `received_invoices` field shape (and computes nothing that the existing
create doesn't already).
Frontend has no component-test harness → gated by `tsc -b` + `build` + a manual
check (the dashboard widget renders for admins, hidden otherwise).
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
## Rollout
Ships as **v2.1.0** (notable new capability) via the standard process. **Requires a
migration**, so the dev server must be stopped for `prisma migrate dev` (ask first),
and the release runs `prisma migrate deploy` on prod. `ANTHROPIC_API_KEY` must be
set in the production `.env` before the feature works.
---
## Deferred: Phase 2 (system-data access) — captured
A later, separate spec + cycle. The AI gains **read-only tools** (function calling)
to answer questions about your data — e.g. "co je po splatnosti?", "kolik jsme
fakturovali v květnu?". Key design work for Phase 2:
- A curated set of **read tools** (search invoices, overdue list, project lookup,
attendance summary, …), each **scoped to the calling user's permissions** so the
AI never returns data the user can't see.
- The **agentic tool-loop** (Claude requests a tool → app executes against Prisma →
returns results → Claude continues), with a per-conversation tool-call cap to
bound cost (within the same monthly budget).
- **Prompt-injection hardening:** because the AI will both read untrusted invoice
content _and_ hold tools, tools stay **read-only**, results are treated as data,
and any state-changing action remains human-confirmed.

View File

@@ -0,0 +1,188 @@
# Design — Bulk plan creation (Feature B)
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation plan
**Builds on:** Feature A (multi-record plan cells, max 3 per cell), shipped in v2.0.5. Spec: `docs/superpowers/specs/2026-06-08-multi-record-plan-cells-design.md`.
---
## Goal
Let a planner assign one project/category to **many employees over a date range
in a single action**, instead of creating each person's plan record one by one.
A "Hromadné přiřazení" button on **/plan-work** opens a modal that mirrors the
existing single-record create form, plus an employee checklist and an "include
weekends" toggle.
## Decisions (locked in brainstorming)
- **Modal mirrors single-create** (`Datum od` + "Více dní" → `Datum do`, Projekt,
Kategorie, Text poznámky) **plus** a multi-select employee checklist and a
**"Zahrnout víkendy"** checkbox.
- **Storage = range entries** (`work_plan_entries`), one per contiguous run of
eligible days per employee — the same row shape the single-create form produces.
- **Weekends:** default off (MonFri only). Only Sat/Sun are excluded — **no
separate holiday handling**.
- **Cap:** governed by Feature A's `MAX_RECORDS_PER_CELL = 3`. A day already at
the cap for an employee is **skipped** (never overwritten), which also splits
that employee's range around the skipped day.
- **Past dates blocked** (no `force` exposed in the bulk UI).
- **Category** picker defaults to **Práce**; **Project** is optional ("bez
projektu"); **note** optional, applied to all created entries.
## Non-goals / out of scope
- No DB migration (reuses `work_plan_entries` and Feature A's cap logic).
- No overwrite/replace of existing records (purely additive under the cap).
- No holiday/`svátky` exclusion (only weekends).
- No bulk creation of **overrides** — bulk always creates **entries** (the normal
layer); single-day exceptions remain the per-cell day-panel's job.
---
## Storage & weekend behaviour
For each selected employee, the service computes the **eligible days** in
`[date_from, date_to]` and groups **contiguous** eligible days into range
entries.
A day `D` is **eligible** for employee `U` when all hold:
1. `D` is within `[date_from, date_to]` (inclusive).
2. `include_weekends` is true, **or** `D` is a weekday (MonFri).
3. `U` has fewer than `MAX_RECORDS_PER_CELL` active entries covering `D` (under
the cap).
Contiguous runs of eligible days become one range entry each
(`date_from = run start`, `date_to = run end`). Consequences:
- **Víkendy ON, no cap conflicts:** one continuous range per employee — identical
to creating a single multi-day record for everyone.
- **Víkendy OFF:** one range per work-week (MonFri chunks), because a weekend day
breaks the run.
- **A cap-full day** breaks the run too, so the range splits around it (and that
`(employee, day)` is counted as skipped).
In the grid these are ordinary range entries — they show the "součást rozsahu"
badge and open the range-vs-this-day chooser on click, exactly like
manually-created ranges.
---
## Backend
### Route — `POST /api/admin/plan/entries/bulk`
- Guard: `requirePermission("attendance.manage")` (same as single entry create).
- Body (validated by a new Zod schema `BulkPlanEntrySchema` in
`src/schemas/plan.schema.ts`):
```
{
user_ids: number[] // ≥1
date_from: string // YYYY-MM-DD
date_to: string // YYYY-MM-DD (≥ date_from)
include_weekends: boolean
project_id?: number | null
category: string // default applied client-side; validated server-side
note?: string // default ""
}
```
- On success: one **summary audit** row (`logAudit`, action `create`, entityType
`work_plan_entry`, description e.g. `Hromadné přiřazení: <kategorie> <projekt> —
N zaměstnanců, <from><to> (<created_days> dní)`), then `success(reply,
summary, 201, "Hromadné přiřazení dokončeno")`.
### Service — `bulkCreateEntries(input, actorUserId)` in `src/services/plan.service.ts`
Up-front validation (fail fast, nothing created):
- `user_ids.length >= 1` (else `{ error, status: 400 }`).
- `date_to >= date_from` (else 400).
- `assertNotPastDate(date_from, false)` and `assertNotPastDate(date_to, false)`
(bulk blocks past dates; 403).
- `assertActiveCategory(category)` (400 if inactive/unknown).
Then, per employee:
- Load the employee's active entries overlapping `[date_from, date_to]` once;
compute per-day coverage counts.
- Walk the days, mark eligible ones (weekday filter + under-cap), group contiguous
runs.
- For each run, create the range by **reusing `createEntry`**
(`{ user_id, date_from: runStart, date_to: runEnd, project_id, category, note }`,
`actorUserId`, `force: false`). Reusing `createEntry` keeps cap / past-date /
category validation DRY; its cap re-check passes because every day in the run is
already under the cap. A `createEntry` that nonetheless returns `{ error }` (rare
race) is counted as skipped, not fatal.
Aggregate and return:
```
{
created_entries: number // range rows created
created_days: number // (employee, day) records created
skipped_days: number // weekday-eligible (employee, day) slots skipped at the cap
users: number // employees processed
}
```
`skipped_days` counts only days that passed the weekday filter but were at the
cap — weekend days (when excluded) are out of scope, not "skipped".
Best-effort across employees/segments (no cross-entry transaction): up-front
validation already rejects bad input, and the cap-skip is by design. Partial
results are reported in the summary.
---
## Frontend
### Modal — `src/admin/components/BulkPlanModal.tsx`
Mirrors the single-create `EditForm` fields + bulk-attendance's employee picker
(`BulkAttendanceModal.tsx` is the pattern to follow):
- `Datum od` (DateField) + "Více dní" CheckboxField → `Datum do` (DateField).
- **Zaměstnanci** — scrollable `CheckboxField` list (source: the plan users
already loaded for the grid, `/plan/users`), with a "Vybrat vše" / "Odznačit
vše" toggle and a "Vybráno X z Y" caption; submit disabled when none selected.
- **Zahrnout víkendy** CheckboxField (default off).
- **Projekt** Select (optional — "— bez projektu —"), **Kategorie** Select
(active categories, default `work`/Práce), **Text poznámky** TextField (optional).
- A short helper line, e.g. _"Vytvoří záznamy pro vybrané dny u zvolených
zaměstnanců. Dny nad limit 3 záznamů se přeskočí."_
### Wiring — `src/admin/pages/PlanWork.tsx`
- A "Hromadné přiřazení" Button in the toolbar (next to "Správa kategorií"),
rendered only when `canEdit` (`attendance.manage`).
- A `bulkCreate` mutation (React Query) → `POST /api/admin/plan/entries/bulk`;
on success show an alert summarising the returned counts (e.g. _"Vytvořeno 18
záznamů pro 5 zaměstnanců; 4 dny přeskočeny (limit 3 na den)."_), close the
modal, and `invalidate: ["plan"]` so the grid refreshes.
---
## Testing (`src/__tests__/plan.test.ts` or a new `bulkPlan.test.ts`)
Backend service tests against the real test DB:
- **Weekday filter:** weekends-off over a MonSun span creates only MonFri days
(and as the expected number of per-work-week ranges); weekends-on creates one
continuous range.
- **Segmentation:** a cap-full day in the middle splits the range into two; the
skipped day is counted in `skipped_days`.
- **Cap skip:** an employee already at 3 entries on some days gets those days
skipped; the summary reflects `created_days` vs `skipped_days`.
- **Multi-employee aggregation:** `users`, `created_entries`, `created_days` sum
correctly across the selected employees.
- **Validation:** empty `user_ids` → 400; past `date_from` → 403; inactive
category → 400; `date_to < date_from` → 400.
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
## Rollout
Ships as **v2.0.6** via the standard process (commit → tag → Gitea → tarball →
prod deploy → health check). No migration step. The grid refreshes via
`["plan"]` invalidation.

View File

@@ -0,0 +1,208 @@
# Design — Multi-record plan cells (max 3 per cell)
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation plan
**Feature A of 2.** Feature B (bulk plan creation) is a separate later cycle — see "Deferred: Feature B" at the end.
---
## Goal
Today a plan cell (one person, one day) resolves to **exactly one** record: an
override hides the planned range, and overlapping range entries collapse to
"newest wins". The work plan should instead let a cell hold **up to 3 records**,
so a person can have several assignments on the same day (additive) _and_ still
get one-day exceptions to a planned range.
This is the enabling model change. The eventual "bulk assign a project to many
employees over a date range" form (Feature B) will create records under this
model and respect the 3-per-cell cap.
## Use cases (both required)
1. **Additive** — several projects/tasks the same day (e.g. morning project A,
afternoon project B). Both records show in the cell.
2. **Day exception** — assigned to a multi-day range, but one day differs;
replace just that day without splitting the range.
## Non-goals / out of scope
- No bulk-create UI (Feature B).
- No database migration — the tables already allow multiple rows per
(user, day) (the old `work_plan_overrides` uniqueness constraint was dropped in
migration `20260605122718_drop_wpo_unique_constraint`).
- The max is a fixed constant (3), not user-configurable.
---
## Model — "layered" (approved)
Keep the two existing tables and their meaning; stop collapsing them to one
record.
- **`work_plan_entries` (ranges) = the normal-plan layer.** Multiple active
ranges may cover the same day → the **additive** case.
- **`work_plan_overrides` (single-day) = the exception layer.** When a day has
≥1 active override, the overrides **replace** the range entries for that day
(today's "override beats entry" rule, kept) → the **day-exception** case.
### Resolution rule
For a given `(user, day)`:
1. Active overrides for that exact day (`is_deleted = false`). If any →
the cell is the overrides, **newest-first, capped at 3**.
2. Otherwise → active entries covering that day
(`date_from ≤ day ≤ date_to`, `is_deleted = false`), **newest-first, capped at 3**.
3. Otherwise → empty.
The cell is therefore an array of 03 `ResolvedCell` items, all from a single
layer (overrides **or** entries, never mixed). This preserves the "exception
replaces the day" semantic while allowing additive stacking within each layer.
**Known limitation (accepted):** a range entry and an exception override cannot
show together on the same day — an override is all-or-nothing for that day. This
matches what "exception" means.
### Cap (max 3 per cell)
`MAX_RECORDS_PER_CELL = 3` (single constant in `plan.service.ts`).
Enforced on create, per layer, per day:
- **`createOverride`** — reject if the `(user, day)` already has 3 active
overrides.
- **`createEntry`** — reject if **any** day in the new `[date_from, date_to]`
range is already covered by 3 active entries for that user. The error names the
first offending date (Czech message). Cap counts entries regardless of whether
they are currently hidden by an override (simpler and predictable).
`createOverride` **changes from replace-semantics to additive-with-cap**: it no
longer soft-deletes the existing override for that day. The transaction +
row-lock that protected the old "at most one active override" invariant is
repurposed to enforce "at most 3" under concurrency. The `replacedData` /
`replacedDescription` plumbing (and the route branch that logged the replaced
row's soft-delete) is removed.
---
## Backend changes
### `src/services/plan.service.ts`
- `ResolvedCell` interface unchanged (still one record).
- `resolveCell(userId, dateStr)` → returns `ResolvedCell[]` (03) using the
resolution rule above. Replaces the current single-result + "newest wins"
`console.warn`.
- `resolveGrid(userIds, from, to)``cells[userId][dateStr]` becomes
`ResolvedCell[]` (empty array, not `null`, for no records). Same two-query
load; per (user, day) build the capped array from the right layer.
- `createEntry` → add the per-day entry-cap check before insert.
- `createOverride` → drop the soft-delete-replace; add the override-cap check;
keep the transaction/lock for race safety; drop `replacedData`.
- Add `export const MAX_RECORDS_PER_CELL = 3;`.
### `src/routes/admin/plan.ts`
- `POST /plan/overrides` — remove the `result.replacedData` audit branch (no
longer produced). Everything else unchanged (cap errors surface via the normal
`{ error, status }` path).
### `src/routes/admin/dashboard.ts`
- `today_plan` is built from `resolveCell` → now an **array**. Map each record
with its category label + colour (the existing enrichment, applied per item).
`result.today_plan` becomes `TodayPlan[]` (possibly empty).
---
## Frontend changes
### Types — `src/admin/lib/queries/plan.ts`
- `GridData.cells: Record<number, Record<string, ResolvedCell[]>>` (was
`ResolvedCell | null`).
### Grid — `src/admin/components/PlanGrid.tsx` + `PlanRangeChips.tsx`
- A cell renders **up to 3 stacked records**, each a compact row: category
colour bar + category label + project (mono). When the cell has **exactly one**
record, also show its note (2-line clamp), as today. With 23 records, notes
are omitted for density.
- `--cat-color` is set per record row (not per cell).
- `onCellClick(userId, date, cells)` passes the **array**. Empty cell → still a
one-click create. Occupied cell → opens the day panel (below).
- Past-day / read-only / weekend / today styling unchanged.
```
┌─ Jan N. · Čt 12 ───────┐
│▌ PRÁCE · 26710001 │
│▌ DOVOLENÁ │
│▌ ŠKOLENÍ · 26710044 │
└────────────────────────┘ ▌ = category colour bar
```
### Cell editor — `src/admin/components/PlanCellModal.tsx`
New top-level **day-panel** mode shown when a cell has ≥1 record:
- Lists the day's records (each: colour bar, category, project, optional note)
with **edit (✎)** and **delete (🗑)** per record.
- **"+ Přidat záznam"** button, disabled at 3 with a hint
("Maximum 3 záznamy na den"). Add targets the **showing layer**: an entry in
normal mode, an override in exception mode — so the panel reads simply as
"records for this day" and the entry/override distinction stays invisible.
- Editing a record reuses the existing `EditForm`. Editing a record that belongs
to a **multi-day range** still routes through the existing `day-in-range`
chooser ("edit whole range" vs "carve out this day" = create override).
- Empty cell skips the panel and opens the create form directly (current
behaviour).
`PlanWork.tsx` wires the new panel: cell-click opens it with the array; the
panel's per-record actions reuse the existing entry/override create/update/delete
mutations and `invalidate: ["plan"]`.
### Dashboard — `DashTodayPlan.tsx` + `Dashboard.tsx`
- `today_plan` prop becomes `TodayPlan[]`. Render up to 3 records stacked
(reuse the existing single-record card layout per item). Empty array → the
existing "Pro dnešek nemáte naplánováno." state.
---
## Testing (`src/__tests__/plan.test.ts`)
Update existing assertions for the array shape, and add:
- `resolveCell` returns `[]` for an empty day; one item for a single entry; the
override layer (entries hidden) when an override exists.
- **Additive:** two entries covering the same day → both returned (newest first).
- **Cap display:** 4 overlapping entries on a day → only 3 returned.
- **Cap enforcement:** `createEntry` rejects when a day in range already has 3
entries (error names the date); `createOverride` rejects at 3 overrides.
- **Additive overrides:** `createOverride` no longer soft-deletes the prior
override; two overrides on a day coexist.
- `resolveGrid` cell arrays match `resolveCell` for the same (user, day).
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
## Rollout
Ships as its own release (**v2.0.5**) via the standard process (commit → tag →
Gitea → tarball → prod deploy → health check). No migration step.
---
## Deferred: Feature B (bulk plan creation) — decisions captured
A later, separate spec + cycle. Locked decisions from this brainstorming:
- **Form:** select one project, a date range (fromto), and multiple employees;
create the assignment for all of them at once.
- **Category:** a category picker in the bulk form, **default Práce**.
- **Weekends:** an **"include weekends" checkbox** (off = MonFri only, which
requires splitting each person into per-work-week entries; on = one continuous
range).
- **Conflict handling:** governed by this feature's **max-3-per-cell** rule — for
each employee/day, create the record only if the cell is under the cap;
otherwise **skip** that day (no error).

View File

@@ -0,0 +1,218 @@
# Odin — Multi-Conversation AI Chat — Design
**Date:** 2026-06-08
**Status:** Approved (design), pending spec review
## Goal
Replace the single-thread Odin assistant with a multi-conversation chat: a
purpose-built UI on the `/odin` page where the user keeps several named
conversations (like topics), switches between them via top tabs, and can
rename or delete them. All existing assistant capabilities (chat, PDF invoice
extraction → review → save, monthly budget) carry over unchanged.
## Background (current state)
- `ai_chat_messages` is a single rolling thread per user (`user_id, role,
content, created_at`). Endpoints `GET/POST/DELETE /api/admin/ai/history`
read/append/clear that one thread.
- The chat lives in `DashAssistant.tsx`, rendered on the `/odin` page (moved
off the dashboard in the prior change, branch `feat/odin-page`).
- Stateless AI endpoints (`/chat`, `/extract-invoices`, `/usage`, `/budget`)
are unaffected by conversations — they don't persist anything themselves.
## Decisions (from brainstorming)
- **Titling:** new conversation auto-titles from its first user message; user
can rename.
- **Per-conversation actions:** rename + delete.
- **Existing history:** migrate the current single thread into one conversation
per user ("keep as first conversation"); nothing is lost.
- **Layout:** top tabs + chat (not a left sidebar).
## Data model
### `ai_conversations` (new)
| column | type | notes |
| ---------- | --------------------- | ----------------------------------- |
| id | INT PK AUTO_INCREMENT | |
| user_id | INT NOT NULL | owner; isolation key |
| title | VARCHAR(255) NOT NULL | default "Nová konverzace" |
| created_at | TIMESTAMP(0) NOT NULL | default CURRENT_TIMESTAMP |
| updated_at | TIMESTAMP(0) NOT NULL | bumped on each append; sort/recency |
Index: `idx_ai_conv_user (user_id, id)`.
### `ai_chat_messages` (modified)
- Add `conversation_id INT NOT NULL` (FK → `ai_conversations(id)` `ON DELETE
CASCADE`). Keep `user_id` (denormalized, harmless; simplifies cleanup).
- Replace index `idx_ai_chat_user (user_id, id)` with
`idx_ai_chat_conv (conversation_id, id)` (messages are read per conversation).
### Prisma
```prisma
model ai_conversations {
id Int @id @default(autoincrement())
user_id Int
title String @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
messages ai_chat_messages[]
@@index([user_id, id], map: "idx_ai_conv_user")
}
model ai_chat_messages {
id Int @id @default(autoincrement())
user_id Int
conversation_id Int
role String @db.VarChar(20)
content String @db.Text
created_at DateTime @default(now()) @db.Timestamp(0)
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
@@index([conversation_id, id], map: "idx_ai_chat_conv")
}
```
### Migration (hand-written SQL; one tracked migration)
1. `CREATE TABLE ai_conversations (...)`.
2. `ALTER TABLE ai_chat_messages ADD COLUMN conversation_id INT NULL AFTER user_id`.
3. Backfill one conversation per user who has messages:
`INSERT INTO ai_conversations (user_id, title, created_at, updated_at)
SELECT user_id, 'Konverzace', NOW(), NOW() FROM ai_chat_messages GROUP BY user_id;`
4. Point messages at it:
`UPDATE ai_chat_messages m JOIN ai_conversations c ON c.user_id = m.user_id
SET m.conversation_id = c.id;`
5. Drop the old index, enforce NOT NULL, add the FK + new index:
`ALTER TABLE ai_chat_messages DROP INDEX idx_ai_chat_user,
MODIFY conversation_id INT NOT NULL,
ADD CONSTRAINT fk_ai_chat_conv FOREIGN KEY (conversation_id)
REFERENCES ai_conversations(id) ON DELETE CASCADE,
ADD INDEX idx_ai_chat_conv (conversation_id, id);`
The backfill is a no-op when `ai_chat_messages` is empty (prod today), and
preserves the dev thread.
## Service layer (`ai.service.ts`)
Replace the single-thread helpers (`getChatHistory` / `appendChatMessages` /
`clearChatHistory`) with conversation-scoped ones. Every function takes
`userId` and verifies the conversation belongs to the user.
- `listConversations(userId)` → `{id, title, updated_at}[]`, ordered by `id` asc
(stable tab order).
- `createConversation(userId, title?)` → the new conversation (default title
"Nová konverzace").
- `getConversation(userId, id)` → conversation or null (ownership check helper).
- `getConversationMessages(userId, id)` → `StoredChatMessage[]` oldest→newest,
or `{ error, status }` if not owned/found.
- `appendConversationMessages(userId, id, msgs)` → append; **bump `updated_at`**;
**auto-title**: if the conversation's title is still the default and this batch
has a user message, set `title` = first ~60 chars of that user message.
- `renameConversation(userId, id, title)` / `deleteConversation(userId, id)` —
ownership-checked; delete cascades messages.
Auto-title keeps titling server-side and robust (no client title logic).
## Routes (`routes/admin/ai.ts`, all `requirePermission("ai.use")`)
| Method | Path | Body / result |
| ------ | ----------------------------- | ------------------------------------------ |
| GET | `/conversations` | → `{ conversations: [...] }` |
| POST | `/conversations` | `{ title? }` → `{ conversation }` |
| GET | `/conversations/:id/messages` | → `{ messages: [...] }` (404 if not owned) |
| POST | `/conversations/:id/messages` | `{ messages:[{role,content}] }` (1..20) |
| PATCH | `/conversations/:id` | `{ title }` (1..255) |
| DELETE | `/conversations/:id` | → `{ ok: true }` |
`:id` parsed via `parseId`; bodies via `parseBody`; ownership 404 from the
service surfaced with `error(reply, ...)`. The old `/history` routes are removed.
`/chat`, `/extract-invoices`, `/usage`, `/budget` are unchanged.
## Frontend
### Queries (`lib/queries/ai.ts`)
- `aiConversationsOptions()` → `["ai","conversations"]`, `GET /conversations`.
- `aiConversationMessagesOptions(id)` → `["ai","conversation",id,"messages"]`,
`GET /conversations/:id/messages`, `gcTime: 0` (each mount re-reads the DB,
same rationale as today: no stale-cache resurrection).
- Types: `Conversation { id, title, updated_at }`, reuse `StoredChatMessage`.
### Components (`src/admin/components/odin/`)
- **`OdinChat.tsx`** — orchestrator. Owns: active conversation id, input, busy,
staged attachments, review list. Loads the conversation list; ensures one
exists (creates a default if the user has none); loads the active thread;
routes send/extract/save through the active conversation. Full-height flex
layout: header → tabs → thread (flex-grow, scroll) → review → composer.
- **`OdinTabs.tsx`** — horizontally-scrollable tabs (one per conversation) +
a "+" new-chat button + a ⋮ menu on the active tab (Přejmenovat / Smazat).
Rename uses a small Modal with a text field; delete uses ConfirmDialog.
- **`OdinThread.tsx`** — message list with an Odin avatar on assistant turns,
user turns right-aligned; the "Pracuji…" indicator; empty-state greeting.
(Message text colour set on the `Typography` — GlobalStyles pins `p` to
text.secondary.)
- **`InvoiceReviewCard.tsx`** — the editable extract card (supplier, number,
Částka s DPH, měna, sazba, dates, popis) + Uložit / Zahodit.
- **`OdinComposer.tsx`** — staged-attachment chips + attach button + message
field (Enter to send) + send button; disabled until the active thread loads.
`DashAssistant.tsx` is deleted; `pages/Odin.tsx` renders `OdinChat`.
### Data flow
1. Mount → load `aiConversationsOptions`. If empty, `POST /conversations` to
create a default and select it; else select the first (or last-active).
2. Selecting a tab sets the active id → `aiConversationMessagesOptions(id)`
loads that thread into the displayed turns (seed-once per id; submit marks
the thread live so a late load can't clobber — same guard as today).
3. **Send (chat):** optimistic user turn → `POST /chat` → append assistant
reply → `POST /conversations/:id/messages` persists both → bump usage. On
error: roll back, keep input.
4. **Send (attachments):** `POST /extract-invoices` → review cards →
client-side summary turn → persist the user+summary turns. Save a card →
`POST /received-invoices` (gross/VAT-inclusive, unchanged).
5. **New / rename / delete** → mutations that invalidate `["ai","conversations"]`
(and reset the active id appropriately on delete).
### Reuse
All proven logic carries over verbatim: staged-attach (no auto-send), the three
bug fixes (no clear-on-error, rollback dangling turn, stable ids — `nextUid`,
NOT `crypto.randomUUID`), the model-context trimming (last 20, leading-user),
budget indicator, gross/VAT save. The only structural change is "one thread" →
"active conversation thread", plus the tab/CRUD shell.
## Error handling
- Ownership: service returns `{ error: "Konverzace nenalezena", status: 404 }`
for a conversation the user doesn't own; routes surface it. No cross-user read
or write is possible (every query filters by `user_id`).
- Persistence is best-effort and logged (never swallowed), as today.
- Auto-create-default and create/rename/delete failures surface a Czech toast.
## Testing
Backend (Vitest, real DB):
- Conversation CRUD: create → list → rename → delete (cascade removes messages).
- **Isolation:** user A cannot read/append/rename/delete user B's conversation
(404), and `listConversations(A)` excludes B's.
- Append: ordering oldest→newest; `updated_at` bumps; **auto-title** sets the
title from the first user message and does not overwrite a renamed title.
- HTTP: routes 403 without `ai.use`; 404 for a foreign/:id; 400 on bad body.
Frontend: no component tests (consistent with the codebase); gated by
`tsc -b` + `npm run build`. Live verification by the user/controller.
## Out of scope (Phase 2+)
- Querying system data inside chat (the original deferred Phase-2 scope).
- Conversation search, pinning, folders, sharing.
- Streaming responses.

View File

@@ -0,0 +1,49 @@
# Odin → General Assistant (Phase 2+) — Forward-Looking Notes
**Date:** 2026-06-08 · **Status:** NOT scheduled — design notes only, recorded so Phase 1 choices don't box us in.
The vision: Odin grows from "chat + invoice import" into a **general assistant** that can **query system data** (invoices, projects, attendance, vehicles…) and **take actions** (create an order, mark paid, draft a quote…). This note assesses whether the Phase-1 architecture supports that, and what to carry forward deliberately.
## Bottom line
Phase 1 sets us up well; it does **not** box us in. Two things to carry forward on purpose:
1. **Structured message storage** (content blocks, not a plain string) — cheap to plan now, costly to retrofit later.
2. **The assistant acts strictly as the authenticated user** — every tool runs through the existing permission/ownership layer.
Everything else (conversations, budget/usage, thin services) is already pointing the right way.
## What Phase 1 already gets right
- **Conversations** — multi-turn, persisted, per-user, isolated. Exactly the context substrate an agent needs.
- **Budget + usage tracking + `ai.use` gate** — the cost/access spine. Tool loops spend more, so this matters _more_, not less.
- **Thin service layer** (`{ data } | { error, status }`, ownership-checked) — ideal to wrap as tools without rewriting business logic.
- **The extract → review → save flow** — a perfect template for "propose action → human confirms → execute." The `canSave`/`invoices.create` gate added in Phase 1 is the one-tool version of per-action authorization.
## The one schema thing to note now
`ai_chat_messages.content` is a **plain string**. Tool use requires persisting **content blocks** (`text` + `tool_use` + `tool_result`), because the model must see the full tool-call history to continue. When Phase 2 lands, store the **raw content-block array** (JSON column, e.g. `content_json`/`blocks`) and keep a derived plain-text for display/search. Don't change it now — just design Phase 2 persistence as "store the blocks," treating the current string as a lossy projection.
## The core shift (Phase 2)
1. **Tool use over the existing services.** Define tools (`list_invoices`, `get_project`, `create_order`, …) whose handlers call the **same service functions the routes use**. The SDK tool-runner loops; we execute. Reuses all validation/business logic and — critically — permissions.
2. **Authorization is the hard part.** Every tool MUST run **as the user**, through `requirePermission` / service ownership checks. The assistant must never do what the user can't. It is the user's _delegate_, not a privileged actor.
3. **Read vs write.** Read/query tools may run autonomously. Write/destructive tools **propose → confirm → execute** (generalize the invoice review card into a generic "action card"). Aligns with the assistant safety rules (confirm side-effecting actions).
4. **Audit.** Every action the assistant takes should `logAudit` like a manual action, attributed to the user with a marker (e.g. `via: "odin"`), for a real trail.
5. **Cost.** Tool loops are multi-round-trip → 310× the tokens of a chat turn. The $50 cap bites faster; consider per-conversation cost display and per-action budget re-checks (the inter-file budget re-check in `extract-invoices` is the pattern).
## Data access: tools-over-services, NOT RAG
Recommendation: **function-calling against the real services**, not embeddings/RAG. The data is structured, live, and permissioned — tools give correct, current, authorized answers. RAG over a snapshot would be stale, would leak across permission boundaries, and would duplicate logic. Reserve embeddings for genuinely unstructured document search if it ever comes up.
## Other forward notes
- **Model / effort:** a true agentic assistant wants a stronger model and possibly `effort` tuning; Phase-1 Sonnet single-shot is right for now.
- **Streaming:** multi-step tool use feels slow without it — add SSE when Phase 2 lands.
- **System prompt:** describe the user's role + available tools + the confirm-before-write rule.
- **Managed Agents vs self-hosted loop:** Anthropic's Managed Agents host the loop, but for a self-hosted business app with our own services + permissions, **Claude API + tool use with a self-hosted loop** is the better fit — authorization stays in our stack.
## What NOT to do prematurely
- Don't add a tool framework, embeddings, or streaming now — Phase 1 is plain chat + a fixed-prompt extractor, and that's the right scope.
- Don't widen `ai.use` to non-admins until the per-action authorization story (point 2) is in place.

View File

@@ -0,0 +1,383 @@
# Objednávky vydané (Issued Purchase Orders) — Design Spec
**Date:** 2026-06-09
**Status:** Approved (brainstorm) → ready for implementation plan
**Author:** brainstorm session
> **Updated 2026-06-09 (as-built):** Shipped with three deviations from this
> brainstorm — **Vydané-first tabs** (Vydané | Přijaté, mirroring Invoices), a
> **shared chevron month/year filter** above the tabs that filters both lists,
> and a PDF **rebuilt on the shared invoice/order-confirmation template** rather
> than the custom layout sketched below. See the affected sections (UI placement,
> PDF export, Frontend) and the plan's "Post-plan consistency pass" note.
---
## Goal
Add a new document type — **objednávky vydané** (purchase orders you issue to a
supplier) — alongside the existing customer orders. You author the PO with line
items, it gets an auto-generated number, you track its status, and you export it
to PDF to send to the supplier. The existing orders module becomes the
**přijaté** (customer-order) side of a two-tab UI, mirroring how the Invoices
page already splits **Vydané | Přijaté**.
## Non-goals (v1)
- **No cross-module wiring.** A PO does **not** link to warehouse goods receipts
(`sklad_receipts`) or to received invoices (`faktury přijaté`). The full
procurement loop (ordered → received → invoiced) is a deliberate later phase.
- **No new supplier master.** POs reuse the existing `customers` table as generic
business partners.
- **No NAS persistence of the PO PDF** in v1 (generated on demand only).
- **No "scope sections"** (the CZ/EN rich-text blocks orders/quotations carry).
- **No AI/Odin authoring.** POs are authored by hand; Odin extraction is a
received-document concept and does not apply to documents you create.
---
## Decisions log (resolved during brainstorm)
1. **Direction mapping.** Today's `orders` (status default `prijata`, flowing
quotation → order → project → invoice) stay as the **Přijaté** side, largely
unchanged — only surfaced under a new tab. The new **Vydané** side is a
brand-new document type. _(Not a `direction` flag on the existing table.)_
2. **Supplier source.** A PO references the existing **`customers`** table (FK),
used as a generic business partner. No new supplier/vendor master.
3. **Linkage.** **Standalone document** in v1. No automatic links to warehouse or
received invoices.
4. **UI placement.** **Tabs on the Orders page**, mirroring the Invoices page.
_(As-built: **Vydané first / Přijaté second**`Vydané | Přijaté`, tab values
`vydane`/`prijate`, persisted in `?tab=` — matching the Invoices page order. A
shared **chevron month/year selector** sits above the tabs and filters BOTH
lists: Vydané by `order_date`, Přijaté by `created_at`. The pre-existing Přijaté
search bug — search term sent but ignored by the backend — was also fixed.)_
5. **Model shape.** A **dedicated `issued_orders` + `issued_order_items`** pair,
mirroring `invoices`/`invoice_items`_not_ an extension of `orders` and _not_
an upload-only record like `received_invoices`.
6. **VAT regime.** **NET base + VAT-on-top** (the _issued-invoice_ convention),
the **opposite** of `received_invoices` (which are gross/VAT-inclusive). Totals
use the same per-line-rounded math as `computeInvoiceTotals`.
7. **Numbering.** A **dedicated** `number_sequences` type (`"issued_order"`),
separate from the `"shared"` orders/projects pool and from `"invoice"`. Number
assigned **at creation** (even for a `draft`), with release-on-delete.
8. **Permissions.** **Reuse the existing `orders.*` set** (`view`/`create`/`edit`/
`delete`) plus `orders.export` for the PDF — mirrors invoices sharing one
permission set across both tabs; **no permission migration needed**.
---
## Architecture
A self-contained vertical slice following the codebase's established document
pattern (the same shape `invoices` uses):
```
prisma/schema.prisma → models issued_orders, issued_order_items + enum
src/schemas/issued-orders.schema.ts
src/services/issued-orders.service.ts
src/services/numbering.service.ts (extend: issued-order number fns)
src/routes/admin/issued-orders.ts (register in routes/admin/index.ts)
src/routes/admin/issued-orders-pdf.ts (PDF route, modeled on invoices-pdf.ts)
src/admin/pages/Orders.tsx (add Přijaté | Vydané tabs)
src/admin/pages/IssuedOrderDetail.tsx (create/edit form + PDF export)
src/admin/lib/queries/issued-orders.ts
src/admin/lib/entityTypeLabels.ts (add "issued_order" Czech label)
src/admin/AdminApp.tsx (lazy routes for detail/new)
src/__tests__/issued-orders.test.ts
```
**Tech stack:** Fastify 5, Prisma 7 (MySQL), Zod 4, React 19 + MUI v7, React
Query, Puppeteer (PDF), Vitest. All new code follows the audited conventions
(success/error helpers, `parseBody`/`parseId`, shared Zod coercers, broad query
invalidation, deterministic ordering with an `id` tiebreak, locked
read-modify-write for numbering).
---
## Data model
### `issued_orders` (parallels `invoices`)
| Field | Type | Notes |
| ---------------- | -------------------------------------------------- | ------------------------------------------- |
| `id` | Int PK autoincrement | |
| `po_number` | VARCHAR(50) unique | auto-assigned (see Numbering) |
| `customer_id` | Int FK → `customers` (onDelete Restrict, nullable) | the **supplier/partner** being ordered from |
| `status` | enum `issued_orders_status` | default `draft` |
| `currency` | VARCHAR(10) default `"CZK"` | |
| `vat_rate` | Decimal(5,2) default `21.00` | doc-level default rate |
| `apply_vat` | Boolean default `true` | |
| `exchange_rate` | Decimal(12,4) nullable | foreign-currency, like orders |
| `order_date` | Date | date the PO is issued |
| `delivery_date` | Date nullable | requested delivery / required-by |
| `language` | VARCHAR(5) default `"cs"` | drives PDF language |
| `delivery_terms` | VARCHAR(500) nullable | optional, shown on PDF |
| `payment_terms` | VARCHAR(500) nullable | optional, shown on PDF |
| `issued_by` | VARCHAR(255) nullable | signatory name |
| `notes` | Text nullable | rich text, **rendered on PDF** (sanitized) |
| `internal_notes` | Text nullable | private, **not** on PDF |
| `created_at` | DateTime default now | |
| `modified_at` | DateTime @updatedAt | |
**Relations:** `issued_order_items[]`, `customers?`.
**Indexes:** `(customer_id)`, `(status, order_date)`.
### `issued_order_items` (parallels `invoice_items`)
| Field | Type | Notes |
| ------------------ | ------------------------------------------- | ------------------------------- |
| `id` | Int PK | |
| `issued_order_id` | Int FK → `issued_orders` (onDelete Cascade) | |
| `description` | VARCHAR(500) | main line text |
| `item_description` | Text nullable | optional long detail |
| `quantity` | Decimal(12,3) default `1.000` | |
| `unit` | VARCHAR(20) nullable | e.g. ks, hod, kg |
| `unit_price` | Decimal(12,2) default `0.00` | **NET** unit price |
| `vat_rate` | Decimal(5,2) default `21.00` | per-line; overrides doc default |
| `position` | Int default `0` | ordering |
**Index:** `(issued_order_id)`.
### `issued_orders_status` enum
`draft`, `sent`, `confirmed`, `completed`, `cancelled`
(Czech labels: _Koncept · Odeslaná · Potvrzená · Dokončená · Stornovaná_.)
---
## Numbering
A dedicated sequence, exactly like invoices:
- New `number_sequences` type string `"issued_order"` (unique on `(type, year)`,
as the table already enforces). Separate from `"shared"` and `"invoice"`.
- New `company_settings` columns:
- `issued_order_number_pattern` VARCHAR default `"{YY}{CODE}{NNNN}"`
- `issued_order_type_code` VARCHAR default `"72"` (orders use `71`, invoices
`81`; `72` is free and configurable).
- New functions in `src/services/numbering.service.ts`, reusing the existing
`applyPattern` + `getNextSequence` (`SELECT … FOR UPDATE` lock, P2002
first-of-year retry):
- `generateIssuedOrderNumber(tx)` — atomically consumes the next number.
- `previewIssuedOrderNumber()` — non-consuming peek (for the create form).
- `releaseIssuedOrderNumber(year, number)` — decrement only if the deleted PO
held the current highest number that year (gap prevention), mirroring
`releaseInvoiceNumber`.
- The number is **assigned at creation**, including for a `draft`. Deleting a PO
releases the number per the rule above.
---
## Backend API
### Schema — `src/schemas/issued-orders.schema.ts`
`CreateIssuedOrderSchema`, `UpdateIssuedOrderSchema`, `IssuedOrderItemSchema`,
built **only** from the shared coercers in `src/schemas/common.ts`:
- `customer_id``nullableIntIdFromForm`
- `quantity``positiveNumberFromForm`; `unit_price``nonNegativeNumberFromForm`
- `vat_rate``numberInRange(0, 100)`
- `order_date` / `delivery_date``isoDateString` (lenient)
- text fields → `z.string().max(...)`; optional email-like none here
- Czech user-facing messages; English identifiers.
**No** raw `z.union([z.number(), z.string()]).transform(Number)` — that idiom is
banned (silent `NaN`).
### Service — `src/services/issued-orders.service.ts`
Plain exported async functions returning `{ data }` or `{ error, status }`:
- `listIssuedOrders(params)` — paginated; filters `status`, `customer_id`,
`search` (po_number / supplier name); whitelist sort
(`id`/`po_number`/`status`/`order_date`/`created_at`) with a deterministic
**`{ id }` tiebreak**; each row enriched via `computeIssuedOrderTotals`.
- `getIssuedOrder(id)` — full detail with items, supplier name, and
`valid_transitions`.
- `createIssuedOrder(body)` — inside `prisma.$transaction`:
`generateIssuedOrderNumber(tx)` → insert header → batch-insert items.
- `updateIssuedOrder(id, body)` — validates the status transition against
`VALID_TRANSITIONS`; replaces items (delete-all + recreate) only while the doc
is editable (`draft`/`sent`); blocks any `po_number` change.
- `deleteIssuedOrder(id)` — delete (items cascade) then
`releaseIssuedOrderNumber(year, po_number)`.
- `computeIssuedOrderTotals(items, applyVat, docRate)` — **NET + VAT-on-top,
rounded per line before accumulation** (same shape as `computeInvoiceTotals`):
`base = qty × unit_price`; `lineVat = round(base × rate/100)` when
`applyVat`; `subtotal = Σ base`, `vat = Σ lineVat`, `total = subtotal + vat`.
Falls back line `vat_rate` → doc `vat_rate` → 21.
- `getNextIssuedOrderNumber()` — proxies `previewIssuedOrderNumber()`.
`VALID_TRANSITIONS`:
```
draft → [sent, cancelled]
sent → [confirmed, cancelled]
confirmed → [completed, cancelled]
completed → [] // terminal
cancelled → [] // terminal
```
### Routes — `src/routes/admin/issued-orders.ts`
Registered in `src/routes/admin/index.ts`. Every handler uses `success()`/
`error()`, `parseBody(Schema, …)`, `parseId((request.params as any).id, reply)`,
and `logAudit` with a **new** entity type `"issued_order"` (create/update/delete,
with `oldValues`/`newValues`). Adding this entity type requires also registering
its Czech label in `src/admin/lib/entityTypeLabels.ts` (keyed to the server's
`EntityType` value), per the single-source-of-truth convention.
| Method · Path | Guard | Purpose |
| ------------------------------------------ | --------------- | -------------------------- |
| GET `/api/admin/issued-orders` | `orders.view` | list (paginated, filtered) |
| GET `/api/admin/issued-orders/next-number` | `orders.create` | preview next PO number |
| GET `/api/admin/issued-orders/:id` | `orders.view` | detail |
| POST `/api/admin/issued-orders` | `orders.create` | create |
| PUT `/api/admin/issued-orders/:id` | `orders.edit` | update |
| DELETE `/api/admin/issued-orders/:id` | `orders.delete` | delete |
| GET `/api/admin/issued-orders/:id/pdf` | `orders.export` | render + stream PDF |
---
## PDF export
> **As-built (2026-06-09):** rather than the custom/compact layout this section
> originally sketched, the issued-order PDF was **rebuilt on the shared
> invoice / order-confirmation red-accent (#de3a3a) template** (`src/routes/admin/issued-orders-pdf.ts`),
> titled **OBJEDNÁVKA**, for visual consistency with the rest of the document
> suite. Because a PO is a buying document, the direction is **flipped vs an
> invoice**: the selected **customer record renders as "Dodavatel" (supplier)**
> and **your company as "Odběratel" (buyer)**. It is generated **on demand** and
> streamed as `application/pdf` — **no NAS persistence**. Sanitization,
> on-demand/no-NAS behavior, and the NET+VAT-on-top totals below are unchanged.
New route + template `src/routes/admin/issued-orders-pdf.ts`, reusing
`htmlToPdf` (Puppeteer) from `src/utils/pdf.ts`:
- **Dodavatel (supplier) block** = the selected `customers` record (name,
IČO/`company_id`, DIČ/`vat_id`, address). **Odběratel (buyer) block** = your
company (`company_settings`: name, IČO, DIČ, address, logo as base64).
- Body: items table → VAT recap grouped by rate → **NET total + VAT + gross**.
- `cs` / `en` strings selected by `language`. Czech date/currency via the
existing formatters.
- `notes` passed through the **same** `cleanQuillHtml` + DOMPurify sanitization
used by invoices/orders before going into the template (mandatory for any new
rich-text-on-PDF field).
- **v1 behavior:** generate on demand and stream `application/pdf` with
`Content-Disposition` filename `{po_number}.pdf`. **No NAS write.** (A future
`?save=1 → Objednávky vydané/YYYY/MM/{po_number}.pdf` add-on is a small,
isolated follow-up.)
---
## Frontend
- **`src/admin/pages/Orders.tsx` — add `Vydané | Přijaté` tabs** using the same
MUI Tabs pattern as `Invoices.tsx`. The current orders list moves **verbatim**
into the **Přijaté** tab — every hook, mutation, `invalidate` array, validation,
and permission check preserved unchanged; only presentation is reorganized. The
**Vydané** tab renders the new list.
_(As-built: `Orders.tsx` became a **parent shell** that owns the `PageHeader` +
the shared month/year selector + the tabs; `IssuedOrders.tsx` (Vydané) and
`OrdersReceived.tsx` (Přijaté) are **content-only children** receiving `month`/`year`
props. `OrdersReceived`'s create modal is **hoisted** to the shell via
`createOpen`/`setCreateOpen`, the ReceivedInvoices pattern. **No KPI/stat cards**
on the Orders landing — deliberate, since orders have no paid/overdue semantics.)_
- **Vydané list** — `DataTable` columns: `po_number`, supplier (customer name),
status chip (color per status), `order_date`, total (currency), actions (open,
**Export PDF**, delete). `FilterBar` with status + customer `Select`s and search
at standard widths; pagination + sortable headers; an "Vytvořit objednávku
vydanou" button gated on `orders.create`.
- **`src/admin/pages/IssuedOrderDetail.tsx`** (lazy routes `/orders/issued/new`
and `/orders/issued/:id` in `AdminApp.tsx`): supplier `Select`, `order_date` /
`delivery_date` `DateField`s, currency / VAT rate / `apply_vat`, a **line-items
table** (add/remove rows + `@dnd-kit` reorder, like `InvoiceDetail`), `RichEditor`
notes, internal notes, `delivery_terms` / `payment_terms`, `issued_by`, **live
totals** (net / VAT / gross), status-transition buttons, and an **Export PDF**
button gated on `orders.export`. Top-level sections wrapped in `<PageEnter>`;
imports come from the `ui/` kit.
- **Queries** `src/admin/lib/queries/issued-orders.ts``issuedOrderListOptions`,
`issuedOrderDetailOptions`, `issuedOrderNextNumberOptions`. All mutations
invalidate the **broad `["issued-orders"]`** domain key.
- **Rules of Hooks:** all hooks run before any early permission `return`
(`<Forbidden/>`/`<Navigate/>` go after every hook) — `npm run lint` enforces
`react-hooks/rules-of-hooks` as an error.
- Sidebar nav unchanged — one "Objednávky" item; the split lives in the tabs.
---
## Permissions
Reuse the existing `orders.*` set for the Vydané tab:
- `orders.view` — list + detail + PDF view
- `orders.create` — create + next-number preview
- `orders.edit` — update
- `orders.delete` — delete
- `orders.export` — PDF export (already seeded)
This mirrors invoices (issued + received share one `invoices.*` set) and needs
**no permission migration**. _(Future option, out of scope for v1: a dedicated
`purchase-orders._` set via a seed + migration if purchasing must be locked down
separately from sales orders.)\*
---
## Status lifecycle
`draft → sent → confirmed → completed`, plus any non-terminal → `cancelled`.
- **Editable** (header + items): `draft`, `sent`.
- **Read-only except a permitted status change**: `confirmed`, `completed`,
`cancelled`.
- Enforced server-side by the `VALID_TRANSITIONS` guard in the service; the detail
page only renders buttons for valid next states.
---
## Testing
`src/__tests__/issued-orders.test.ts`, against the real **`app_test`** DB via
`buildApp()` (no Prisma mocks):
- **Numbering:** sequential allocation; release-only-if-highest on delete; the
generated number matches the configured pattern.
- **Create + totals:** create with multiple items, assert NET+VAT-on-top totals
with **per-line rounding pinned to exact 2-decimal values**; mixed per-line VAT
rates; `apply_vat=false` zeroes VAT but preserves the NET subtotal.
- **Status transitions:** every valid transition succeeds; invalid transitions
return the proper error; items become read-only after `confirmed`.
- **Permissions:** each endpoint rejects a caller lacking the required `orders.*`
permission (not bare auth).
- **PDF:** `GET /:id/pdf` returns `200` with `content-type: application/pdf`.
---
## Migration
A single `prisma migrate dev --name issued_orders`:
1. Creates `issued_orders` and `issued_order_items` tables + the
`issued_orders_status` enum.
2. Adds `issued_order_number_pattern` and `issued_order_type_code` columns to
`company_settings` with SQL defaults (`"{YY}{CODE}{NNNN}"`, `"72"`).
Per project rules: **ask the user to stop the dev server first**; commit both
`schema.prisma` and the generated migration folder; run `prisma generate`. No raw
SQL on prod — the column defaults ship inside the migration's `migration.sql`.
**Quality gates before "done":** `npx tsc -b --noEmit`, `npm run build`,
`npx vitest run`, `npm run lint` (0 errors).
---
## Future phases (explicitly out of scope for v1)
- **Procurement links:** PO ↔ warehouse goods receipt (`sklad_receipts`) and
PO ↔ received invoice matching (ordered → received → invoiced rollups), with
Odin pre-filling a received invoice from a PO.
- **NAS persistence** of the generated PO PDF (`?save=1`).
- **Dedicated `purchase-orders.*` permission set.**
- **Scope sections** (CZ/EN rich-text blocks) if POs ever need narrative content.

78
eslint.config.mjs Normal file
View File

@@ -0,0 +1,78 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import prettier from "eslint-config-prettier";
export default tseslint.config(
{
ignores: [
"dist/**",
"dist-client/**",
"node_modules/**",
"prisma/migrations/**",
"src/admin/bones/**",
"*.config.js",
"*.config.mjs",
"*.config.ts",
".claude/**",
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
// Server + shared code
files: ["src/**/*.{ts,tsx}", "scripts/**/*.ts", "prisma/seed.ts"],
languageOptions: {
globals: { ...globals.node, ...globals.browser },
},
rules: {
// TS already resolves identifiers — `no-undef` only false-positives on
// global types/ambient names in .ts files.
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-require-imports": "warn",
"no-empty": ["warn", { allowEmptyCatch: false }],
// Intentional control-character regexes (NUL/path-traversal guards,
// combining-marks stripping) — not a defect here.
"no-control-regex": "off",
// Stylistic / advisory — surface as warnings, don't fail the lint gate.
"no-useless-escape": "warn",
"no-useless-assignment": "warn",
"preserve-caught-error": "warn",
"prefer-const": "warn",
},
},
{
// Frontend React code
files: ["src/admin/**/*.{ts,tsx}", "src/**/*.tsx"],
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
},
},
{
// Plain Node helper scripts (.js/.mjs/.cjs in scripts/)
files: ["scripts/**/*.{js,mjs,cjs}"],
languageOptions: { globals: { ...globals.node } },
rules: { "no-undef": "off" },
},
{
// Tests
files: ["src/__tests__/**/*.ts"],
languageOptions: { globals: { ...globals.node } },
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
prettier,
);

View File

@@ -13,7 +13,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />

4571
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "1.3.2",
"version": "2.4.1",
"description": "",
"main": "dist/server.js",
"scripts": {
@@ -17,65 +17,86 @@
"db:push": "prisma db push",
"db:studio": "prisma studio",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"seed": "tsx prisma/seed.ts",
"typecheck": "tsc -b --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"@prisma/client": "^6.19.2",
"@mui/material": "^7.3.11",
"@mui/x-date-pickers": "^8.29.0",
"@prisma/adapter-mariadb": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tanstack/react-query": "^5.100.5",
"bcryptjs": "^3.0.3",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
"dotenv": "^17.3.1",
"fastify": "^5.8.2",
"file-type": "^16.5.4",
"file-type": "^22.0.1",
"framer-motion": "^12.38.0",
"hi-base32": "^0.5.1",
"jsdom": "^29.0.2",
"jsonwebtoken": "^9.0.3",
"leaflet": "^1.9.4",
"mariadb": "^3.5.2",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.2",
"otpauth": "^9.5.0",
"prisma": "^6.19.2",
"prisma": "^7.8.0",
"puppeteer": "^24.40.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-datepicker": "^9.1.0",
"react-dom": "^18.3.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-quill-new": "^3.8.3",
"react-router-dom": "^6.30.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/dompurify": "^3.0.5",
"@eslint/js": "^10.0.1",
"@types/jsdom": "^28.0.1",
"@types/jsonwebtoken": "^9.0.10",
"@types/leaflet": "^1.9.21",
"@types/mysql": "^2.15.27",
"@types/node": "^25.5.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.61.0",
"vite": "^8.0.0",
"vitest": "^4.1.0"
},
"overrides": {
"@hono/node-server": "^1.19.13"
}
}

16
prisma.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
// Prisma 7 moved the datasource connection URL and seed config out of
// schema.prisma / package.json into this file. `import "dotenv/config"` is
// required because Prisma 7 no longer auto-loads .env. The runtime database
// connection itself is made via the driver adapter in src/config/database.ts.
export default defineConfig({
schema: "prisma/schema.prisma",
datasource: {
url: env("DATABASE_URL"),
},
migrations: {
seed: "tsx prisma/seed.ts",
},
});

View File

@@ -1,2 +0,0 @@
-- Add billing_text column to invoices table
ALTER TABLE `invoices` ADD COLUMN `billing_text` VARCHAR(500) NULL AFTER `issued_by`;

View File

@@ -1,3 +0,0 @@
-- Add lock fields to quotations table for pessimistic locking
ALTER TABLE `quotations` ADD COLUMN `locked_by` INT NULL AFTER `scope_description`;
ALTER TABLE `quotations` ADD COLUMN `locked_at` DATETIME NULL AFTER `locked_by`;

View File

@@ -1,9 +0,0 @@
CREATE TABLE `invoice_alert_log` (
`id` INT NOT NULL AUTO_INCREMENT,
`invoice_type` VARCHAR(20) NOT NULL,
`invoice_id` INT NOT NULL,
`alert_type` VARCHAR(20) NOT NULL,
`sent_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `invoice_type_invoice_id_alert_type` (`invoice_type`, `invoice_id`, `alert_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -1 +0,0 @@
ALTER TABLE `invoices` ADD COLUMN `language` VARCHAR(5) DEFAULT 'cs' AFTER `billing_text`;

View File

@@ -1,2 +0,0 @@
-- Add file_path column for NAS storage of received invoice files
ALTER TABLE `received_invoices` ADD COLUMN `file_path` VARCHAR(500) NULL AFTER `file_data`;

View File

@@ -1,4 +0,0 @@
-- Remove file_data (BLOB) and file_path columns — files are now stored on NAS
-- Path is derived from year, month, and file_name
ALTER TABLE `received_invoices` DROP COLUMN `file_data`;
ALTER TABLE `received_invoices` DROP COLUMN `file_path`;

View File

@@ -1 +0,0 @@
ALTER TABLE `company_settings` ADD COLUMN `logo_data_dark` MEDIUMBLOB NULL AFTER `logo_data`;

View File

@@ -1,4 +0,0 @@
ALTER TABLE `company_settings`
ADD COLUMN `offer_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `order_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `invoice_number_pattern` VARCHAR(100) NULL;

View File

@@ -1,3 +0,0 @@
ALTER TABLE `company_settings`
ADD COLUMN `smtp_from` VARCHAR(255) NULL,
ADD COLUMN `smtp_from_name` VARCHAR(255) NULL;

View File

@@ -1,13 +0,0 @@
-- System settings columns on company_settings
ALTER TABLE `company_settings`
ADD COLUMN `break_threshold_hours` DECIMAL(4,2) DEFAULT 6,
ADD COLUMN `break_duration_short` INT DEFAULT 15,
ADD COLUMN `break_duration_long` INT DEFAULT 30,
ADD COLUMN `clock_rounding_minutes` INT DEFAULT 15,
ADD COLUMN `invoice_alert_email` VARCHAR(255) NULL,
ADD COLUMN `leave_notify_email` VARCHAR(255) NULL,
ADD COLUMN `max_login_attempts` INT DEFAULT 5,
ADD COLUMN `lockout_minutes` INT DEFAULT 15,
ADD COLUMN `max_requests_per_minute` INT DEFAULT 300,
ADD COLUMN `available_vat_rates` LONGTEXT NULL,
ADD COLUMN `available_currencies` LONGTEXT NULL;

View File

@@ -1,18 +0,0 @@
-- Create new unified permission
INSERT INTO permissions (name, display_name, description, module)
VALUES ('settings.manage', 'Správa nastavení', 'Správa všech nastavení systému', 'settings')
ON DUPLICATE KEY UPDATE display_name = VALUES(display_name);
-- Grant to all roles that had any of the old 3
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT DISTINCT rp.role_id, (SELECT id FROM permissions WHERE name = 'settings.manage')
FROM role_permissions rp
JOIN permissions p ON p.id = rp.permission_id
WHERE p.name IN ('offers.settings', 'settings.roles', 'settings.security');
-- Clean up old role_permissions
DELETE FROM role_permissions
WHERE permission_id IN (SELECT id FROM permissions WHERE name IN ('offers.settings', 'settings.roles', 'settings.security'));
-- Remove old permissions
DELETE FROM permissions WHERE name IN ('offers.settings', 'settings.roles', 'settings.security');

View File

@@ -98,6 +98,7 @@ CREATE TABLE `company_settings` (
`vat_id` VARCHAR(50) NULL,
`custom_fields` LONGTEXT NULL,
`logo_data` LONGBLOB NULL,
`logo_data_dark` MEDIUMBLOB NULL,
`quotation_prefix` VARCHAR(20) NULL,
`default_currency` VARCHAR(10) NULL DEFAULT 'CZK',
`default_vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
@@ -108,7 +109,24 @@ CREATE TABLE `company_settings` (
`order_type_code` VARCHAR(10) NULL,
`invoice_type_code` VARCHAR(10) NULL,
`require_2fa` BOOLEAN NOT NULL DEFAULT false,
`break_threshold_hours` DECIMAL(4, 2) NULL DEFAULT 6.00,
`break_duration_short` INTEGER NULL DEFAULT 15,
`break_duration_long` INTEGER NULL DEFAULT 30,
`clock_rounding_minutes` INTEGER NULL DEFAULT 15,
`invoice_alert_email` VARCHAR(255) NULL,
`leave_notify_email` VARCHAR(255) NULL,
`max_login_attempts` INTEGER NULL DEFAULT 5,
`lockout_minutes` INTEGER NULL DEFAULT 15,
`max_requests_per_minute` INTEGER NULL DEFAULT 300,
`available_vat_rates` LONGTEXT NULL,
`available_currencies` LONGTEXT NULL,
`smtp_from` VARCHAR(255) NULL,
`smtp_from_name` VARCHAR(255) NULL,
`offer_number_pattern` VARCHAR(100) NULL,
`order_number_pattern` VARCHAR(100) NULL,
`invoice_number_pattern` VARCHAR(100) NULL,
UNIQUE INDEX `company_settings_uuid_key`(`uuid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -167,14 +185,18 @@ CREATE TABLE `invoices` (
`tax_date` DATE NULL,
`paid_date` DATE NULL,
`issued_by` VARCHAR(255) NULL,
`billing_text` VARCHAR(500) NULL,
`language` VARCHAR(5) NULL DEFAULT 'cs',
`notes` TEXT NULL,
`internal_notes` TEXT NULL,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `idx_invoices_number_unique`(`invoice_number`),
INDEX `customer_id`(`customer_id`),
INDEX `idx_invoices_due_date`(`due_date`),
INDEX `idx_invoices_status_issue`(`status`, `issue_date`),
INDEX `idx_invoices_status_due`(`status`, `due_date`),
INDEX `order_id`(`order_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -239,6 +261,7 @@ CREATE TABLE `number_sequences` (
`year` INTEGER NULL,
`last_number` INTEGER NULL DEFAULT 0,
UNIQUE INDEX `idx_number_sequences_type_year`(`type`, `year`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -294,6 +317,7 @@ CREATE TABLE `orders` (
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `idx_orders_number_unique`(`order_number`),
INDEX `customer_id`(`customer_id`),
INDEX `quotation_id`(`quotation_id`),
PRIMARY KEY (`id`)
@@ -341,6 +365,7 @@ CREATE TABLE `projects` (
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `projects_project_number_key`(`project_number`),
INDEX `customer_id`(`customer_id`),
INDEX `fk_projects_responsible_user`(`responsible_user_id`),
INDEX `order_id`(`order_id`),
@@ -386,10 +411,13 @@ CREATE TABLE `quotations` (
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
`scope_title` VARCHAR(500) NULL,
`scope_description` TEXT NULL,
`locked_by` INTEGER NULL,
`locked_at` DATETIME(0) NULL,
`uuid` VARCHAR(36) NULL,
`modified_at` DATETIME(0) NULL,
`sync_version` INTEGER NULL DEFAULT 0,
UNIQUE INDEX `quotations_quotation_number_key`(`quotation_number`),
INDEX `customer_id`(`customer_id`),
INDEX `idx_quotations_number`(`quotation_number`),
PRIMARY KEY (`id`)
@@ -411,7 +439,6 @@ CREATE TABLE `received_invoices` (
`due_date` DATE NULL,
`paid_date` DATE NULL,
`status` ENUM('unpaid', 'paid') NOT NULL DEFAULT 'unpaid',
`file_data` MEDIUMBLOB NULL,
`file_name` VARCHAR(255) NULL,
`file_mime` VARCHAR(100) NULL,
`file_size` INTEGER UNSIGNED NULL,
@@ -572,6 +599,7 @@ CREATE TABLE `users` (
`totp_secret` VARCHAR(255) NULL,
`totp_enabled` BOOLEAN NOT NULL DEFAULT false,
`totp_backup_codes` TEXT NULL,
`totp_last_used_counter` INTEGER NULL,
UNIQUE INDEX `username`(`username`),
UNIQUE INDEX `email`(`email`),
@@ -597,12 +625,28 @@ CREATE TABLE `vehicles` (
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `invoice_alert_log` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`invoice_type` VARCHAR(20) NOT NULL,
`invoice_id` INTEGER NOT NULL,
`alert_type` VARCHAR(20) NOT NULL,
`sent_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`created_at` DATETIME(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
UNIQUE INDEX `invoice_type_invoice_id_alert_type`(`invoice_type`, `invoice_id`, `alert_type`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `attendance` ADD CONSTRAINT `attendance_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `attendance_project_logs` ADD CONSTRAINT `attendance_project_logs_attendance_id_fkey` FOREIGN KEY (`attendance_id`) REFERENCES `attendance`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `attendance_project_logs` ADD CONSTRAINT `attendance_project_logs_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `invoice_items` ADD CONSTRAINT `invoice_items_ibfk_1` FOREIGN KEY (`invoice_id`) REFERENCES `invoices`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
@@ -674,4 +718,3 @@ ALTER TABLE `trips` ADD CONSTRAINT `trips_vehicle_fk` FOREIGN KEY (`vehicle_id`)
-- AddForeignKey
ALTER TABLE `users` ADD CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;

View File

@@ -0,0 +1,4 @@
-- Insert the settings.system permission (also available via seed)
INSERT INTO `permissions` (`name`, `display_name`, `module`, `description`)
VALUES ('settings.system', 'Systémová nastavení', 'settings', 'Spravovat systémová nastavení, 2FA, e-maily, limity')
ON DUPLICATE KEY UPDATE `name` = `name`;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `quotations` DROP COLUMN `exchange_rate`;
ALTER TABLE `quotations` DROP COLUMN `exchange_rate_date`;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `invoice_items` ADD COLUMN `item_description` TEXT NULL;

View File

@@ -0,0 +1,14 @@
-- AlterTable: quotation_items
ALTER TABLE `quotation_items` DROP COLUMN `uuid`;
ALTER TABLE `quotation_items` DROP COLUMN `is_deleted`;
ALTER TABLE `quotation_items` DROP COLUMN `sync_version`;
-- AlterTable: quotations
ALTER TABLE `quotations` DROP COLUMN `uuid`;
ALTER TABLE `quotations` DROP COLUMN `sync_version`;
-- AlterTable: scope_sections
ALTER TABLE `scope_sections` DROP COLUMN `content_editor_height`;
ALTER TABLE `scope_sections` DROP COLUMN `uuid`;
ALTER TABLE `scope_sections` DROP COLUMN `is_deleted`;
ALTER TABLE `scope_sections` DROP COLUMN `sync_version`;

View File

@@ -0,0 +1,277 @@
-- CreateTable
CREATE TABLE `sklad_categories` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL,
`description` TEXT NULL,
`sort_order` INTEGER NOT NULL DEFAULT 0,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_suppliers` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(255) NOT NULL,
`ico` VARCHAR(20) NULL,
`dic` VARCHAR(20) NULL,
`contact_person` VARCHAR(255) NULL,
`email` VARCHAR(255) NULL,
`phone` VARCHAR(50) NULL,
`address` TEXT NULL,
`notes` TEXT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_locations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`code` VARCHAR(20) NOT NULL,
`name` VARCHAR(100) NOT NULL,
`description` TEXT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `sklad_locations_code_key`(`code`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_items` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`item_number` VARCHAR(50) NULL,
`name` VARCHAR(255) NOT NULL,
`description` TEXT NULL,
`category_id` INTEGER NULL,
`unit` VARCHAR(20) NOT NULL,
`min_quantity` DECIMAL(12, 3) NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true,
`notes` TEXT NULL,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `sklad_items_item_number_key`(`item_number`),
INDEX `sklad_items_category_id`(`category_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_batches` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`item_id` INTEGER NOT NULL,
`receipt_line_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL,
`original_qty` DECIMAL(12, 3) NOT NULL,
`unit_price` DECIMAL(12, 2) NOT NULL,
`received_at` DATETIME(0) NULL,
`is_consumed` BOOLEAN NOT NULL DEFAULT false,
UNIQUE INDEX `sklad_batches_receipt_line_id_key`(`receipt_line_id`),
INDEX `sklad_batches_fifo`(`item_id`, `is_consumed`, `received_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_item_locations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`item_id` INTEGER NOT NULL,
`location_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL DEFAULT 0,
UNIQUE INDEX `sklad_item_locations_unique`(`item_id`, `location_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_receipts` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`receipt_number` VARCHAR(50) NULL,
`supplier_id` INTEGER NULL,
`delivery_note_number` VARCHAR(100) NULL,
`delivery_note_date` DATETIME(0) NULL,
`received_by` INTEGER NULL,
`notes` TEXT NULL,
`status` ENUM('DRAFT', 'CONFIRMED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_receipt_lines` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`receipt_id` INTEGER NOT NULL,
`item_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL,
`unit_price` DECIMAL(12, 2) NOT NULL,
`location_id` INTEGER NULL,
`notes` VARCHAR(255) NULL,
INDEX `sklad_receipt_lines_receipt_id`(`receipt_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_receipt_attachments` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`receipt_id` INTEGER NOT NULL,
`file_name` VARCHAR(255) NOT NULL,
`file_mime` VARCHAR(100) NOT NULL,
`file_size` INTEGER NOT NULL,
`file_path` VARCHAR(500) NOT NULL,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_issues` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`issue_number` VARCHAR(50) NULL,
`project_id` INTEGER NOT NULL,
`issued_by` INTEGER NULL,
`notes` TEXT NULL,
`status` ENUM('DRAFT', 'CONFIRMED', 'CANCELLED') NOT NULL DEFAULT 'DRAFT',
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_issue_lines` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`issue_id` INTEGER NOT NULL,
`item_id` INTEGER NOT NULL,
`batch_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL,
`location_id` INTEGER NULL,
`reservation_id` INTEGER NULL,
`notes` VARCHAR(255) NULL,
INDEX `sklad_issue_lines_issue_id`(`issue_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_reservations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`item_id` INTEGER NOT NULL,
`project_id` INTEGER NOT NULL,
`quantity` DECIMAL(12, 3) NOT NULL,
`remaining_qty` DECIMAL(12, 3) NOT NULL,
`reserved_by` INTEGER NULL,
`notes` TEXT NULL,
`status` ENUM('ACTIVE', 'FULFILLED', 'CANCELLED') NOT NULL DEFAULT 'ACTIVE',
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
INDEX `sklad_reservations_item_status`(`item_id`, `status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_inventory_sessions` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`session_number` VARCHAR(50) NULL,
`notes` TEXT NULL,
`status` ENUM('DRAFT', 'CONFIRMED') NOT NULL DEFAULT 'DRAFT',
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `sklad_inventory_lines` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`session_id` INTEGER NOT NULL,
`item_id` INTEGER NOT NULL,
`location_id` INTEGER NULL,
`system_qty` DECIMAL(12, 3) NOT NULL,
`actual_qty` DECIMAL(12, 3) NOT NULL,
`difference` DECIMAL(12, 3) NOT NULL,
`notes` VARCHAR(255) NULL,
INDEX `sklad_inventory_lines_session_id`(`session_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `sklad_items` ADD CONSTRAINT `sklad_items_category_id_fkey` FOREIGN KEY (`category_id`) REFERENCES `sklad_categories`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_batches` ADD CONSTRAINT `sklad_batches_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_batches` ADD CONSTRAINT `sklad_batches_receipt_line_id_fkey` FOREIGN KEY (`receipt_line_id`) REFERENCES `sklad_receipt_lines`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_item_locations` ADD CONSTRAINT `sklad_item_locations_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_item_locations` ADD CONSTRAINT `sklad_item_locations_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipts` ADD CONSTRAINT `sklad_receipts_supplier_id_fkey` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipts` ADD CONSTRAINT `sklad_receipts_received_by_fkey` FOREIGN KEY (`received_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_attachments` ADD CONSTRAINT `sklad_receipt_attachments_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issues` ADD CONSTRAINT `sklad_issues_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issues` ADD CONSTRAINT `sklad_issues_issued_by_fkey` FOREIGN KEY (`issued_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_issue_id_fkey` FOREIGN KEY (`issue_id`) REFERENCES `sklad_issues`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_batch_id_fkey` FOREIGN KEY (`batch_id`) REFERENCES `sklad_batches`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_reservation_id_fkey` FOREIGN KEY (`reservation_id`) REFERENCES `sklad_reservations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_reservations` ADD CONSTRAINT `sklad_reservations_reserved_by_fkey` FOREIGN KEY (`reserved_by`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `sklad_inventory_sessions`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_item_id_fkey` FOREIGN KEY (`item_id`) REFERENCES `sklad_items`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,17 @@
-- AlterTable
ALTER TABLE `company_settings` ADD COLUMN `warehouse_inventory_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `warehouse_inventory_prefix` VARCHAR(20) NULL,
ADD COLUMN `warehouse_issue_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `warehouse_issue_prefix` VARCHAR(20) NULL,
ADD COLUMN `warehouse_receipt_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `warehouse_receipt_prefix` VARCHAR(20) NULL;
-- Set backward-compatible defaults for existing installations
UPDATE `company_settings`
SET
`warehouse_receipt_prefix` = 'PRI',
`warehouse_receipt_number_pattern` = '{PREFIX}-{YYYY}-{NNN}',
`warehouse_issue_prefix` = 'VYD',
`warehouse_issue_number_pattern` = '{PREFIX}-{YYYY}-{NNN}',
`warehouse_inventory_prefix` = 'INV',
`warehouse_inventory_number_pattern` = '{PREFIX}-{YYYY}-{NNN}';

View File

@@ -0,0 +1,29 @@
-- Add warehouse module permissions and assign them to the admin role.
-- These rows used to be created by `prisma/seed.ts`, which is dev-only and
-- must not be run against production (per CLAUDE.md). This migration is
-- idempotent: re-running it is a no-op.
--
-- The 4 permissions match `prisma/seed.ts` lines 284-308 exactly.
-- 1. Insert the 4 warehouse permissions (INSERT IGNORE skips on duplicate
-- name, so re-running the migration is safe).
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
('warehouse.view', 'Zobrazit sklad', 'warehouse', 'Prohlížet stav skladu, položky, reporty a historii pohybů', NOW()),
('warehouse.operate', 'Příjem a výdej', 'warehouse', 'Vytvářet a potvrzovat příjmy, výdeje a rezervace', NOW()),
('warehouse.manage', 'Správa skladu', 'warehouse', 'Spravovat katalog materiálů, dodavatele, lokace a kategorie', NOW()),
('warehouse.inventory', 'Inventura', 'warehouse', 'Vytvářet a potvrzovat inventurní sčítkání', NOW());
-- 2. Assign all 4 permissions to the admin role. INSERT IGNORE on the
-- composite primary key (role_id, permission_id) makes this idempotent
-- and preserves any other role assignments that already exist.
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
SELECT r.id, p.id
FROM `roles` r
CROSS JOIN `permissions` p
WHERE r.name = 'admin'
AND p.name IN (
'warehouse.view',
'warehouse.operate',
'warehouse.manage',
'warehouse.inventory'
);

View File

@@ -0,0 +1,61 @@
-- Add work_plan_entries and work_plan_overrides tables plus the plan_category
-- enum. Both tables are owned by users (Cascade on delete) and reference
-- projects (SetNull). `created_by` uses Restrict so we never silently lose
-- the audit trail of who created the entry.
-- CreateTable
CREATE TABLE `work_plan_entries` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`user_id` INTEGER NOT NULL,
`date_from` DATE NOT NULL,
`date_to` DATE NOT NULL,
`project_id` INTEGER NULL,
`category` ENUM('work', 'preparation', 'travel', 'leave', 'sick', 'training', 'other') NOT NULL DEFAULT 'work',
`note` VARCHAR(500) NOT NULL,
`created_by` INTEGER NOT NULL,
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`is_deleted` BOOLEAN NULL DEFAULT false,
INDEX `idx_wpe_user_from`(`user_id`, `date_from`),
INDEX `idx_wpe_user_to`(`user_id`, `date_to`),
INDEX `idx_wpe_project`(`project_id`),
INDEX `idx_wpe_dates`(`date_from`, `date_to`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `work_plan_overrides` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`user_id` INTEGER NOT NULL,
`shift_date` DATE NOT NULL,
`project_id` INTEGER NULL,
`category` ENUM('work', 'preparation', 'travel', 'leave', 'sick', 'training', 'other') NOT NULL,
`note` VARCHAR(500) NOT NULL,
`created_by` INTEGER NOT NULL,
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`is_deleted` BOOLEAN NULL DEFAULT false,
INDEX `idx_wpo_date`(`shift_date`),
UNIQUE INDEX `uniq_wpo_user_date`(`user_id`, `shift_date`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `work_plan_entries` ADD CONSTRAINT `work_plan_entries_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `work_plan_entries` ADD CONSTRAINT `work_plan_entries_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `work_plan_entries` ADD CONSTRAINT `work_plan_entries_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `work_plan_overrides` ADD CONSTRAINT `work_plan_overrides_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `work_plan_overrides` ADD CONSTRAINT `work_plan_overrides_project_id_fkey` FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON DELETE SET NULL ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `work_plan_overrides` ADD CONSTRAINT `work_plan_overrides_created_by_fkey` FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;

View File

@@ -0,0 +1,26 @@
-- Drop the unique index on (user_id, shift_date) for work_plan_overrides.
-- MySQL unique indexes don't ignore soft-deleted rows, which would prevent
-- createOverride from soft-deleting an existing override and creating a new
-- one for the same (user_id, shift_date). Application code enforces the
-- "at most one active override per (user_id, shift_date)" invariant.
-- The application-level check is wrapped in a transaction; see
-- src/services/plan.service.ts:createOverride.
--
-- The original index was created in
-- 20260605120000_add_work_plan/migration.sql as `uniq_wpo_user_date`.
-- IMPORTANT ordering: `uniq_wpo_user_date` is the only index covering the
-- `user_id` foreign key, and MySQL refuses to drop an index still needed by a
-- FK. So we CREATE the replacement non-unique index FIRST (giving the FK
-- another covering index), THEN drop the unique one. The reverse order fails
-- with "Cannot drop index ... needed in a foreign key constraint" on a fresh
-- apply.
-- Add a non-unique index for the (user_id, shift_date) lookup used by
-- createOverride's "is there an active override for this user-day?" query
-- and by resolveCell / resolveGrid. This is a plain index, not a unique
-- one — multiple rows (active and soft-deleted) can share the same
-- (user_id, shift_date) pair.
CREATE INDEX `idx_wpo_user_date` ON `work_plan_overrides`(`user_id`, `shift_date`);
DROP INDEX `uniq_wpo_user_date` ON `work_plan_overrides`;

View File

@@ -0,0 +1,32 @@
-- Add plan_categories table and convert category columns from ENUM to VARCHAR(50)
-- CreateTable
CREATE TABLE `plan_categories` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`key` VARCHAR(50) NOT NULL,
`label` VARCHAR(100) NOT NULL,
`color` VARCHAR(7) NOT NULL,
`sort_order` INTEGER NOT NULL DEFAULT 0,
`is_active` BOOLEAN NOT NULL DEFAULT true,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
UNIQUE INDEX `plan_categories_key_key`(`key`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AlterTable: change category from ENUM to VARCHAR(50) in work_plan_entries
ALTER TABLE `work_plan_entries` MODIFY `category` VARCHAR(50) NOT NULL DEFAULT 'work';
-- AlterTable: change category from ENUM to VARCHAR(50) in work_plan_overrides
ALTER TABLE `work_plan_overrides` MODIFY `category` VARCHAR(50) NOT NULL;
-- Seed the categories that were previously the plan_category enum
INSERT INTO `plan_categories` (`key`, `label`, `color`, `sort_order`, `is_active`) VALUES
('work', 'Práce', '#2563eb', 1, true),
('preparation', 'Příprava', '#0d9488', 2, true),
('travel', 'Cesta / Montáž', '#ca8a04', 3, true),
('leave', 'Dovolená', '#16a34a', 4, true),
('sick', 'Nemoc', '#dc2626', 5, true),
('training', 'Školení', '#7c3aed', 6, true),
('other', 'Jiné', '#6b7280', 7, true);

View File

@@ -0,0 +1,15 @@
-- Re-add the projects.create permission (removed in commit 82919d3 when manual
-- project creation was deleted). Idempotent: re-running is a no-op.
-- Mirrors prisma/seed.ts PERMISSIONS exactly (name, display_name, module, description).
-- 1. Insert the permission (INSERT IGNORE skips on duplicate name).
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
('projects.create', 'Vytvořit projekt', 'projects', 'Ručně vytvářet projekty', NOW());
-- 2. Grant it to the admin role (idempotent via composite PK).
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
SELECT r.id, p.id
FROM `roles` r
CROSS JOIN `permissions` p
WHERE r.name = 'admin'
AND p.name IN ('projects.create');

View File

@@ -0,0 +1,29 @@
-- AI assistant (Phase 1): usage-tracking table, monthly budget column, ai.use permission.
CREATE TABLE `ai_usage` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`user_id` INTEGER NULL,
`kind` VARCHAR(20) NOT NULL,
`model` VARCHAR(50) NOT NULL,
`input_tokens` INTEGER NOT NULL DEFAULT 0,
`output_tokens` INTEGER NOT NULL DEFAULT 0,
`cost_usd` DECIMAL(10, 6) NOT NULL DEFAULT 0,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`),
INDEX `idx_ai_usage_created` (`created_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `company_settings`
ADD COLUMN `ai_monthly_budget_usd` DECIMAL(10, 2) NULL DEFAULT 50.00;
-- ai.use permission + grant to admin (INSERT IGNORE → idempotent), mirroring
-- the warehouse-permissions migration.
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
('ai.use', 'AI asistent', 'ai', 'Používat AI asistenta (chat a import faktur)', NOW());
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
SELECT r.id, p.id
FROM `roles` r
CROSS JOIN `permissions` p
WHERE r.name = 'admin'
AND p.name = 'ai.use';

View File

@@ -0,0 +1,11 @@
-- AI assistant (Phase 1): per-user chat history (server-side, permanent).
CREATE TABLE `ai_chat_messages` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`user_id` INTEGER NOT NULL,
`role` VARCHAR(20) NOT NULL,
`content` TEXT NOT NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`),
INDEX `idx_ai_chat_user` (`user_id`, `id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@@ -0,0 +1,32 @@
-- Multi-conversation Odin: conversations table + conversation_id on messages.
CREATE TABLE `ai_conversations` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`user_id` INTEGER NOT NULL,
`title` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
`updated_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
PRIMARY KEY (`id`),
INDEX `idx_ai_conv_user` (`user_id`, `id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `ai_chat_messages`
ADD COLUMN `conversation_id` INTEGER NULL AFTER `user_id`;
-- Backfill: one conversation per user who has messages ("keep as first conversation").
INSERT INTO `ai_conversations` (`user_id`, `title`, `created_at`, `updated_at`)
SELECT `user_id`, 'Konverzace', NOW(), NOW()
FROM `ai_chat_messages`
GROUP BY `user_id`;
UPDATE `ai_chat_messages` m
JOIN `ai_conversations` c ON c.`user_id` = m.`user_id`
SET m.`conversation_id` = c.`id`;
-- Enforce: drop old per-user index, NOT NULL, FK (cascade), per-conversation index.
ALTER TABLE `ai_chat_messages`
DROP INDEX `idx_ai_chat_user`,
MODIFY `conversation_id` INTEGER NOT NULL,
ADD CONSTRAINT `fk_ai_chat_conv` FOREIGN KEY (`conversation_id`)
REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE,
ADD INDEX `idx_ai_chat_conv` (`conversation_id`, `id`);

View File

@@ -0,0 +1,137 @@
-- Audit schema hardening (from the 2026-06-09 codebase audit).
--
-- ⚠️ PENDING — NOT YET APPLIED. Review, then apply with the dev server stopped:
-- npx prisma migrate deploy (prod / already-tracked)
-- -- or, in dev, `npx prisma migrate dev` will pick it up as pending.
--
-- What this does:
-- • Warehouse line→header relations now CASCADE on delete (deleting a
-- receipt/issue/inventory session removes its lines/attachments).
-- • Warehouse line→master-data (item/batch/location) and the financial FKs
-- (invoices/orders/projects/quotations → customer/order/quotation) are now
-- explicit ON DELETE RESTRICT — preventing deletion of a referenced master
-- record / orphaning of a financial record. (Some of these were previously
-- the optional-relation default SET NULL; RESTRICT is the safer intent.)
-- • UNIQUE on warehouse document numbers (receipt_number/issue_number/
-- session_number) — like every other document-number column.
-- • New indexes on hot FK filters (sklad_issues/receipts, bank_accounts).
--
-- ⚠️ BEFORE APPLYING ON PRODUCTION, verify existing data won't violate the new
-- constraints: no DUPLICATE non-null receipt/issue/session numbers (else the
-- UNIQUE index fails), and no rows that would break the RESTRICT FKs. Test on
-- a copy first.
--
-- DropForeignKey
ALTER TABLE `invoices` DROP FOREIGN KEY `invoices_ibfk_1`;
-- DropForeignKey
ALTER TABLE `invoices` DROP FOREIGN KEY `invoices_ibfk_2`;
-- DropForeignKey
ALTER TABLE `orders` DROP FOREIGN KEY `orders_ibfk_1`;
-- DropForeignKey
ALTER TABLE `orders` DROP FOREIGN KEY `orders_ibfk_2`;
-- DropForeignKey
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_1`;
-- DropForeignKey
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_2`;
-- DropForeignKey
ALTER TABLE `projects` DROP FOREIGN KEY `projects_ibfk_3`;
-- DropForeignKey
ALTER TABLE `quotations` DROP FOREIGN KEY `quotations_ibfk_1`;
-- DropForeignKey
ALTER TABLE `sklad_receipt_lines` DROP FOREIGN KEY `sklad_receipt_lines_receipt_id_fkey`;
-- DropForeignKey
ALTER TABLE `sklad_receipt_lines` DROP FOREIGN KEY `sklad_receipt_lines_location_id_fkey`;
-- DropForeignKey
ALTER TABLE `sklad_receipt_attachments` DROP FOREIGN KEY `sklad_receipt_attachments_receipt_id_fkey`;
-- DropForeignKey
ALTER TABLE `sklad_issue_lines` DROP FOREIGN KEY `sklad_issue_lines_issue_id_fkey`;
-- DropForeignKey
ALTER TABLE `sklad_issue_lines` DROP FOREIGN KEY `sklad_issue_lines_location_id_fkey`;
-- DropForeignKey
ALTER TABLE `sklad_inventory_lines` DROP FOREIGN KEY `sklad_inventory_lines_session_id_fkey`;
-- DropForeignKey
ALTER TABLE `sklad_inventory_lines` DROP FOREIGN KEY `sklad_inventory_lines_location_id_fkey`;
-- CreateIndex
CREATE INDEX `idx_bank_accounts_is_default` ON `bank_accounts`(`is_default`);
-- CreateIndex
CREATE UNIQUE INDEX `sklad_receipts_receipt_number_key` ON `sklad_receipts`(`receipt_number`);
-- CreateIndex
CREATE INDEX `sklad_receipts_supplier_id` ON `sklad_receipts`(`supplier_id`);
-- CreateIndex
CREATE INDEX `sklad_receipts_received_by` ON `sklad_receipts`(`received_by`);
-- CreateIndex
CREATE UNIQUE INDEX `sklad_issues_issue_number_key` ON `sklad_issues`(`issue_number`);
-- CreateIndex
CREATE INDEX `sklad_issues_project_id` ON `sklad_issues`(`project_id`);
-- CreateIndex
CREATE INDEX `sklad_issues_issued_by` ON `sklad_issues`(`issued_by`);
-- CreateIndex
CREATE UNIQUE INDEX `sklad_inventory_sessions_session_number_key` ON `sklad_inventory_sessions`(`session_number`);
-- AddForeignKey
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_ibfk_1` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `invoices` ADD CONSTRAINT `invoices_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `orders` ADD CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`quotation_id`) REFERENCES `quotations`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `orders` ADD CONSTRAINT `orders_ibfk_2` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_2` FOREIGN KEY (`quotation_id`) REFERENCES `quotations`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `projects` ADD CONSTRAINT `projects_ibfk_3` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `quotations` ADD CONSTRAINT `quotations_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_lines` ADD CONSTRAINT `sklad_receipt_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_receipt_attachments` ADD CONSTRAINT `sklad_receipt_attachments_receipt_id_fkey` FOREIGN KEY (`receipt_id`) REFERENCES `sklad_receipts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_issue_id_fkey` FOREIGN KEY (`issue_id`) REFERENCES `sklad_issues`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_issue_lines` ADD CONSTRAINT `sklad_issue_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_session_id_fkey` FOREIGN KEY (`session_id`) REFERENCES `sklad_inventory_sessions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `sklad_inventory_lines` ADD CONSTRAINT `sklad_inventory_lines_location_id_fkey` FOREIGN KEY (`location_id`) REFERENCES `sklad_locations`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE `ai_chat_messages` DROP FOREIGN KEY `fk_ai_chat_conv`;
-- AlterTable
ALTER TABLE `quotations` MODIFY `project_code` VARCHAR(100) NULL;
-- AddForeignKey
ALTER TABLE `ai_chat_messages` ADD CONSTRAINT `ai_chat_messages_conversation_id_fkey` FOREIGN KEY (`conversation_id`) REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,52 @@
-- AlterTable
ALTER TABLE `company_settings` ADD COLUMN `issued_order_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `issued_order_type_code` VARCHAR(10) NULL;
-- CreateTable
CREATE TABLE `issued_orders` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`po_number` VARCHAR(50) NULL,
`customer_id` INTEGER NULL,
`status` ENUM('draft', 'sent', 'confirmed', 'completed', 'cancelled') NOT NULL DEFAULT 'draft',
`currency` VARCHAR(10) NULL DEFAULT 'CZK',
`vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
`apply_vat` BOOLEAN NULL DEFAULT true,
`exchange_rate` DECIMAL(10, 4) NULL DEFAULT 1.0000,
`order_date` DATE NULL,
`delivery_date` DATE NULL,
`language` VARCHAR(5) NULL DEFAULT 'cs',
`delivery_terms` VARCHAR(500) NULL,
`payment_terms` VARCHAR(500) NULL,
`issued_by` VARCHAR(255) NULL,
`notes` TEXT NULL,
`internal_notes` TEXT NULL,
`created_at` DATETIME(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
`modified_at` DATETIME(0) NULL,
UNIQUE INDEX `idx_issued_orders_number_unique`(`po_number`),
INDEX `issued_orders_customer_id`(`customer_id`),
INDEX `idx_issued_orders_status_date`(`status`, `order_date`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `issued_order_items` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`issued_order_id` INTEGER NOT NULL,
`description` VARCHAR(500) NULL,
`item_description` TEXT NULL,
`quantity` DECIMAL(12, 3) NULL DEFAULT 1.000,
`unit` VARCHAR(20) NULL,
`unit_price` DECIMAL(12, 2) NULL DEFAULT 0.00,
`vat_rate` DECIMAL(5, 2) NULL DEFAULT 21.00,
`position` INTEGER NULL DEFAULT 0,
INDEX `issued_order_id`(`issued_order_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE `issued_order_items` ADD CONSTRAINT `issued_order_items_ibfk_1` FOREIGN KEY (`issued_order_id`) REFERENCES `issued_orders`(`id`) ON DELETE CASCADE ON UPDATE NO ACTION;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE `company_settings` ADD COLUMN `project_number_pattern` VARCHAR(100) NULL,
ADD COLUMN `project_type_code` VARCHAR(10) NULL;

View File

@@ -0,0 +1,61 @@
-- Drop the three DEAD document "export" permissions and make the 12 surviving
-- document permissions reproducible on a fresh production database.
--
-- Why: `offers.export`, `orders.export`, `invoices.export` gate NOTHING on the
-- backend — every PDF/export route is guarded by `*.view`. They only ever
-- toggled frontend buttons and showed up as dead checkboxes in role management.
-- The 12 real keys (offers/orders/invoices x view/create/edit/delete) ARE each
-- enforced on >=1 route and must survive.
--
-- All 15 keys were previously defined ONLY in prisma/seed.ts (dev-only), so a
-- fresh production DB never had ANY of them. This migration both removes the 3
-- dead keys AND seeds the 12 survivors + their admin grant, mirroring the
-- prisma/seed.ts column set exactly (name, display_name, module, description,
-- created_at; role_permissions has only the composite PK).
--
-- Idempotent: safe whether the rows exist or not, and safe on a prod DB that
-- never had them. Deletes are no-ops when absent; INSERT IGNORE skips on the
-- unique `permissions.name` key and on the `role_permissions` composite PK.
-- 1. Remove the dead export role assignments first (FK-safe: children before
-- parents). No-op when the permissions/assignments don't exist.
DELETE FROM `role_permissions`
WHERE `permission_id` IN (
SELECT `id` FROM `permissions`
WHERE `name` IN ('offers.export', 'orders.export', 'invoices.export')
);
-- 2. Remove the dead export permissions themselves.
DELETE FROM `permissions`
WHERE `name` IN ('offers.export', 'orders.export', 'invoices.export');
-- 3. Idempotently (re)insert the 12 surviving document permissions. INSERT
-- IGNORE skips any that already exist (unique `name`). display_name /
-- description / module match prisma/seed.ts verbatim.
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
('offers.view', 'Zobrazit nabídky', 'offers', 'Prohlížet seznam nabídek', NOW()),
('offers.create', 'Vytvořit nabídku', 'offers', 'Vytvářet nové nabídky', NOW()),
('offers.edit', 'Upravit nabídku', 'offers', 'Upravovat existující nabídky', NOW()),
('offers.delete', 'Smazat nabídku', 'offers', 'Mazat nabídky', NOW()),
('orders.view', 'Zobrazit objednávky', 'orders', 'Prohlížet seznam objednávek', NOW()),
('orders.create', 'Vytvořit objednávku', 'orders', 'Vytvářet nové objednávky', NOW()),
('orders.edit', 'Upravit objednávku', 'orders', 'Upravovat existující objednávky', NOW()),
('orders.delete', 'Smazat objednávku', 'orders', 'Mazat objednávky', NOW()),
('invoices.view', 'Zobrazit faktury', 'invoices', 'Prohlížet seznam faktur', NOW()),
('invoices.create', 'Vytvořit fakturu', 'invoices', 'Vytvářet nové faktury', NOW()),
('invoices.edit', 'Upravit fakturu', 'invoices', 'Upravovat existující faktury', NOW()),
('invoices.delete', 'Smazat fakturu', 'invoices', 'Mazat faktury', NOW());
-- 4. Grant all 12 survivors to the admin role. INSERT IGNORE on the composite
-- PK (role_id, permission_id) makes this idempotent and preserves any other
-- role assignments that already exist.
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
SELECT r.id, p.id
FROM `roles` r
CROSS JOIN `permissions` p
WHERE r.name = 'admin'
AND p.name IN (
'offers.view', 'offers.create', 'offers.edit', 'offers.delete',
'orders.view', 'orders.create', 'orders.edit', 'orders.delete',
'invoices.view', 'invoices.create', 'invoices.edit', 'invoices.delete'
);

View File

@@ -0,0 +1,16 @@
-- DropForeignKey
ALTER TABLE `issued_orders` DROP FOREIGN KEY `issued_orders_ibfk_1`;
-- DropIndex
DROP INDEX `issued_orders_customer_id` ON `issued_orders`;
-- AlterTable
ALTER TABLE `issued_orders` DROP COLUMN `customer_id`,
ADD COLUMN `supplier_id` INTEGER NULL;
-- CreateIndex
CREATE INDEX `issued_orders_supplier_id` ON `issued_orders`(`supplier_id`);
-- AddForeignKey
ALTER TABLE `issued_orders` ADD CONSTRAINT `issued_orders_supplier_fk` FOREIGN KEY (`supplier_id`) REFERENCES `sklad_suppliers`(`id`) ON DELETE RESTRICT ON UPDATE NO ACTION;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
# It should be added in your version-control system (e.g., Git)
provider = "mysql"

View File

@@ -4,7 +4,6 @@ generator client {
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model attendance {
@@ -46,6 +45,7 @@ model attendance_project_logs {
hours Int? @db.UnsignedInt
minutes Int? @db.UnsignedInt
attendance attendance @relation(fields: [attendance_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@index([attendance_id], map: "idx_attendance_project_logs_aid")
@@index([project_id], map: "idx_project_id")
@@ -67,7 +67,6 @@ model audit_logs {
session_id String? @db.VarChar(128)
created_at DateTime? @default(now()) @db.Timestamp(0)
@@index([created_at], map: "idx_audit_log_created")
@@index([action], map: "idx_audit_logs_action")
@@index([created_at], map: "idx_audit_logs_created")
@@index([entity_type, entity_id, created_at], map: "idx_audit_logs_entity")
@@ -75,6 +74,42 @@ model audit_logs {
@@fulltext([description], map: "idx_audit_search")
}
model ai_usage {
id Int @id @default(autoincrement())
user_id Int?
kind String @db.VarChar(20)
model String @db.VarChar(50)
input_tokens Int @default(0)
output_tokens Int @default(0)
cost_usd Decimal @default(0) @db.Decimal(10, 6)
created_at DateTime @default(now()) @db.Timestamp(0)
@@index([created_at], map: "idx_ai_usage_created")
}
model ai_conversations {
id Int @id @default(autoincrement())
user_id Int
title String @db.VarChar(255)
created_at DateTime @default(now()) @db.Timestamp(0)
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
messages ai_chat_messages[]
@@index([user_id, id], map: "idx_ai_conv_user")
}
model ai_chat_messages {
id Int @id @default(autoincrement())
user_id Int
conversation_id Int
role String @db.VarChar(20)
content String @db.Text
created_at DateTime @default(now()) @db.Timestamp(0)
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
@@index([conversation_id, id], map: "idx_ai_chat_conv")
}
model bank_accounts {
id Int @id @default(autoincrement())
account_name String? @db.VarChar(255)
@@ -87,6 +122,8 @@ model bank_accounts {
position Int? @default(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
@@index([is_default], map: "idx_bank_accounts_is_default")
}
model company_settings {
@@ -100,18 +137,18 @@ model company_settings {
vat_id String? @db.VarChar(50)
custom_fields String? @db.LongText
logo_data Bytes?
logo_data_dark Bytes?
logo_data_dark Bytes? @db.MediumBlob
quotation_prefix String? @db.VarChar(20)
default_currency String? @default("CZK") @db.VarChar(10)
default_vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
uuid String? @db.VarChar(36)
uuid String? @unique @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
order_type_code String? @db.VarChar(10)
invoice_type_code String? @db.VarChar(10)
require_2fa Boolean @default(false)
break_threshold_hours Decimal? @default(6) @db.Decimal(4, 2)
break_threshold_hours Decimal? @default(6.00) @db.Decimal(4, 2)
break_duration_short Int? @default(15)
break_duration_long Int? @default(30)
clock_rounding_minutes Int? @default(15)
@@ -127,6 +164,17 @@ model company_settings {
offer_number_pattern String? @db.VarChar(100)
order_number_pattern String? @db.VarChar(100)
invoice_number_pattern String? @db.VarChar(100)
issued_order_number_pattern String? @db.VarChar(100)
issued_order_type_code String? @db.VarChar(10)
project_number_pattern String? @db.VarChar(100)
project_type_code String? @db.VarChar(10)
warehouse_receipt_prefix String? @db.VarChar(20)
warehouse_receipt_number_pattern String? @db.VarChar(100)
warehouse_issue_prefix String? @db.VarChar(20)
warehouse_issue_number_pattern String? @db.VarChar(100)
warehouse_inventory_prefix String? @db.VarChar(20)
warehouse_inventory_number_pattern String? @db.VarChar(100)
ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2)
}
model customers {
@@ -153,6 +201,7 @@ model invoice_items {
id Int @id @default(autoincrement())
invoice_id Int
description String? @db.VarChar(500)
item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
@@ -165,7 +214,7 @@ model invoice_items {
model invoices {
id Int @id @default(autoincrement())
invoice_number String? @db.VarChar(50)
invoice_number String? @unique(map: "idx_invoices_number_unique") @db.VarChar(50)
order_id Int?
customer_id Int?
status String? @default("issued") @db.VarChar(30)
@@ -190,12 +239,13 @@ model invoices {
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
invoice_items invoice_items[]
orders orders? @relation(fields: [order_id], references: [id], onUpdate: NoAction, map: "invoices_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "invoices_ibfk_2")
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "invoices_ibfk_2")
@@index([customer_id], map: "customer_id")
@@index([due_date], map: "idx_invoices_due_date")
@@index([status, issue_date], map: "idx_invoices_status_issue")
@@index([status, due_date], map: "idx_invoices_status_due")
@@index([order_id], map: "order_id")
}
@@ -253,6 +303,8 @@ model number_sequences {
type String? @db.VarChar(50)
year Int?
last_number Int? @default(0)
@@unique([type, year], map: "idx_number_sequences_type_year")
}
model order_items {
@@ -286,7 +338,7 @@ model order_sections {
model orders {
id Int @id @default(autoincrement())
order_number String? @db.VarChar(50)
order_number String? @unique(map: "idx_orders_number_unique") @db.VarChar(50)
customer_order_number String? @db.VarChar(100)
attachment_data Bytes?
attachment_name String? @db.VarChar(255)
@@ -306,14 +358,63 @@ model orders {
invoices invoices[]
order_items order_items[]
order_sections order_sections[]
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "orders_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "orders_ibfk_2")
quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "orders_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "orders_ibfk_2")
projects projects[]
@@index([customer_id], map: "customer_id")
@@index([quotation_id], map: "quotation_id")
}
model issued_orders {
id Int @id @default(autoincrement())
po_number String? @unique(map: "idx_issued_orders_number_unique") @db.VarChar(50)
supplier_id Int?
status issued_orders_status @default(draft)
currency String? @default("CZK") @db.VarChar(10)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
order_date DateTime? @db.Date
delivery_date DateTime? @db.Date
language String? @default("cs") @db.VarChar(5)
delivery_terms String? @db.VarChar(500)
payment_terms String? @db.VarChar(500)
issued_by String? @db.VarChar(255)
notes String? @db.Text
internal_notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
issued_order_items issued_order_items[]
suppliers sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "issued_orders_supplier_fk")
@@index([supplier_id], map: "issued_orders_supplier_id")
@@index([status, order_date], map: "idx_issued_orders_status_date")
}
model issued_order_items {
id Int @id @default(autoincrement())
issued_order_id Int
description String? @db.VarChar(500)
item_description String? @db.Text
quantity Decimal? @default(1.000) @db.Decimal(12, 3)
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
position Int? @default(0)
issued_orders issued_orders @relation(fields: [issued_order_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "issued_order_items_ibfk_1")
@@index([issued_order_id], map: "issued_order_id")
}
enum issued_orders_status {
draft
sent
confirmed
completed
cancelled
}
model permissions {
id Int @id @default(autoincrement())
name String @unique(map: "name") @db.VarChar(50)
@@ -338,7 +439,7 @@ model project_notes {
model projects {
id Int @id @default(autoincrement())
project_number String? @db.VarChar(50)
project_number String? @unique @db.VarChar(50)
name String? @db.VarChar(255)
customer_id Int?
responsible_user_id Int?
@@ -350,11 +451,16 @@ model projects {
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
attendance_project_logs attendance_project_logs[]
project_notes project_notes[]
sklad_issues sklad_issues[]
sklad_reservations sklad_reservations[]
work_plan_entries work_plan_entries[]
work_plan_overrides work_plan_overrides[]
users users? @relation(fields: [responsible_user_id], references: [id], onUpdate: NoAction, map: "fk_projects_responsible_user")
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_1")
quotations quotations? @relation(fields: [quotation_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_2")
orders orders? @relation(fields: [order_id], references: [id], onUpdate: NoAction, map: "projects_ibfk_3")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_1")
quotations quotations? @relation(fields: [quotation_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_2")
orders orders? @relation(fields: [order_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "projects_ibfk_3")
@@index([customer_id], map: "customer_id")
@@index([responsible_user_id], map: "fk_projects_responsible_user")
@@ -372,10 +478,7 @@ model quotation_items {
unit String? @db.VarChar(20)
unit_price Decimal? @default(0.00) @db.Decimal(12, 2)
is_included_in_total Boolean? @default(true)
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "quotation_items_ibfk_1")
@@index([quotation_id], map: "quotation_id")
@@ -383,8 +486,8 @@ model quotation_items {
model quotations {
id Int @id @default(autoincrement())
quotation_number String? @db.VarChar(50)
project_code String? @db.VarChar(50)
quotation_number String? @unique @db.VarChar(50)
project_code String? @db.VarChar(100)
customer_id Int?
created_at DateTime? @default(now()) @db.DateTime(0)
valid_until DateTime? @db.Date
@@ -392,21 +495,17 @@ model quotations {
language String? @default("cs") @db.VarChar(5)
vat_rate Decimal? @default(21.00) @db.Decimal(5, 2)
apply_vat Boolean? @default(true)
exchange_rate Decimal? @default(1.0000) @db.Decimal(10, 4)
exchange_rate_date DateTime? @db.Date
order_id Int?
status String @default("active") @db.VarChar(20)
scope_title String? @db.VarChar(500)
scope_description String? @db.Text
locked_by Int?
locked_at DateTime? @db.DateTime(0)
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
sync_version Int? @default(0)
orders orders[]
projects projects[]
quotation_items quotation_items[]
customers customers? @relation(fields: [customer_id], references: [id], onUpdate: NoAction, map: "quotations_ibfk_1")
customers customers? @relation(fields: [customer_id], references: [id], onDelete: Restrict, onUpdate: NoAction, map: "quotations_ibfk_1")
scope_sections scope_sections[]
@@index([customer_id], map: "customer_id")
@@ -487,11 +586,7 @@ model scope_sections {
title String? @db.VarChar(500)
title_cz String? @db.VarChar(500)
content String? @db.Text
content_editor_height Int?
uuid String? @db.VarChar(36)
modified_at DateTime? @db.DateTime(0)
is_deleted Boolean? @default(false)
sync_version Int? @default(0)
quotations quotations @relation(fields: [quotation_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "scope_sections_ibfk_1")
@@index([quotation_id], map: "quotation_id")
@@ -578,12 +673,20 @@ model users {
totp_secret String? @db.VarChar(255)
totp_enabled Boolean @default(false)
totp_backup_codes String? @db.Text
totp_last_used_counter Int?
attendance attendance[]
leave_balances leave_balances[]
leave_requests_leave_requests_user_idTousers leave_requests[] @relation("leave_requests_user_idTousers")
leave_requests_leave_requests_reviewer_idTousers leave_requests[] @relation("leave_requests_reviewer_idTousers")
projects projects[]
trips trips[]
sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
sklad_issues_issued sklad_issues[] @relation("SkladIssuedBy")
sklad_reservations sklad_reservations[] @relation("SkladReservedBy")
work_plan_entries work_plan_entries[] @relation("work_plan_entries_creator")
work_plan_entries_owned work_plan_entries[]
work_plan_overrides work_plan_overrides[] @relation("work_plan_overrides_creator")
work_plan_overrides_owned work_plan_overrides[]
roles roles? @relation(fields: [role_id], references: [id], onUpdate: NoAction, map: "users_ibfk_1")
@@index([is_active], map: "idx_users_is_active")
@@ -624,8 +727,9 @@ model invoice_alert_log {
invoice_id Int
alert_type String @db.VarChar(20) // "3days" or "due"
sent_at DateTime @default(now()) @db.DateTime(0)
created_at DateTime @default(now()) @db.DateTime(0)
@@unique([invoice_type, invoice_id, alert_type])
@@unique([invoice_type, invoice_id, alert_type], map: "invoice_type_invoice_id_alert_type")
}
enum received_invoices_status {
@@ -640,3 +744,329 @@ enum attendance_leave_type {
holiday
unpaid
}
enum sklad_receipt_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_issue_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_reservation_status {
ACTIVE
FULFILLED
CANCELLED
}
enum sklad_inventory_status {
DRAFT
CONFIRMED
}
model sklad_categories {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
description String? @db.Text
sort_order Int @default(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_items[]
@@map("sklad_categories")
}
model sklad_suppliers {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
ico String? @db.VarChar(20)
dic String? @db.VarChar(20)
contact_person String? @db.VarChar(255)
email String? @db.VarChar(255)
phone String? @db.VarChar(50)
address String? @db.Text
notes String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[]
issued_orders issued_orders[]
@@map("sklad_suppliers")
}
model sklad_locations {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(20)
name String @db.VarChar(100)
description String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_item_locations[]
receipt_lines sklad_receipt_lines[]
issue_lines sklad_issue_lines[]
inventory_lines sklad_inventory_lines[]
@@map("sklad_locations")
}
model sklad_items {
id Int @id @default(autoincrement())
item_number String? @unique @db.VarChar(50)
name String @db.VarChar(255)
description String? @db.Text
category_id Int?
unit String @db.VarChar(20)
min_quantity Decimal? @db.Decimal(12, 3)
is_active Boolean @default(true)
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
category sklad_categories? @relation(fields: [category_id], references: [id], onDelete: SetNull)
batches sklad_batches[]
item_locations sklad_item_locations[]
receipt_lines sklad_receipt_lines[]
issue_lines sklad_issue_lines[]
reservations sklad_reservations[]
inventory_lines sklad_inventory_lines[]
@@index([category_id], map: "sklad_items_category_id")
@@map("sklad_items")
}
model sklad_batches {
id Int @id @default(autoincrement())
item_id Int
receipt_line_id Int @unique
quantity Decimal @db.Decimal(12, 3)
original_qty Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
received_at DateTime? @db.DateTime(0)
is_consumed Boolean @default(false)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id], onDelete: Restrict)
issue_lines sklad_issue_lines[]
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
@@map("sklad_batches")
}
model sklad_item_locations {
id Int @id @default(autoincrement())
item_id Int
location_id Int
quantity Decimal @default(0) @db.Decimal(12, 3)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations @relation(fields: [location_id], references: [id], onDelete: Restrict)
@@unique([item_id, location_id], map: "sklad_item_locations_unique")
@@map("sklad_item_locations")
}
model sklad_receipts {
id Int @id @default(autoincrement())
receipt_number String? @unique @db.VarChar(50)
supplier_id Int?
delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0)
received_by Int?
notes String? @db.Text
status sklad_receipt_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id], onDelete: SetNull)
received_by_user users? @relation("SkladReceivedBy", fields: [received_by], references: [id], onDelete: SetNull)
items sklad_receipt_lines[]
attachments sklad_receipt_attachments[]
@@index([supplier_id], map: "sklad_receipts_supplier_id")
@@index([received_by], map: "sklad_receipts_received_by")
@@map("sklad_receipts")
}
model sklad_receipt_lines {
id Int @id @default(autoincrement())
receipt_id Int
item_id Int
quantity Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
location_id Int?
notes String? @db.VarChar(255)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
batch sklad_batches?
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
@@map("sklad_receipt_lines")
}
model sklad_receipt_attachments {
id Int @id @default(autoincrement())
receipt_id Int
file_name String @db.VarChar(255)
file_mime String @db.VarChar(100)
file_size Int
file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id], onDelete: Cascade)
@@map("sklad_receipt_attachments")
}
model sklad_issues {
id Int @id @default(autoincrement())
issue_number String? @unique @db.VarChar(50)
project_id Int
issued_by Int?
notes String? @db.Text
status sklad_issue_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
issued_by_user users? @relation("SkladIssuedBy", fields: [issued_by], references: [id], onDelete: SetNull)
items sklad_issue_lines[]
@@index([project_id], map: "sklad_issues_project_id")
@@index([issued_by], map: "sklad_issues_issued_by")
@@map("sklad_issues")
}
model sklad_issue_lines {
id Int @id @default(autoincrement())
issue_id Int
item_id Int
batch_id Int
quantity Decimal @db.Decimal(12, 3)
location_id Int?
reservation_id Int?
notes String? @db.VarChar(255)
issue sklad_issues @relation(fields: [issue_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
batch sklad_batches @relation(fields: [batch_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id], onDelete: SetNull)
@@index([issue_id], map: "sklad_issue_lines_issue_id")
@@map("sklad_issue_lines")
}
model sklad_reservations {
id Int @id @default(autoincrement())
item_id Int
project_id Int
quantity Decimal @db.Decimal(12, 3)
remaining_qty Decimal @db.Decimal(12, 3)
reserved_by Int?
notes String? @db.Text
status sklad_reservation_status @default(ACTIVE)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
project projects @relation(fields: [project_id], references: [id], onDelete: Restrict)
reserved_by_user users? @relation("SkladReservedBy", fields: [reserved_by], references: [id], onDelete: SetNull)
issue_lines sklad_issue_lines[]
@@index([item_id, status], map: "sklad_reservations_item_status")
@@map("sklad_reservations")
}
model sklad_inventory_sessions {
id Int @id @default(autoincrement())
session_number String? @unique @db.VarChar(50)
notes String? @db.Text
status sklad_inventory_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_inventory_lines[]
@@map("sklad_inventory_sessions")
}
model sklad_inventory_lines {
id Int @id @default(autoincrement())
session_id Int
item_id Int
location_id Int?
system_qty Decimal @db.Decimal(12, 3)
actual_qty Decimal @db.Decimal(12, 3)
difference Decimal @db.Decimal(12, 3)
notes String? @db.VarChar(255)
session sklad_inventory_sessions @relation(fields: [session_id], references: [id], onDelete: Cascade)
item sklad_items @relation(fields: [item_id], references: [id], onDelete: Restrict)
location sklad_locations? @relation(fields: [location_id], references: [id], onDelete: Restrict)
@@index([session_id], map: "sklad_inventory_lines_session_id")
@@map("sklad_inventory_lines")
}
model plan_categories {
id Int @id @default(autoincrement())
key String @unique @db.VarChar(50)
label String @db.VarChar(100)
color String @db.VarChar(7)
sort_order Int @default(0)
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
updated_at DateTime? @default(now()) @updatedAt @db.DateTime(0)
}
model work_plan_entries {
id Int @id @default(autoincrement())
user_id Int
date_from DateTime @db.Date
date_to DateTime @db.Date
project_id Int?
category String @default("work") @db.VarChar(50)
note String @db.VarChar(500)
created_by Int
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
is_deleted Boolean? @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
creator users @relation("work_plan_entries_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([user_id, date_from], map: "idx_wpe_user_from")
@@index([user_id, date_to], map: "idx_wpe_user_to")
@@index([project_id], map: "idx_wpe_project")
@@index([date_from, date_to], map: "idx_wpe_dates")
}
model work_plan_overrides {
id Int @id @default(autoincrement())
user_id Int
shift_date DateTime @db.Date
project_id Int?
category String @db.VarChar(50)
note String @db.VarChar(500)
created_by Int
created_at DateTime? @default(now()) @db.Timestamp(0)
updated_at DateTime? @default(now()) @db.Timestamp(0)
is_deleted Boolean? @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
projects projects? @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
creator users @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([user_id, shift_date], map: "idx_wpo_user_date")
@@index([shift_date], map: "idx_wpo_date")
}

474
prisma/seed.ts Normal file
View File

@@ -0,0 +1,474 @@
import "dotenv/config";
import bcrypt from "bcryptjs";
// Use the shared, adapter-wired client (Prisma 7 needs a driver adapter).
// dotenv is imported first so DATABASE_URL is set before database.ts reads it.
import prisma from "../src/config/database";
const PERMISSIONS: {
name: string;
display_name: string;
module: string;
description: string;
}[] = [
// Docházka
{
name: "attendance.record",
display_name: "Záznam docházky",
module: "attendance",
description: "Zapisovat příchody/odchody",
},
{
name: "attendance.history",
display_name: "Historie docházky",
module: "attendance",
description: "Prohlížet vlastní historii docházky",
},
{
name: "attendance.approve",
display_name: "Schvalování docházky",
module: "attendance",
description: "Schvalovat žádosti o dovolenou/nemoc",
},
{
name: "attendance.manage",
display_name: "Správa docházky",
module: "attendance",
description: "Spravovat všechny záznamy, vytvářet/editovat/mazat",
},
{
name: "attendance.balances",
display_name: "Správa bilancí",
module: "attendance",
description: "Spravovat zůstatky dovolené a sick days",
},
// Kniha jízd
{
name: "trips.record",
display_name: "Záznam jízd",
module: "trips",
description: "Zapisovat jízdy",
},
{
name: "trips.history",
display_name: "Historie jízd",
module: "trips",
description: "Prohlížet vlastní historii jízd",
},
{
name: "trips.manage",
display_name: "Správa jízd",
module: "trips",
description: "Spravovat všechny jízdy",
},
// Vozidla
{
name: "vehicles.manage",
display_name: "Správa vozidel",
module: "vehicles",
description: "Spravovat vozový park",
},
// Nabídky
{
name: "offers.view",
display_name: "Zobrazit nabídky",
module: "offers",
description: "Prohlížet seznam nabídek",
},
{
name: "offers.create",
display_name: "Vytvořit nabídku",
module: "offers",
description: "Vytvářet nové nabídky",
},
{
name: "offers.edit",
display_name: "Upravit nabídku",
module: "offers",
description: "Upravovat existující nabídky",
},
{
name: "offers.delete",
display_name: "Smazat nabídku",
module: "offers",
description: "Mazat nabídky",
},
// Objednávky
{
name: "orders.view",
display_name: "Zobrazit objednávky",
module: "orders",
description: "Prohlížet seznam objednávek",
},
{
name: "orders.create",
display_name: "Vytvořit objednávku",
module: "orders",
description: "Vytvářet nové objednávky",
},
{
name: "orders.edit",
display_name: "Upravit objednávku",
module: "orders",
description: "Upravovat existující objednávky",
},
{
name: "orders.delete",
display_name: "Smazat objednávku",
module: "orders",
description: "Mazat objednávky",
},
// Faktury
{
name: "invoices.view",
display_name: "Zobrazit faktury",
module: "invoices",
description: "Prohlížet seznam faktur",
},
{
name: "invoices.create",
display_name: "Vytvořit fakturu",
module: "invoices",
description: "Vytvářet nové faktury",
},
{
name: "invoices.edit",
display_name: "Upravit fakturu",
module: "invoices",
description: "Upravovat existující faktury",
},
{
name: "invoices.delete",
display_name: "Smazat fakturu",
module: "invoices",
description: "Mazat faktury",
},
// Projekty
{
name: "projects.view",
display_name: "Zobrazit projekty",
module: "projects",
description: "Prohlížet seznam projektů",
},
{
name: "projects.create",
display_name: "Vytvořit projekt",
module: "projects",
description: "Ručně vytvářet projekty",
},
{
name: "projects.edit",
display_name: "Upravit projekt",
module: "projects",
description: "Upravovat existující projekty",
},
{
name: "projects.delete",
display_name: "Smazat projekt",
module: "projects",
description: "Mazat projekty",
},
{
name: "projects.files",
display_name: "Soubory projektu",
module: "projects",
description: "Spravovat soubory a složky projektu",
},
// Zákazníci
{
name: "customers.view",
display_name: "Zobrazit zákazníky",
module: "customers",
description: "Prohlížet seznam zákazníků",
},
{
name: "customers.create",
display_name: "Vytvořit zákazníka",
module: "customers",
description: "Vytvářet nové zákazníky",
},
{
name: "customers.edit",
display_name: "Upravit zákazníka",
module: "customers",
description: "Upravovat existující zákazníky",
},
{
name: "customers.delete",
display_name: "Smazat zákazníka",
module: "customers",
description: "Mazat zákazníky",
},
// Uživatelé
{
name: "users.view",
display_name: "Zobrazit uživatele",
module: "users",
description: "Prohlížet seznam uživatelů",
},
{
name: "users.create",
display_name: "Vytvořit uživatele",
module: "users",
description: "Vytvářet nové uživatele",
},
{
name: "users.edit",
display_name: "Upravit uživatele",
module: "users",
description: "Upravovat existující uživatele",
},
{
name: "users.delete",
display_name: "Smazat uživatele",
module: "users",
description: "Mazat uživatele",
},
// Nastavení
{
name: "settings.company",
display_name: "Nastavení společnosti",
module: "settings",
description: "Upravovat údaje o společnosti, logo",
},
{
name: "settings.system",
display_name: "Systémová nastavení",
module: "settings",
description: "Spravovat systémová nastavení, 2FA, e-maily, limity",
},
{
name: "settings.banking",
display_name: "Bankovní účty",
module: "settings",
description: "Spravovat bankovní účty společnosti",
},
{
name: "settings.roles",
display_name: "Role a oprávnění",
module: "settings",
description: "Spravovat role a přiřazovat oprávnění",
},
{
name: "settings.templates",
display_name: "Šablony",
module: "settings",
description: "Spravovat šablony nabídek a položek",
},
{
name: "settings.audit",
display_name: "Audit log",
module: "settings",
description: "Prohlížet auditní logy",
},
// Sklad
{
name: "warehouse.view",
display_name: "Zobrazit sklad",
module: "warehouse",
description: "Prohlížet stav skladu, položky, reporty a historii pohybů",
},
{
name: "warehouse.operate",
display_name: "Příjem a výdej",
module: "warehouse",
description: "Vytvářet a potvrzovat příjmy, výdeje a rezervace",
},
{
name: "warehouse.manage",
display_name: "Správa skladu",
module: "warehouse",
description: "Spravovat katalog materiálů, dodavatele, lokace a kategorie",
},
{
name: "warehouse.inventory",
display_name: "Inventura",
module: "warehouse",
description: "Vytvářet a potvrzovat inventurní sčítkání",
},
];
async function main() {
// HARD PRODUCTION GUARD: this seed unconditionally wipes ALL permissions and
// role_permissions (and seeds an admin/admin user) — purely dev-convenience
// behavior. Production baseline data must come from tracked Prisma migrations,
// never from the seed (see CLAUDE.md "Database Migrations"). Refuse to run on
// production so the destructive wipe can never hit a live database.
if (process.env.APP_ENV === "production") {
throw new Error(
"Odmítnuto: prisma/seed.ts NESMÍ běžet na produkci (APP_ENV=production). " +
"Seed maže všechna oprávnění a je určen pouze pro vývoj. " +
"Produkční data musí být zavedena migrací.\n" +
"Refusing to run: prisma/seed.ts must NEVER run on production " +
"(APP_ENV=production). It wipes all permissions and is dev-only; " +
"seed production baseline data via a migration instead.",
);
}
console.log("Seeding permissions...");
// 1. Clear existing permissions and re-insert current set
await prisma.role_permissions.deleteMany({});
await prisma.permissions.deleteMany({});
console.log(" Cleared old permissions");
for (const perm of PERMISSIONS) {
await prisma.permissions.create({ data: perm });
}
console.log(` Inserted ${PERMISSIONS.length} permissions`);
// 2. Ensure admin role exists
let adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
if (!adminRole) {
adminRole = await prisma.roles.create({
data: {
name: "admin",
display_name: "Administrátor",
description: "Hlavní administrátor s plným přístupem",
},
});
console.log(" Created admin role");
} else {
console.log(" Admin role already exists (id=" + adminRole.id + ")");
}
// 3. Assign all permissions to admin role
const allPerms = await prisma.permissions.findMany();
const existingAssignments = await prisma.role_permissions.findMany({
where: { role_id: adminRole.id },
});
// Delete stale assignments (permissions no longer in the list)
const validIds = new Set(allPerms.map((p) => p.id));
const staleIds = existingAssignments.filter(
(a) => !validIds.has(a.permission_id),
);
if (staleIds.length > 0) {
for (const s of staleIds) {
await prisma.role_permissions.delete({
where: {
role_id_permission_id: {
role_id: adminRole.id,
permission_id: s.permission_id,
},
},
});
}
console.log(` Removed ${staleIds.length} stale role_permissions`);
}
// Add missing assignments
const existingIds = new Set(existingAssignments.map((a) => a.permission_id));
let added = 0;
for (const perm of allPerms) {
if (!existingIds.has(perm.id)) {
await prisma.role_permissions.create({
data: { role_id: adminRole.id, permission_id: perm.id },
});
added++;
}
}
if (added > 0) console.log(` Added ${added} role_permission assignments`);
console.log(` Admin role now has all ${allPerms.length} permissions`);
// 4. Ensure admin user exists
let adminUser = await prisma.users.findFirst({
where: { username: "admin" },
});
if (!adminUser) {
const hash = bcrypt.hashSync("admin", 10);
adminUser = await prisma.users.create({
data: {
username: "admin",
email: "admin@boha.local",
password_hash: hash,
first_name: "Admin",
last_name: "BOHA",
role_id: adminRole.id,
is_active: true,
},
});
console.log(" Created admin user (admin / admin)");
} else if (adminUser.role_id !== adminRole.id) {
await prisma.users.update({
where: { id: adminUser.id },
data: { role_id: adminRole.id },
});
console.log(" Updated admin user role_id to admin role");
} else {
console.log(" Admin user already exists with correct role");
}
// 5. Create default company_settings if not exists
const existingSettings = await prisma.company_settings.findFirst();
if (!existingSettings) {
await prisma.company_settings.create({
data: {
company_name: "BOHA s.r.o.",
default_currency: "CZK",
default_vat_rate: 21.0,
available_vat_rates: JSON.stringify([0, 10, 15, 21]),
available_currencies: JSON.stringify(["CZK", "EUR"]),
offer_number_pattern: "NAB-{YYYY}-{NNN}",
order_number_pattern: "OBJ-{YYYY}-{NNN}",
invoice_number_pattern: "FV-{YYYY}-{NNN}",
break_threshold_hours: 6.0,
break_duration_short: 15,
break_duration_long: 30,
clock_rounding_minutes: 15,
require_2fa: false,
max_login_attempts: 5,
lockout_minutes: 15,
max_requests_per_minute: 300,
},
});
console.log(" Created default company_settings");
} else {
console.log(" company_settings already exists");
}
// 6. Initialize number_sequences for current year
const year = new Date().getFullYear();
const types = [
"quotation",
"order",
"invoice",
"warehouse_receipt",
"warehouse_issue",
"warehouse_inventory",
];
for (const type of types) {
const existing = await prisma.number_sequences.findFirst({
where: { type, year },
});
if (!existing) {
await prisma.number_sequences.create({
data: { type, year, last_number: 0 },
});
console.log(` Created number_sequence: ${type}/${year}`);
}
}
console.log(" number_sequences ready");
console.log(
"\nSeed complete — " +
PERMISSIONS.length +
" permissions, 1 admin role, company_settings + number_sequences ready.",
);
}
main()
.catch((e) => {
console.error("Seed failed:", e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@@ -5,10 +5,10 @@
*/
import "../src/config/env";
import { PrismaClient } from "@prisma/client";
import fs from "fs";
import { nasFinancialsManager } from "../src/services/nas-financials-manager";
const prisma = new PrismaClient();
// Shared adapter-wired client (Prisma 7 needs a driver adapter).
import prisma from "../src/config/database";
async function main() {
if (!nasFinancialsManager.isConfigured()) {
@@ -51,6 +51,39 @@ async function main() {
continue;
}
// Read-back verification before we null out the ONLY DB copy of the bytes.
// The manager uses writeFileSync (synchronous) and returns a relative path;
// resolve it back to disk and confirm the file exists and its size matches
// the source buffer. A 0-byte/truncated/corrupt write would otherwise lose
// the invoice permanently once file_data is set to null. If verification
// fails we KEEP file_data and report the row as failed.
const expectedSize = rec.file_data.length;
const readBack = nasFinancialsManager.readReceivedInvoice(result.filePath);
let writtenSize = -1;
if (readBack) {
writtenSize = readBack.data.length;
} else {
// Fall back to a direct stat in case readReceivedInvoice's path guard
// rejects an otherwise-valid path; either way we need a confirmed size.
try {
writtenSize = fs.statSync(
(nasFinancialsManager as unknown as { basePath: string }).basePath +
"/" +
result.filePath,
).size;
} catch {
writtenSize = -1;
}
}
if (writtenSize !== expectedSize) {
console.error(
` FAIL id=${rec.id}: read-back mismatch (expected ${expectedSize} bytes, got ${writtenSize}); keeping DB copy`,
);
failed++;
continue;
}
await prisma.received_invoices.update({
where: { id: rec.id },
data: { file_path: result.filePath, file_data: null },

View File

@@ -1,35 +1,29 @@
import crypto from 'crypto';
import { PrismaClient } from '@prisma/client';
import "dotenv/config";
import { decryptWithKey, encryptWithKey } from "../src/utils/encryption";
// Shared adapter-wired client (Prisma 7 needs a driver adapter).
import prisma from "../src/config/database";
const prisma = new PrismaClient();
function decrypt(ciphertext: string, keyHex: string): string {
const key = Buffer.from(keyHex, 'hex');
const [ivHex, encHex, tagHex] = ciphertext.split(':');
const iv = Buffer.from(ivHex, 'hex');
const encrypted = Buffer.from(encHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return decipher.update(encrypted, undefined, 'utf8') + decipher.final('utf8');
}
function encrypt(plaintext: string, keyHex: string): string {
const key = Buffer.from(keyHex, 'hex');
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('hex')}:${encrypted.toString('hex')}:${tag.toString('hex')}`;
}
// Reuse the dual-format decrypt/encrypt from src/utils/encryption.ts so this
// script handles BOTH wire formats: the TS `hex:hex:hex` form and the
// PHP-legacy single-base64 blob. The previous hand-rolled `decrypt` only parsed
// the TS form and threw `Buffer.from(undefined, 'hex')` on any legacy secret —
// inside the $transaction that aborted/rolled back the ENTIRE batch on the first
// legacy user. These key-parameterized helpers accept the old/new keys passed on
// the command line (the config-bound `decrypt`/`encrypt` use the env key only).
const decrypt = (ciphertext: string, keyHex: string): string =>
decryptWithKey(ciphertext, keyHex);
const encrypt = (plaintext: string, keyHex: string): string =>
encryptWithKey(plaintext, keyHex);
async function main() {
const oldKey = process.argv[2];
const newKey = process.argv[3];
const dryRun = process.argv.includes('--dry-run');
const dryRun = process.argv.includes("--dry-run");
if (!oldKey || !newKey) {
console.error('Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]');
console.error(
"Usage: tsx scripts/rotate-totp-key.ts <old-key-hex> <new-key-hex> [--dry-run]",
);
process.exit(1);
}
@@ -46,12 +40,14 @@ async function main() {
const decrypted = decrypt(user.totp_secret!, oldKey);
const reEncrypted = encrypt(decrypted, newKey);
decrypt(reEncrypted, newKey); // verify round-trip
console.log(` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`);
console.log(
` [OK] ${user.username} (id=${user.id}) — decryption and re-encryption verified`,
);
} catch (e) {
console.error(` [FAIL] ${user.username} (id=${user.id}) — ${e}`);
}
}
console.log('\nDry run complete. No changes made.');
console.log("\nDry run complete. No changes made.");
return;
}
@@ -59,13 +55,19 @@ async function main() {
for (const user of users) {
const decrypted = decrypt(user.totp_secret!, oldKey);
const reEncrypted = encrypt(decrypted, newKey);
await tx.users.update({ where: { id: user.id }, data: { totp_secret: reEncrypted } });
await tx.users.update({
where: { id: user.id },
data: { totp_secret: reEncrypted },
});
console.log(` Re-encrypted TOTP for ${user.username} (id=${user.id})`);
}
});
console.log('\nAll TOTP secrets re-encrypted successfully.');
console.log("\nAll TOTP secrets re-encrypted successfully.");
await prisma.$disconnect();
}
main().catch((e) => { console.error(e); process.exit(1); });
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -2,7 +2,13 @@ import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import AdminApp from "./admin/AdminApp";
// Bootstrap loader: renders before MuiProvider (and its global styles) mount,
// so it must be fully self-contained — no theme vars, no global CSS classes.
// Background follows the persisted theme attribute set by ThemeContext.
function AdminLoader() {
const light =
typeof document !== "undefined" &&
document.documentElement.getAttribute("data-theme") === "light";
return (
<div
style={{
@@ -10,10 +16,20 @@ function AdminLoader() {
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--bg-primary)",
background: light ? "#f4f3f1" : "#0f0f0f",
}}
>
<div className="admin-spinner" />
<style>{"@keyframes boha-boot-spin{to{transform:rotate(360deg)}}"}</style>
<div
style={{
width: 32,
height: 32,
border: "2px solid #d63031",
borderTopColor: "transparent",
borderRadius: "50%",
animation: "boha-boot-spin 0.8s linear infinite",
}}
/>
</div>
);
}

468
src/__tests__/ai.test.ts Normal file
View File

@@ -0,0 +1,468 @@
import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config as appConfig } from "../config/env";
import { securityHeaders } from "../middleware/security";
import aiRoutes from "../routes/admin/ai";
import {
computeCostUsd,
recordUsage,
getMonthSpendUsd,
getBudgetUsd,
assertBudgetAvailable,
isConfigured,
listConversations,
createConversation,
getConversationMessages,
appendConversationMessages,
renameConversation,
deleteConversation,
} from "../services/ai.service";
const KIND = "test_ai"; // marker so we only clean our own rows
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
const CONV_UID_B = 999999992;
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
beforeEach(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
});
afterAll(async () => {
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
});
await prisma.ai_conversations.deleteMany({
where: { title: { contains: HIST_MARKER } },
});
});
describe("ai.service cost + budget", () => {
it("computes Sonnet 4.6 cost from tokens", () => {
// 1,000,000 input @ $3 + 1,000,000 output @ $15 = $18
expect(
computeCostUsd("claude-sonnet-4-6", 1_000_000, 1_000_000),
).toBeCloseTo(18, 6);
expect(computeCostUsd("claude-sonnet-4-6", 4000, 500)).toBeCloseTo(
0.0195,
6,
);
});
it("records usage with the computed cost", async () => {
await recordUsage({
userId: null,
kind: KIND,
model: "claude-sonnet-4-6",
inputTokens: 4000,
outputTokens: 500,
});
const rows = await prisma.ai_usage.findMany({ where: { kind: KIND } });
expect(rows.length).toBe(1);
expect(Number(rows[0].cost_usd)).toBeCloseTo(0.0195, 6);
});
it("sums only the current month's spend (excludes prior months)", async () => {
// Measure the baseline so the assertion is robust to any OTHER current-month
// rows that may already exist in the test DB (getMonthSpendUsd sums ALL
// kinds, not just ours). We assert the DELTA we introduce, not an absolute
// upper bound.
const before = await getMonthSpendUsd();
await recordUsage({
userId: null,
kind: KIND,
model: "claude-sonnet-4-6",
inputTokens: 1_000_000,
outputTokens: 0,
}); // +$3 this month
// An old row (year 2000) must NOT count toward the current month.
await prisma.ai_usage.create({
data: {
kind: KIND,
model: "claude-sonnet-4-6",
input_tokens: 1_000_000,
output_tokens: 0,
cost_usd: 3,
created_at: new Date("2000-01-01T00:00:00Z"),
},
});
const after = await getMonthSpendUsd();
// Only the $3 current-month row moved the needle; the year-2000 $3 is
// excluded. The delta is exactly $3 (not $6).
expect(after - before).toBeCloseTo(3, 6);
});
it("assertBudgetAvailable blocks at/over budget, allows under", async () => {
const budget = await getBudgetUsd();
// Under budget → null (allowed)
expect(await assertBudgetAvailable()).toBeNull();
// Push spend over budget for this month, then it must block with 402.
// Tag with a unique marker so we can delete EXACTLY this row at the end of
// the test — that keeps the inflated spend from leaking into any other
// current-month test regardless of execution order (the beforeEach kind=KIND
// cleanup would catch it too, but cleaning in-test makes it order-proof).
const overBudget = await prisma.ai_usage.create({
data: {
kind: KIND,
model: "claude-sonnet-4-6",
input_tokens: 0,
output_tokens: 0,
cost_usd: budget + 1,
created_at: new Date(),
},
});
try {
const blocked = await assertBudgetAvailable();
expect(blocked).not.toBeNull();
expect(blocked?.status).toBe(402);
} finally {
// Remove the over-budget row immediately so it cannot inflate
// getMonthSpendUsd for any subsequently-running test.
await prisma.ai_usage
.delete({ where: { id: overBudget.id } })
.catch(() => {});
}
});
it("isConfigured reflects the API key presence", () => {
expect(typeof isConfigured()).toBe("boolean");
});
});
describe("ai.service conversations", () => {
beforeEach(async () => {
await prisma.ai_conversations.deleteMany({
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
});
});
// Narrow a service result to its error branch with a clear failure message.
const expectError = (
r: { error: string; status: number } | { data: unknown },
status: number,
) => {
if (!("error" in r))
throw new Error(`expected an error result, got ${JSON.stringify(r)}`);
expect(r.status).toBe(status);
};
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
const conv = await createConversation(CONV_UID_A);
expect(conv.title).toBe("Nová konverzace");
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "Kolik je hodin v Praze?" },
{ role: "assistant", content: "Nevím, ale…" },
]);
const list = await listConversations(CONV_UID_A);
expect(list).toHaveLength(1);
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
if (!("data" in msgs)) throw new Error("expected messages, got an error");
expect(msgs.data.map((m) => m.content)).toEqual([
"Kolik je hodin v Praze?",
"Nevím, ale…",
]);
});
it("does not overwrite a renamed title on later appends", async () => {
const conv = await createConversation(CONV_UID_A);
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "tohle by nemělo přepsat název" },
]);
const list = await listConversations(CONV_UID_A);
expect(list[0].title).toBe("Moje téma");
});
it("bumps updated_at on append", async () => {
const conv = await createConversation(CONV_UID_A);
// Force an old timestamp, then prove append refreshes it (TIMESTAMP(0) is
// second-precision, so compare against a year rather than ms deltas).
await prisma.ai_conversations.update({
where: { id: conv.id },
data: { updated_at: new Date("2000-01-01T00:00:00Z") },
});
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "ping" },
]);
const after = (await listConversations(CONV_UID_A))[0].updated_at;
expect(after.getFullYear()).toBeGreaterThan(2000);
});
it("isolates conversations per user (404 on every op across owners)", async () => {
const a = await createConversation(CONV_UID_A);
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
expectError(await getConversationMessages(CONV_UID_B, a.id), 404);
expectError(
await appendConversationMessages(CONV_UID_B, a.id, [
{ role: "user", content: "x" },
]),
404,
);
expectError(await renameConversation(CONV_UID_B, a.id, "hack"), 404);
expectError(await deleteConversation(CONV_UID_B, a.id), 404);
});
it("cascade-deletes messages with the conversation", async () => {
const conv = await createConversation(CONV_UID_A);
await appendConversationMessages(CONV_UID_A, conv.id, [
{ role: "user", content: "x" },
]);
await deleteConversation(CONV_UID_A, conv.id);
const remaining = await prisma.ai_chat_messages.count({
where: { conversation_id: conv.id },
});
expect(remaining).toBe(0);
});
});
describe("HTTP /api/admin/ai", () => {
let app: Awaited<ReturnType<typeof buildAiApp>>;
let adminToken: string;
let noPermToken: string;
let noPermRoleId: number;
let noPermUserId: number;
let savedKey: string;
async function buildAiApp() {
const a = Fastify({ logger: false });
await a.register(cookie);
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
a.addHook("onRequest", securityHeaders);
await a.register(aiRoutes, { prefix: "/api/admin/ai" });
return a;
}
function token(user: {
id: number;
username: string;
roleName: string | null;
}) {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
appConfig.jwt.secret,
{ expiresIn: "15m" },
);
}
beforeAll(async () => {
savedKey = appConfig.anthropic.apiKey;
app = await buildAiApp();
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("admin not found");
adminToken = token({
id: admin.id,
username: admin.username,
roleName: admin.roles?.name ?? null,
});
const stamp = Date.now();
const role = await prisma.roles.create({
data: { name: `noperm_ai_${stamp}`, display_name: "No Perm AI" },
});
noPermRoleId = role.id;
const u = await prisma.users.create({
data: {
username: `noperm_ai_${stamp}`,
first_name: "No",
last_name: "Perm",
email: `noperm_ai_${stamp}@test.local`,
password_hash:
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
role_id: role.id,
is_active: true,
},
});
noPermUserId = u.id;
noPermToken = token({
id: u.id,
username: u.username,
roleName: role.name,
});
});
afterAll(async () => {
if (app) await app.close();
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
if (noPermUserId)
await prisma.users
.deleteMany({ where: { id: noPermUserId } })
.catch(() => {});
if (noPermRoleId)
await prisma.roles
.deleteMany({ where: { id: noPermRoleId } })
.catch(() => {});
});
it("GET /usage requires ai.use", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/usage",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
});
it("GET /usage returns spend + budget for an admin", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/usage",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(typeof body.data.budget_usd).toBe("number");
expect(typeof body.data.month_spend_usd).toBe("number");
});
it("POST /chat returns 503 when AI is not configured", async () => {
(appConfig.anthropic as { apiKey: string }).apiKey = "";
const res = await app.inject({
method: "POST",
url: "/api/admin/ai/chat",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [{ role: "user", content: "ahoj" }] },
});
expect(res.statusCode).toBe(503);
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
});
it("GET /conversations requires ai.use", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
});
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
const created = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} t` },
});
expect(created.statusCode).toBe(200);
const id = created.json().data.conversation.id as number;
const appended = await app.inject({
method: "POST",
url: `/api/admin/ai/conversations/${id}/messages`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [{ role: "user", content: "ahoj" }] },
});
expect(appended.statusCode).toBe(200);
const msgs = await app.inject({
method: "GET",
url: `/api/admin/ai/conversations/${id}/messages`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(
msgs.json().data.messages.map((m: { content: string }) => m.content),
).toContain("ahoj");
const renamed = await app.inject({
method: "PATCH",
url: `/api/admin/ai/conversations/${id}`,
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} renamed` },
});
expect(renamed.json().data.conversation.title).toBe(
`${HIST_MARKER} renamed`,
);
const del = await app.inject({
method: "DELETE",
url: `/api/admin/ai/conversations/${id}`,
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(del.statusCode).toBe(200);
});
it("GET messages of a non-existent conversation → 404", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations/999999000/messages",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(404);
});
it("POST /conversations requires ai.use", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations",
headers: {
Authorization: `Bearer ${noPermToken}`,
"Content-Type": "application/json",
},
payload: { title: "x" },
});
expect(res.statusCode).toBe(403);
});
it("GET /conversations lists the user's conversations", async () => {
const created = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: `${HIST_MARKER} listed` },
});
const id = created.json().data.conversation.id as number;
const list = await app.inject({
method: "GET",
url: "/api/admin/ai/conversations",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(list.statusCode).toBe(200);
expect(
list.json().data.conversations.map((c: { id: number }) => c.id),
).toContain(id);
});
it("rejects an empty title (PATCH) and empty messages (POST) with 400", async () => {
const patch = await app.inject({
method: "PATCH",
url: "/api/admin/ai/conversations/999999000",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { title: "" },
});
expect(patch.statusCode).toBe(400);
const append = await app.inject({
method: "POST",
url: "/api/admin/ai/conversations/999999000/messages",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
payload: { messages: [] },
});
expect(append.statusCode).toBe(400);
});
});

View File

@@ -0,0 +1,445 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import { securityHeaders } from "../middleware/security";
import attendanceRoutes from "../routes/admin/attendance";
// ---------------------------------------------------------------------------
// Regression tests for the attendance create/edit wire format.
//
// The admin create/edit forms send COMBINED local datetimes
// ("YYYY-MM-DDTHH:MM:00", built by combineDatetime from a date + time input)
// for arrival_time / departure_time / break_start / break_end, and the
// service parses them with `new Date(...)`. Commit 519edce validated these
// fields with `timeString` (bare HH:MM), which rejected every create/edit
// containing a time, and CreateAttendanceSchema lacked break_start/break_end
// entirely, so breaks were silently stripped on create. These tests pin the
// combined-datetime contract at the HTTP route level.
// ---------------------------------------------------------------------------
// All fixtures live on far-future dates so they can never collide with real
// rows in the throwaway test DB; cleanup deletes exactly this range.
const RANGE_START = new Date("2098-01-01T00:00:00");
const RANGE_END = new Date("2099-01-01T00:00:00");
let app: Awaited<ReturnType<typeof buildApp>>;
let adminUserId: number;
let adminToken: string;
async function buildApp() {
const a = Fastify({ logger: false });
await a.register(cookie);
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
a.addHook("onRequest", securityHeaders);
await a.register(attendanceRoutes, { prefix: "/api/admin/attendance" });
return a;
}
/**
* Generate a JWT access token. `requireAuth` re-loads auth data from the DB
* via `loadAuthData(payload.sub)`, so only the `sub` (user id) matters here.
*/
function generateToken(user: {
id: number;
username: string;
roleName: string | null;
}): string {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
config.jwt.secret,
{ expiresIn: "15m" },
);
}
async function authPost(path: string, token: string, body: unknown) {
return app.inject({
method: "POST",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
async function authPut(path: string, token: string, body: unknown) {
return app.inject({
method: "PUT",
url: path,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
payload: body as object,
});
}
async function cleanupFixtureRows() {
await prisma.attendance.deleteMany({
where: {
user_id: adminUserId,
shift_date: { gte: RANGE_START, lt: RANGE_END },
},
});
}
beforeAll(async () => {
app = await buildApp();
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminUserId = admin.id;
adminToken = generateToken({
id: admin.id,
username: admin.username,
roleName: admin.roles?.name ?? null,
});
await cleanupFixtureRows();
});
beforeEach(async () => {
await cleanupFixtureRows();
});
afterAll(async () => {
await cleanupFixtureRows();
if (app) await app.close();
});
describe("POST /api/admin/attendance (combined datetimes)", () => {
it("creates a work record with arrival+departure datetimes and persists them", async () => {
const arrival = "2098-03-10T08:00:00";
const departure = "2098-03-10T16:30:00";
const res = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-03-10",
leave_type: "work",
arrival_time: arrival,
departure_time: departure,
notes: "attendance_wire_test work",
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: body.data.id },
});
expect(rec).toBeTruthy();
expect(rec!.leave_type).toBe("work");
expect(rec!.arrival_time?.getTime()).toBe(new Date(arrival).getTime());
expect(rec!.departure_time?.getTime()).toBe(new Date(departure).getTime());
});
it("persists break_start/break_end on create (previously silently stripped)", async () => {
const res = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-03-11",
leave_type: "work",
arrival_time: "2098-03-11T08:00:00",
departure_time: "2098-03-11T16:30:00",
break_start: "2098-03-11T12:00:00",
break_end: "2098-03-11T12:30:00",
notes: "attendance_wire_test break",
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: body.data.id },
});
expect(rec!.break_start?.getTime()).toBe(
new Date("2098-03-11T12:00:00").getTime(),
);
expect(rec!.break_end?.getTime()).toBe(
new Date("2098-03-11T12:30:00").getTime(),
);
});
it("creates a leave record with NO time fields", async () => {
const res = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-03-12",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test leave",
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: body.data.id },
});
expect(rec!.leave_type).toBe("vacation");
expect(Number(rec!.leave_hours)).toBe(8);
expect(rec!.arrival_time).toBeNull();
expect(rec!.departure_time).toBeNull();
});
it('tolerates "" for unset datetime fields (old form payloads) → null', async () => {
// The AttendanceCreate page historically always sent empty strings for
// the unfilled time fields — even for leave records — which 400'd EVERY
// submit after 519edce. The schema must treat "" as "not set".
const res = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-03-13",
leave_type: "sick",
leave_hours: 8,
arrival_time: "",
departure_time: "",
break_start: "",
break_end: "",
notes: "attendance_wire_test empty strings",
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: body.data.id },
});
expect(rec!.leave_type).toBe("sick");
expect(rec!.arrival_time).toBeNull();
expect(rec!.departure_time).toBeNull();
expect(rec!.break_start).toBeNull();
expect(rec!.break_end).toBeNull();
});
});
describe("GET /api/admin/attendance (complete month, no truncation)", () => {
it("returns every record of a month with more than 100 rows", async () => {
// The admin month view computes the KPI cards and summary totals from
// this single fetch, so it must cover the COMPLETE month. With the old
// 100-row pagination cap, months with >100 records were silently
// truncated (newest-first) and the screen totals dropped the earliest
// days while the print stayed complete.
const rows = [];
for (let day = 1; day <= 30; day++) {
for (let s = 0; s < 4; s++) {
rows.push({
user_id: adminUserId,
shift_date: new Date(2098, 4, day, 12, 0, 0),
leave_type: "work" as const,
arrival_time: new Date(2098, 4, day, 6 + s, 0, 0),
departure_time: new Date(2098, 4, day, 10 + s, 0, 0),
notes: "attendance_wire_test bulk month",
});
}
}
await prisma.attendance.createMany({ data: rows });
const res = await app.inject({
method: "GET",
url: "/api/admin/attendance?year=2098&month=5&limit=2000",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data).toHaveLength(120);
expect(body.pagination.total).toBe(120);
});
});
describe("GET /api/admin/attendance (month window boundaries, @db.Date)", () => {
it("includes the month's own LAST day and excludes the neighbor month's first day", async () => {
// shift_date is @db.Date — Prisma compares the filter Dates by their UTC
// date part. The old LOCAL-midnight month bounds (22:00/23:00 UTC of the
// previous day) shifted the whole window a day back: the June list
// included May 31 and DROPPED June 30. Fixture rows sit exactly on the
// 2098-06 / 2098-07 boundary.
const juneLast = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 5, 30, 12, 0, 0), // 2098-06-30, local noon
leave_type: "work",
arrival_time: new Date(2098, 5, 30, 8, 0, 0),
departure_time: new Date(2098, 5, 30, 16, 0, 0),
notes: "attendance_wire_test june-last-day",
},
});
const julyFirst = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 6, 1, 12, 0, 0), // 2098-07-01, local noon
leave_type: "work",
arrival_time: new Date(2098, 6, 1, 8, 0, 0),
departure_time: new Date(2098, 6, 1, 16, 0, 0),
notes: "attendance_wire_test july-first-day",
},
});
const june = await app.inject({
method: "GET",
url: "/api/admin/attendance?year=2098&month=6&limit=100",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(june.statusCode).toBe(200);
const juneIds = june.json().data.map((r: { id: number }) => r.id);
expect(juneIds).toContain(juneLast.id);
expect(juneIds).not.toContain(julyFirst.id);
const july = await app.inject({
method: "GET",
url: "/api/admin/attendance?year=2098&month=7&limit=100",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(july.statusCode).toBe(200);
const julyIds = july.json().data.map((r: { id: number }) => r.id);
expect(julyIds).toContain(julyFirst.id);
expect(julyIds).not.toContain(juneLast.id);
});
});
describe("POST /api/admin/attendance (same-day duplicate window, @db.Date)", () => {
it("rejects a second leave record on the SAME day", async () => {
const first = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-10",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test dup-leave-first",
});
expect(first.statusCode).toBe(201);
// Old bug: the duplicate/overlap window was built from LOCAL midnights of
// the UTC-midnight shiftDate, so the validation queried ONLY the previous
// day — a same-day duplicate leave sailed through undetected.
const second = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-10",
leave_type: "sick",
leave_hours: 8,
notes: "attendance_wire_test dup-leave-second",
});
expect(second.statusCode).toBe(400);
expect(second.json().success).toBe(false);
});
it("does NOT falsely reject a leave on the day AFTER an existing record", async () => {
const first = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-10",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test neighbor-day-first",
});
expect(first.statusCode).toBe(201);
// Old bug (the other half): creating a record for the NEXT day queried
// the window [Aug 10, Aug 11) — i.e. yesterday's records — and bounced
// with a false "záznam o nepřítomnosti již existuje".
const nextDay = await authPost("/api/admin/attendance", adminToken, {
user_id: adminUserId,
shift_date: "2098-08-11",
leave_type: "vacation",
leave_hours: 8,
notes: "attendance_wire_test neighbor-day-next",
});
expect(nextDay.statusCode).toBe(201);
expect(nextDay.json().success).toBe(true);
});
});
describe("PUT /api/admin/attendance/:id (combined datetimes)", () => {
it("updates a record with combined datetimes (incl. overnight departure)", async () => {
const created = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 2, 14, 12, 0, 0),
leave_type: "work",
arrival_time: new Date("2098-03-14T08:00:00"),
departure_time: new Date("2098-03-14T16:30:00"),
notes: "attendance_wire_test update",
},
});
const res = await authPut(
`/api/admin/attendance/${created.id}`,
adminToken,
{
leave_type: "work",
arrival_time: "2098-03-14T22:00:00",
departure_time: "2098-03-15T06:00:00",
break_start: "2098-03-15T02:00:00",
break_end: "2098-03-15T02:30:00",
notes: "attendance_wire_test updated",
},
);
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: created.id },
});
expect(rec!.arrival_time?.getTime()).toBe(
new Date("2098-03-14T22:00:00").getTime(),
);
expect(rec!.departure_time?.getTime()).toBe(
new Date("2098-03-15T06:00:00").getTime(),
);
expect(rec!.break_start?.getTime()).toBe(
new Date("2098-03-15T02:00:00").getTime(),
);
expect(rec!.break_end?.getTime()).toBe(
new Date("2098-03-15T02:30:00").getTime(),
);
expect(rec!.notes).toBe("attendance_wire_test updated");
});
it("clears times when null is sent (leave-type edit)", async () => {
const created = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(2098, 2, 16, 12, 0, 0),
leave_type: "work",
arrival_time: new Date("2098-03-16T08:00:00"),
departure_time: new Date("2098-03-16T16:30:00"),
notes: "attendance_wire_test to-leave",
},
});
const res = await authPut(
`/api/admin/attendance/${created.id}`,
adminToken,
{
leave_type: "vacation",
leave_hours: 8,
arrival_time: null,
departure_time: null,
break_start: null,
break_end: null,
notes: "attendance_wire_test to-leave",
},
);
expect(res.statusCode).toBe(200);
expect(res.json().success).toBe(true);
const rec = await prisma.attendance.findUnique({
where: { id: created.id },
});
expect(rec!.leave_type).toBe("vacation");
expect(rec!.arrival_time).toBeNull();
expect(rec!.departure_time).toBeNull();
});
});

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { verifyAccessToken, hashToken } from "../services/auth";
import jwt from "jsonwebtoken";
import { config } from "../config/env";
import prisma from "../config/database";
// Prefix for fixtures so cleanup never touches real data.
const N = "auth_svc_test_";
let testUserId: number;
let testUsername: string;
let testRoleName: string | null;
beforeAll(async () => {
// Clean up any leftover fixtures from a previous failed run.
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
// Use a real (active, unlocked) user from the seeded DB so loadAuthData
// returns a full AuthData. We don't need a specific role — any active user
// works — but we record its role so we can assert roleName precisely.
const role = await prisma.roles.findFirst();
if (!role) throw new Error("Test setup: no roles found — seed the database");
const user = await prisma.users.create({
data: {
username: `${N}user`,
email: `${N}user@test.local`,
// DUMMY_HASH used elsewhere in the suite; we never verify a password
// here, only that the token's `sub` resolves to this user.
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "Auth",
last_name: "Service",
is_active: true,
role_id: role.id,
},
include: { roles: true },
});
testUserId = user.id;
testUsername = user.username;
testRoleName = user.roles?.name ?? null;
});
afterAll(async () => {
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
});
describe("auth service", () => {
describe("verifyAccessToken", () => {
it("returns AuthData for a valid token signed for a real user", async () => {
const token = jwt.sign(
{ sub: testUserId, username: testUsername, role: testRoleName },
config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry, algorithm: "HS256" },
);
const result = await verifyAccessToken(token);
expect(result).not.toBeNull();
expect(result!.userId).toBe(testUserId);
expect(result!.username).toBe(testUsername);
expect(result!.roleName).toBe(testRoleName);
// Permissions resolve to an array (admins get all, others get role perms).
expect(Array.isArray(result!.permissions)).toBe(true);
});
it("returns null WITHOUT logging for an invalid JWT (expected condition)", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const result = await verifyAccessToken("invalid-token");
expect(result).toBeNull();
// Invalid/expired access tokens are an expected condition (the client
// refreshes on the resulting 401), so verifyAccessToken deliberately
// does NOT log them as errors — only genuinely unexpected failures.
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
it("returns null WITHOUT logging for an expired JWT", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const expiredToken = jwt.sign(
{ sub: 1, username: "test", role: "user" },
config.jwt.secret,
{ expiresIn: -1 },
);
const result = await verifyAccessToken(expiredToken);
expect(result).toBeNull();
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
describe("hashToken", () => {
it("matches the known SHA-256 vector for a fixed input", () => {
// SHA-256("hello") — pins the algorithm so a silent change (e.g. to a
// different but still 64-hex-deterministic hash) is caught.
expect(hashToken("hello")).toBe(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
);
});
it("produces deterministic SHA-256 hex output", () => {
const t1 = hashToken("hello");
const t2 = hashToken("hello");
expect(t1).toBe(t2);
expect(t1).toMatch(/^[a-f0-9]{64}$/);
});
it("produces different hashes for different inputs", () => {
const t1 = hashToken("a");
const t2 = hashToken("b");
expect(t1).not.toBe(t2);
});
});
});

View File

@@ -1,12 +1,73 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import bcrypt from "bcryptjs";
import { buildApp, extractCookie } from "./helpers";
import prisma from "../config/database";
import { config } from "../config/env";
let app: Awaited<ReturnType<typeof buildApp>>;
// Fixture prefix so cleanup never touches real data.
const N = "auth_test_";
const TEST_USERNAME = `${N}user`;
const TEST_PASSWORD = "Sup3r-Secret-Pw!";
let testUserId: number;
/** Pull every Set-Cookie header line for a given cookie name (raw, with attrs). */
function rawSetCookie(res: { headers: Record<string, any> }, name: string) {
const cookies = res.headers["set-cookie"];
if (!cookies) return undefined;
const arr = Array.isArray(cookies) ? cookies : [cookies];
return arr.find((c: string) => c.startsWith(`${name}=`));
}
beforeAll(async () => {
app = await buildApp();
// Clean up any leftover fixture from a previous failed run, plus its tokens.
const leftovers = await prisma.users.findMany({
where: { username: { startsWith: N } },
select: { id: true },
});
if (leftovers.length) {
await prisma.refresh_tokens.deleteMany({
where: { user_id: { in: leftovers.map((u) => u.id) } },
});
await prisma.users.deleteMany({ where: { username: { startsWith: N } } });
}
const role = await prisma.roles.findFirst();
if (!role) throw new Error("Test setup: no roles found — seed the database");
// Hash the known password with the SAME bcrypt cost the app uses so login's
// bcrypt.compare succeeds.
const passwordHash = await bcrypt.hash(
TEST_PASSWORD,
config.security.bcryptCost,
);
const user = await prisma.users.create({
data: {
username: TEST_USERNAME,
email: `${N}user@test.local`,
password_hash: passwordHash,
first_name: "Auth",
last_name: "Test",
is_active: true,
totp_enabled: false,
role_id: role.id,
},
});
testUserId = user.id;
});
afterAll(async () => {
await prisma.refresh_tokens
.deleteMany({ where: { user_id: testUserId } })
.catch(() => {});
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
await app.close();
});
@@ -29,6 +90,39 @@ describe("POST /api/admin/login", () => {
});
expect(res.statusCode).toBe(400);
});
it("returns 401 for a valid user with the wrong password", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/login",
payload: { username: TEST_USERNAME, password: "definitely-wrong" },
});
expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false);
});
it("returns 200 with an access token and a refresh cookie on valid credentials", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/login",
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(typeof body.data.access_token).toBe("string");
expect(body.data.access_token.length).toBeGreaterThan(0);
expect(body.data.expires_in).toBe(config.jwt.accessTokenExpiry);
expect(body.data.user.userId).toBe(testUserId);
expect(body.data.user.username).toBe(TEST_USERNAME);
// The refresh token must be set as an httpOnly, SameSite=Strict cookie.
const cookie = extractCookie(res, "refresh_token");
expect(cookie).toBeTruthy();
const rawCookie = rawSetCookie(res, "refresh_token")!;
expect(rawCookie).toMatch(/HttpOnly/i);
expect(rawCookie).toMatch(/SameSite=Strict/i);
});
});
describe("POST /api/admin/refresh", () => {
@@ -39,14 +133,88 @@ describe("POST /api/admin/refresh", () => {
});
expect(res.statusCode).toBe(401);
});
it("returns 401 for an invalid/unknown refresh token", async () => {
const res = await app.inject({
method: "POST",
url: "/api/admin/refresh",
cookies: { refresh_token: "not-a-real-token" },
});
expect(res.statusCode).toBe(401);
expect(res.json().success).toBe(false);
});
it("rotates a valid refresh cookie into a new access token + new refresh cookie", async () => {
// First log in to obtain a valid refresh cookie.
const loginRes = await app.inject({
method: "POST",
url: "/api/admin/login",
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
expect(loginRes.statusCode).toBe(200);
const originalRefresh = extractCookie(loginRes, "refresh_token")!;
expect(originalRefresh).toBeTruthy();
const refreshRes = await app.inject({
method: "POST",
url: "/api/admin/refresh",
cookies: { refresh_token: originalRefresh },
});
expect(refreshRes.statusCode).toBe(200);
const body = refreshRes.json();
expect(body.success).toBe(true);
expect(typeof body.data.access_token).toBe("string");
expect(body.data.user.userId).toBe(testUserId);
// The refresh token must be ROTATED: a new cookie value, different from
// the one we presented.
const rotated = extractCookie(refreshRes, "refresh_token");
expect(rotated).toBeTruthy();
expect(rotated).not.toBe(originalRefresh);
// Reuse-detection: presenting the now-consumed original token must fail.
const reuseRes = await app.inject({
method: "POST",
url: "/api/admin/refresh",
cookies: { refresh_token: originalRefresh },
});
expect(reuseRes.statusCode).toBe(401);
});
});
describe("POST /api/admin/logout", () => {
it("clears refresh token cookie", async () => {
it("clears the refresh token cookie", async () => {
// Log in so logout has a real token to delete.
const loginRes = await app.inject({
method: "POST",
url: "/api/admin/login",
payload: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
const refresh = extractCookie(loginRes, "refresh_token")!;
const res = await app.inject({
method: "POST",
url: "/api/admin/logout",
cookies: { refresh_token: refresh },
});
expect(res.statusCode).toBeLessThan(500);
expect(res.statusCode).toBe(200);
// The response must actively clear the cookie — an expiry in the past
// and/or an empty value (clearCookie sets Max-Age=0 / Expires in the past).
const raw = rawSetCookie(res, "refresh_token");
expect(raw).toBeTruthy();
const cleared =
/Max-Age=0/i.test(raw!) ||
/Expires=Thu, 01 Jan 1970/i.test(raw!) ||
/^refresh_token=;/.test(raw!);
expect(cleared).toBe(true);
// The deleted token must no longer be usable for refresh.
const reuseRes = await app.inject({
method: "POST",
url: "/api/admin/refresh",
cookies: { refresh_token: refresh },
});
expect(reuseRes.statusCode).toBe(401);
});
});

View File

@@ -0,0 +1,152 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import bankAccountsRoutes from "../routes/admin/bank-accounts";
import prisma from "../config/database";
import { config } from "../config/env";
// Fixture marker so cleanup never touches real data.
const N = "bank_accounts_test_";
let app: ReturnType<typeof Fastify>;
let token: string;
const createdIds: number[] = [];
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(bankAccountsRoutes, {
prefix: "/api/admin/bank-accounts",
});
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
// Admin role bypasses the settings.banking permission guard.
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
if (!adminRole)
throw new Error("Test setup: no admin role found — seed the database");
const user = await prisma.users.create({
data: {
username: `${N}user`,
email: `${N}user@test.local`,
password_hash: "x",
first_name: "Bank",
last_name: "Test",
is_active: true,
totp_enabled: false,
role_id: adminRole.id,
},
});
token = jwt.sign(
{ sub: user.id, username: user.username, role: "admin" },
config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry },
);
});
afterAll(async () => {
if (createdIds.length) {
await prisma.bank_accounts
.deleteMany({ where: { id: { in: createdIds } } })
.catch(() => {});
}
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
await app.close();
});
const auth = { authorization: () => `Bearer ${token}` };
describe("bank accounts — flat-body create/update persists all fields", () => {
it("creates an account from a flat top-level body and persists every field", async () => {
const payload = {
account_name: `${N}Hlavní účet`,
bank_name: "Komerční banka",
account_number: "123456789/0100",
iban: "CZ6508000000192000145399",
bic: "KOMBCZPP",
currency: "CZK",
is_default: true,
};
const post = await app.inject({
method: "POST",
url: "/api/admin/bank-accounts/",
headers: { authorization: auth.authorization() },
payload,
});
expect(post.statusCode).toBe(201);
const id = post.json().data.id as number;
expect(typeof id).toBe("number");
createdIds.push(id);
// Read back via the list endpoint and confirm NONE of the fields are blank.
const row = await prisma.bank_accounts.findUnique({ where: { id } });
expect(row?.account_name).toBe(payload.account_name);
expect(row?.bank_name).toBe(payload.bank_name);
expect(row?.account_number).toBe(payload.account_number);
expect(row?.iban).toBe(payload.iban);
expect(row?.bic).toBe(payload.bic);
expect(row?.currency).toBe("CZK");
expect(row?.is_default).toBe(true);
});
it("updates an account from a flat body that also carries the id", async () => {
const created = await prisma.bank_accounts.create({
data: { account_name: `${N}orig`, bank_name: "Orig", currency: "CZK" },
});
createdIds.push(created.id);
// Mirrors the client payload: spread fields + id (id routes the URL/method,
// is declared in the schema, and is ignored by the route which uses :id).
const put = await app.inject({
method: "PUT",
url: `/api/admin/bank-accounts/${created.id}`,
headers: { authorization: auth.authorization() },
payload: {
id: created.id,
account_name: `${N}upraveno`,
bank_name: "Nová banka",
iban: "CZ9999",
currency: "EUR",
is_default: false,
},
});
expect(put.statusCode).toBe(200);
const row = await prisma.bank_accounts.findUnique({
where: { id: created.id },
});
expect(row?.account_name).toBe(`${N}upraveno`);
expect(row?.bank_name).toBe("Nová banka");
expect(row?.iban).toBe("CZ9999");
expect(row?.currency).toBe("EUR");
});
it("rejects the old nested {bank:{...}} body shape with 400 (no silent blank save)", async () => {
const post = await app.inject({
method: "POST",
url: "/api/admin/bank-accounts/",
headers: { authorization: auth.authorization() },
payload: { bank: { account_name: `${N}nested`, bank_name: "X" } },
});
expect(post.statusCode).toBe(400);
// And nothing was persisted under that name.
const leaked = await prisma.bank_accounts.findFirst({
where: { account_name: `${N}nested` },
});
expect(leaked).toBeNull();
});
it("requires authentication", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/bank-accounts/",
});
expect(res.statusCode).toBe(401);
});
});

View File

@@ -0,0 +1,132 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import companySettingsRoutes from "../routes/admin/company-settings";
import prisma from "../config/database";
import { config } from "../config/env";
// Fixture prefix so cleanup never touches real data.
const N = "settings_numbering_test_";
let app: ReturnType<typeof Fastify>;
let token: string;
// Snapshot the prior values of the four numbering fields we mutate so we can
// restore them in afterAll — other tests read company_settings and must not be
// poisoned by our writes.
let priorValues: {
issued_order_number_pattern: string | null;
issued_order_type_code: string | null;
project_number_pattern: string | null;
project_type_code: string | null;
} | null = null;
let settingsId: number | null = null;
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(companySettingsRoutes, {
prefix: "/api/admin/company-settings",
});
// Clean up any leftover fixture from a previous failed run.
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
// Attach the fixture user to the admin role so the PUT/GET permission guards
// (settings.company / settings.system) pass.
const adminRole = await prisma.roles.findFirst({ where: { name: "admin" } });
if (!adminRole)
throw new Error("Test setup: no admin role found — seed the database");
const user = await prisma.users.create({
data: {
username: `${N}user`,
email: `${N}user@test.local`,
password_hash: "x",
first_name: "Settings",
last_name: "Test",
is_active: true,
totp_enabled: false,
role_id: adminRole.id,
},
});
token = jwt.sign(
{ sub: user.id, username: user.username, role: "admin" },
config.jwt.secret,
{ expiresIn: config.jwt.accessTokenExpiry },
);
// Snapshot existing settings so we can restore after the suite.
const existing = await prisma.company_settings.findFirst({
select: {
id: true,
issued_order_number_pattern: true,
issued_order_type_code: true,
project_number_pattern: true,
project_type_code: true,
},
});
if (existing) {
settingsId = existing.id;
priorValues = {
issued_order_number_pattern: existing.issued_order_number_pattern,
issued_order_type_code: existing.issued_order_type_code,
project_number_pattern: existing.project_number_pattern,
project_type_code: existing.project_type_code,
};
}
});
afterAll(async () => {
// Restore the four numbering fields to their pre-test values.
if (settingsId !== null && priorValues !== null) {
await prisma.company_settings
.update({ where: { id: settingsId }, data: priorValues })
.catch(() => {});
}
await prisma.users
.deleteMany({ where: { username: { startsWith: N } } })
.catch(() => {});
await app.close();
});
describe("company-settings numbering — issued orders + projects round-trip", () => {
it("persists and returns issued_order_* and project_* numbering fields", async () => {
const payload = {
issued_order_number_pattern: "{YY}V{NNNN}",
issued_order_type_code: "72",
project_number_pattern: "{YY}P{NNNN}",
project_type_code: "73",
};
const put = await app.inject({
method: "PUT",
url: "/api/admin/company-settings/",
headers: { authorization: `Bearer ${token}` },
payload,
});
expect(put.statusCode).toBe(200);
expect(put.json().success).toBe(true);
const get = await app.inject({
method: "GET",
url: "/api/admin/company-settings/",
headers: { authorization: `Bearer ${token}` },
});
expect(get.statusCode).toBe(200);
const data = get.json().data;
expect(data.issued_order_number_pattern).toBe("{YY}V{NNNN}");
expect(data.issued_order_type_code).toBe("72");
expect(data.project_number_pattern).toBe("{YY}P{NNNN}");
expect(data.project_type_code).toBe("73");
});
it("requires authentication", async () => {
const res = await app.inject({
method: "GET",
url: "/api/admin/company-settings/",
});
expect(res.statusCode).toBe(401);
});
});

View File

@@ -0,0 +1,105 @@
import { describe, it, expect } from "vitest";
import {
CreateCustomerSchema,
UpdateCustomerSchema,
} from "../schemas/customers.schema";
describe("UpdateCustomerSchema", () => {
it("rejects empty name", () => {
const result = UpdateCustomerSchema.safeParse({ name: "" });
expect(result.success).toBe(false);
});
it("accepts valid name", () => {
const result = UpdateCustomerSchema.safeParse({ name: "Acme Corp" });
expect(result.success).toBe(true);
});
it("accepts partial updates without name", () => {
const result = UpdateCustomerSchema.safeParse({ street: "Main St" });
expect(result.success).toBe(true);
});
it("rejects a name longer than 255 chars", () => {
const result = UpdateCustomerSchema.safeParse({ name: "x".repeat(256) });
expect(result.success).toBe(false);
});
it("accepts nullable address/identity fields set to null", () => {
const result = UpdateCustomerSchema.safeParse({
street: null,
city: null,
postal_code: null,
country: null,
company_id: null,
vat_id: null,
});
expect(result.success).toBe(true);
});
});
describe("CreateCustomerSchema", () => {
it("requires a name", () => {
const result = CreateCustomerSchema.safeParse({ street: "Main St" });
expect(result.success).toBe(false);
});
it("accepts a full valid payload with all optional fields", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
street: "Main St 1",
city: "Praha",
postal_code: "11000",
country: "CZ",
company_id: "12345678",
vat_id: "CZ12345678",
custom_fields: [{ label: "Note", value: "VIP" }],
customer_field_order: ["name", "street"],
});
expect(result.success).toBe(true);
});
it("accepts custom_fields as an array of arbitrary objects", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
custom_fields: [
{ label: "a", value: 1 },
{ nested: { deep: true } },
"raw string",
],
});
expect(result.success).toBe(true);
});
it("rejects custom_fields that is not an array", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
custom_fields: { not: "an array" },
});
expect(result.success).toBe(false);
});
it("rejects custom_fields longer than the 100-item cap", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
custom_fields: Array.from({ length: 101 }, (_, i) => ({ i })),
});
expect(result.success).toBe(false);
});
it("rejects customer_field_order entries that are not strings", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
customer_field_order: ["name", 5],
});
expect(result.success).toBe(false);
});
it("rejects an over-length nullable field (vat_id > 255)", () => {
const result = CreateCustomerSchema.safeParse({
name: "Acme Corp",
vat_id: "x".repeat(256),
});
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import dashboardRoutes from "../routes/admin/dashboard";
// ---------------------------------------------------------------------------
// Regression test for the "Docházka dnes" / "Přítomní dnes" dashboard window.
//
// attendance.shift_date is @db.Date and Prisma truncates a filter Date to its
// UTC date part. The dashboard used local-midnight boundaries
// (new Date(y, m, d) = 22:00/23:00 UTC of the PREVIOUS day under
// TZ=Europe/Prague), which truncated to [yesterday, today) — today's punches
// matched zero rows and everyone showed as absent. The boundaries must be
// UTC-midnight instants of the LOCAL calendar day.
//
// Unlike the other suites this one has to use the REAL current date (the
// route computes "today" internally), so fixtures are tracked by id and
// deleted in afterAll.
// ---------------------------------------------------------------------------
let app: Awaited<ReturnType<typeof buildApp>>;
let adminUserId: number;
let adminToken: string;
const createdIds: number[] = [];
async function buildApp() {
const a = Fastify({ logger: false });
await a.register(dashboardRoutes, { prefix: "/api/admin/dashboard" });
return a;
}
beforeAll(async () => {
app = await buildApp();
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
include: { roles: true },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminUserId = admin.id;
adminToken = jwt.sign(
{ sub: admin.id, username: admin.username, role: "admin" },
config.jwt.secret,
{ expiresIn: "15m" },
);
});
afterAll(async () => {
if (createdIds.length) {
await prisma.attendance.deleteMany({ where: { id: { in: createdIds } } });
}
if (app) await app.close();
});
describe("GET /api/admin/dashboard — today's attendance window", () => {
it("counts a punched-in user as present (local-noon @db.Date row)", async () => {
const now = new Date();
// The attendance regime stores shift_date at LOCAL NOON (so the UTC date
// part equals the Prague calendar date) — same as punchAction does.
const row = await prisma.attendance.create({
data: {
user_id: adminUserId,
shift_date: new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
12,
0,
0,
),
leave_type: "work",
arrival_time: now,
departure_time: null,
notes: "dashboard_window_test",
},
});
createdIds.push(row.id);
const res = await app.inject({
method: "GET",
url: "/api/admin/dashboard",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
const attendance = body.data.attendance;
expect(attendance).toBeTruthy();
const me = attendance.users.find(
(u: { user_id: number }) => u.user_id === adminUserId,
);
expect(me).toBeTruthy();
expect(me.status).toBe("in");
expect(attendance.present_today).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,116 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createInvoice, getInvoiceStats } from "../services/invoices.service";
import { createOffer } from "../services/offers.service";
// These tests prove that DRAFT documents (a status introduced by the DB-drafts
// feature) are NOT counted in period reporting aggregations. A draft is not a
// real financial document, so its VAT/amount/created-count must be excluded.
//
// They hit the real `app_test` DB (per the suite convention) via the service
// layer directly. Every row we create is tracked and removed in afterEach so we
// leave the DB clean, and we reset the touched number sequences so a finalized
// (issued/active) doc created here doesn't leak a consumed number into other
// files.
const invoiceIds: number[] = [];
const offerIds: number[] = [];
afterEach(async () => {
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
for (const id of offerIds)
await prisma.quotations.deleteMany({ where: { id } });
invoiceIds.length = 0;
offerIds.length = 0;
await prisma.number_sequences.deleteMany({
where: { type: { in: ["invoice", "offer"] } },
});
});
describe("drafts excluded from invoice monthly stats (VAT)", () => {
it("a draft invoice's VAT is NOT in vat_month while an issued one IS", async () => {
// Pick a far-future month so no seeded/other-test invoices fall in it and
// the totals are deterministic. issue_date drives the stats window.
const year = 2099;
const month = 7;
const issueDate = `${year}-0${month}-15`;
// Single CZK line, 21% VAT: base 1000, VAT 210. Identical on both docs so
// the only difference between "draft excluded" and "issued counted" is the
// status filter under test.
const item = { quantity: 1, unit_price: 1000, vat_rate: 21 };
const draft = await createInvoice({
status: "draft",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
issue_date: issueDate,
items: [item],
});
invoiceIds.push(draft.id);
expect(draft.status).toBe("draft");
expect(draft.invoice_number).toBeNull();
// Sanity: with ONLY the draft present, vat_month must be empty (draft excluded).
const draftOnly = await getInvoiceStats(month, year);
expect(draftOnly.vat_month).toEqual([]);
expect(draftOnly.vat_month_czk).toBe(0);
const issued = await createInvoice({
status: "issued",
currency: "CZK",
apply_vat: true,
vat_rate: 21,
issue_date: issueDate,
items: [item],
});
invoiceIds.push(issued.id);
expect(issued.status).toBe("issued");
// Now the issued invoice's VAT (210 CZK) must be counted — and ONLY that
// one, not the draft's (which would double it to 420 if drafts leaked in).
const stats = await getInvoiceStats(month, year);
const czk = stats.vat_month.find((v) => v.currency === "CZK");
expect(czk?.amount).toBe(210);
expect(stats.vat_month_czk).toBe(210);
});
});
describe("drafts excluded from quotations created-this-month count", () => {
it("a draft offer is NOT counted; an active one IS", async () => {
// Mirror the dashboard route's exact aggregation: count quotations created
// in the window, excluding drafts. Use a tight window around `now` (the
// route uses the current month) and assert the delta this test introduces.
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const countNonDraft = () =>
prisma.quotations.count({
where: {
status: { not: "draft" },
created_at: { gte: monthStart, lt: monthEnd },
},
});
const before = await countNonDraft();
const draft = await createOffer({ status: "draft" });
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
offerIds.push(draft.id);
expect(draft.status).toBe("draft");
// The draft must not move the non-draft count.
expect(await countNonDraft()).toBe(before);
const active = await createOffer({ status: "active" });
if ("error" in active)
throw new Error(`createOffer failed: ${active.error}`);
offerIds.push(active.id);
// The active offer is a real document → the count goes up by exactly 1.
expect(await countNonDraft()).toBe(before + 1);
});
});

View File

@@ -0,0 +1,285 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import {
previewIssuedOrderNumber,
previewInvoiceNumber,
previewOfferNumber,
} from "../services/numbering.service";
import {
createIssuedOrder,
updateIssuedOrder,
deleteIssuedOrder,
} from "../services/issued-orders.service";
import {
createInvoice,
updateInvoice,
deleteInvoice,
} from "../services/invoices.service";
import {
createOffer,
updateOffer,
deleteOffer,
} from "../services/offers.service";
// Track every row we create so the suite leaves the (real) app_test DB clean.
const issuedOrderIds: number[] = [];
const invoiceIds: number[] = [];
const offerIds: number[] = [];
afterEach(async () => {
for (const id of issuedOrderIds)
await prisma.issued_orders.deleteMany({ where: { id } });
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
for (const id of offerIds)
await prisma.quotations.deleteMany({ where: { id } });
issuedOrderIds.length = 0;
invoiceIds.length = 0;
offerIds.length = 0;
// Reset every sequence this suite may have touched so preview values are
// stable across tests and we don't leak consumed numbers into other files.
await prisma.number_sequences.deleteMany({
where: { type: { in: ["issued_order", "invoice", "offer"] } },
});
});
/* -------------------------------------------------------------------------- */
/* Issued orders */
/* -------------------------------------------------------------------------- */
describe("issued-order drafts — deferred numbering", () => {
it("a draft has no number and does NOT consume the sequence", async () => {
const before = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({}); // defaults to draft
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
expect(draft.status).toBe("draft");
expect(draft.po_number).toBeNull();
// Preview is unchanged → the sequence was not consumed.
const after = (await previewIssuedOrderNumber()).number;
expect(after).toBe(before);
});
it("finalizing a draft (draft -> sent) assigns the next sequence number", async () => {
const expected = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
const res = await updateIssuedOrder(draft.id, { status: "sent" });
expect("error" in res).toBe(false);
const row = await prisma.issued_orders.findUnique({
where: { id: draft.id },
});
expect(row?.status).toBe("sent");
expect(row?.po_number).toBe(expected);
expect("po_number" in res && res.po_number).toBe(expected);
});
it("finalizing is idempotent — re-finalizing does not re-number", async () => {
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
issuedOrderIds.push(draft.id);
await updateIssuedOrder(draft.id, { status: "sent" });
const first = (
await prisma.issued_orders.findUnique({ where: { id: draft.id } })
)?.po_number;
const seqAfterFirst = await prisma.number_sequences.findFirst({
where: { type: "issued_order" },
});
// draft -> sent is no longer valid (already sent); the number must not change.
// Drive a second finalize-style update that keeps status sent.
await updateIssuedOrder(draft.id, { status: "sent" });
const second = (
await prisma.issued_orders.findUnique({ where: { id: draft.id } })
)?.po_number;
const seqAfterSecond = await prisma.number_sequences.findFirst({
where: { type: "issued_order" },
});
expect(second).toBe(first);
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
});
it("two drafts coexist with null numbers (no unique violation)", async () => {
const a = await createIssuedOrder({});
const b = await createIssuedOrder({});
if ("error" in a) throw new Error(`createIssuedOrder failed: ${a.error}`);
if ("error" in b) throw new Error(`createIssuedOrder failed: ${b.error}`);
issuedOrderIds.push(a.id, b.id);
expect(a.po_number).toBeNull();
expect(b.po_number).toBeNull();
});
it("deleting a draft releases nothing (no number was consumed)", async () => {
const before = (await previewIssuedOrderNumber()).number;
const draft = await createIssuedOrder({});
if ("error" in draft)
throw new Error(`createIssuedOrder failed: ${draft.error}`);
await deleteIssuedOrder(draft.id);
const after = (await previewIssuedOrderNumber()).number;
expect(after).toBe(before);
});
});
/* -------------------------------------------------------------------------- */
/* Invoices */
/* -------------------------------------------------------------------------- */
describe("invoice drafts — deferred numbering", () => {
it("a draft has no number and does NOT consume the sequence", async () => {
const before = (await previewInvoiceNumber()).number;
const draft = await createInvoice({});
invoiceIds.push(draft.id);
expect(draft.status).toBe("draft");
expect(draft.invoice_number).toBeNull();
const after = (await previewInvoiceNumber()).number;
expect(after).toBe(before);
});
it("finalizing a draft (draft -> issued) assigns the next sequence number", async () => {
const expected = (await previewInvoiceNumber()).number;
const draft = await createInvoice({});
invoiceIds.push(draft.id);
const res = await updateInvoice(draft.id, { status: "issued" });
expect("error" in res).toBe(false);
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.status).toBe("issued");
expect(row?.invoice_number).toBe(expected);
expect("invoice_number" in res && res.invoice_number).toBe(expected);
});
it("finalizing twice does not re-number / double-consume", async () => {
const draft = await createInvoice({});
invoiceIds.push(draft.id);
await updateInvoice(draft.id, { status: "issued" });
const first = (
await prisma.invoices.findUnique({ where: { id: draft.id } })
)?.invoice_number;
const seqAfterFirst = await prisma.number_sequences.findFirst({
where: { type: "invoice" },
});
// issued -> issued is a no-op status-wise; the number must stay put.
await updateInvoice(draft.id, { status: "issued" });
const second = (
await prisma.invoices.findUnique({ where: { id: draft.id } })
)?.invoice_number;
const seqAfterSecond = await prisma.number_sequences.findFirst({
where: { type: "invoice" },
});
expect(second).toBe(first);
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
});
it("two invoice drafts coexist with null numbers", async () => {
const a = await createInvoice({});
const b = await createInvoice({});
invoiceIds.push(a.id, b.id);
expect(a.invoice_number).toBeNull();
expect(b.invoice_number).toBeNull();
});
it("deleting a draft releases nothing", async () => {
const before = (await previewInvoiceNumber()).number;
const draft = await createInvoice({});
await deleteInvoice(draft.id);
const after = (await previewInvoiceNumber()).number;
expect(after).toBe(before);
});
});
/* -------------------------------------------------------------------------- */
/* Offers */
/* -------------------------------------------------------------------------- */
describe("offer drafts — deferred numbering", () => {
it("a draft has no number and does NOT consume the sequence", async () => {
const before = await previewOfferNumber();
const draft = await createOffer({});
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
offerIds.push(draft.id);
expect(draft.status).toBe("draft");
expect(draft.quotation_number).toBeNull();
const after = await previewOfferNumber();
expect(after).toBe(before);
});
it("finalizing a draft (draft -> active) assigns the next sequence number", async () => {
const expected = await previewOfferNumber();
const draft = await createOffer({});
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
offerIds.push(draft.id);
const res = await updateOffer(draft.id, { status: "active" });
expect("error" in res).toBe(false);
const row = await prisma.quotations.findUnique({ where: { id: draft.id } });
expect(row?.status).toBe("active");
expect(row?.quotation_number).toBe(expected);
expect("quotation_number" in res && res.quotation_number).toBe(expected);
});
it("finalizing twice does not re-number / double-consume", async () => {
const draft = await createOffer({});
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
offerIds.push(draft.id);
await updateOffer(draft.id, { status: "active" });
const first = (
await prisma.quotations.findUnique({ where: { id: draft.id } })
)?.quotation_number;
const seqAfterFirst = await prisma.number_sequences.findFirst({
where: { type: "offer" },
});
// active -> active is a no-op; the number must stay put.
await updateOffer(draft.id, { status: "active" });
const second = (
await prisma.quotations.findUnique({ where: { id: draft.id } })
)?.quotation_number;
const seqAfterSecond = await prisma.number_sequences.findFirst({
where: { type: "offer" },
});
expect(second).toBe(first);
expect(seqAfterSecond?.last_number).toBe(seqAfterFirst?.last_number);
});
it("two offer drafts coexist with null numbers", async () => {
const a = await createOffer({});
const b = await createOffer({});
if ("error" in a) throw new Error(`createOffer failed: ${a.error}`);
if ("error" in b) throw new Error(`createOffer failed: ${b.error}`);
offerIds.push(a.id, b.id);
expect(a.quotation_number).toBeNull();
expect(b.quotation_number).toBeNull();
});
it("an offer created already-finalized (status active) numbers immediately", async () => {
const expected = await previewOfferNumber();
const offer = await createOffer({ status: "active" });
if ("error" in offer) throw new Error(`createOffer failed: ${offer.error}`);
offerIds.push(offer.id);
expect(offer.quotation_number).toBe(expected);
});
it("deleting a draft releases nothing", async () => {
const before = await previewOfferNumber();
const draft = await createOffer({});
if ("error" in draft) throw new Error(`createOffer failed: ${draft.error}`);
await deleteOffer(draft.id);
const after = await previewOfferNumber();
expect(after).toBe(before);
});
});

68
src/__tests__/env.test.ts Normal file
View File

@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import { config } from "../config/env";
describe("env validation", () => {
it("has numeric port within valid range", () => {
expect(typeof config.port).toBe("number");
expect(config.port).toBeGreaterThan(0);
expect(config.port).toBeLessThanOrEqual(65535);
});
it("has JWT_SECRET defined", () => {
expect(config.jwt.secret).toBeTruthy();
expect(config.jwt.secret.length).toBeGreaterThanOrEqual(32);
});
it("has TOTP_ENCRYPTION_KEY defined", () => {
expect(config.totp.encryptionKey).toBeTruthy();
expect(config.totp.encryptionKey.length).toBeGreaterThanOrEqual(32);
});
it("has positive JWT expiry values", () => {
expect(config.jwt.accessTokenExpiry).toBeGreaterThan(0);
expect(config.jwt.refreshTokenSessionExpiry).toBeGreaterThan(0);
expect(config.jwt.refreshTokenRememberExpiry).toBeGreaterThan(0);
});
it("has positive maxUploadSize", () => {
expect(config.nas.maxUploadSize).toBeGreaterThan(0);
});
it("has DATABASE_URL defined", () => {
expect(config.db.url).toBeTruthy();
});
});
describe("env validation — failure path", () => {
// The config singleton runs its validation at module-load time and is then
// cached by the module system, so we cannot re-import it with bad env to
// observe a throw without complex module-registry resetting. Instead we test
// the exact predicates config/env.ts uses to reject bad input — this is the
// logic that protects the boot, and the loaded config above proves the
// happy path. (Predicates kept in sync with src/config/env.ts.)
const HEX64_RE = /^[0-9a-fA-F]{64}$/;
it("rejects a too-short / non-hex JWT_SECRET", () => {
expect(HEX64_RE.test("short")).toBe(false);
expect(HEX64_RE.test("a".repeat(63))).toBe(false); // one char short
expect(HEX64_RE.test("z".repeat(64))).toBe(false); // 64 chars, not hex
// The real config's secret must pass the very same check.
expect(HEX64_RE.test(config.jwt.secret)).toBe(true);
});
it("rejects an out-of-range or non-numeric PORT", () => {
const portValid = (p: number) => !Number.isNaN(p) && p >= 1 && p <= 65535;
expect(portValid(parseInt("0", 10))).toBe(false);
expect(portValid(parseInt("70000", 10))).toBe(false);
expect(portValid(parseInt("notaport", 10))).toBe(false); // NaN
expect(portValid(config.port)).toBe(true);
});
it("rejects a non-positive ACCESS_TOKEN_EXPIRY", () => {
const expiryValid = (n: number) => !Number.isNaN(n) && n > 0;
expect(expiryValid(0)).toBe(false);
expect(expiryValid(-1)).toBe(false);
expect(expiryValid(parseInt("oops", 10))).toBe(false); // NaN
expect(expiryValid(config.jwt.accessTokenExpiry)).toBe(true);
});
});

View File

@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
toCzk,
getRate,
__resetRateCacheForTest,
} from "../services/exchange-rates";
// Mock global fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe("exchange-rates", () => {
beforeEach(() => {
mockFetch.mockReset();
// The rate cache is module-level and persists across tests. Without this
// reset, the first test to populate "today" would short-circuit every
// later mockFetch resolution (cache hit), making assertions order-dependent
// and asserting stale rates. Reset it so each test gets a clean fetch.
__resetRateCacheForTest();
});
describe("toCzk", () => {
it("returns amount unchanged for CZK", async () => {
const result = await toCzk(123.45, "CZK");
expect(result).toBe(123.45);
});
it("throws for unknown currency when API fails and no cache", async () => {
mockFetch.mockRejectedValue(new Error("Network error"));
await expect(toCzk(100, "XYZ")).rejects.toThrow(
/Nepodařilo se získat aktuální kurzy/,
);
});
it("throws for unknown currency even when API succeeds", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
}),
});
await expect(toCzk(100, "XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
});
it("converts EUR using fetched rate", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
}),
});
const result = await toCzk(100, "EUR");
expect(result).toBe(2500);
});
it("honours amount != 1 (currency quoted per 100 units)", async () => {
// CNB quotes some currencies (e.g. HUF, JPY) per 100 units: the `rate`
// is for `amount` units, so the per-unit rate is rate/amount. Here the
// per-unit rate is 250/100 = 2.5 CZK; converting 100 units → 250 CZK.
// If the service ignored `amount` it would compute 100*250 = 25000.
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }],
}),
});
const result = await toCzk(100, "HUF");
expect(result).toBe(250);
});
});
describe("getRate", () => {
it("returns 1 for CZK", async () => {
const result = await getRate("CZK");
expect(result).toBe(1);
});
it("throws for unknown currency", async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "EUR", rate: 25, amount: 1 }],
}),
});
await expect(getRate("XYZ")).rejects.toThrow(/Neznámá měna: XYZ/);
});
it("returns the per-unit rate for amount != 1", async () => {
// rate 250 for amount 100 → per-unit rate 2.5 (CZK per 1 unit).
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({
rates: [{ currencyCode: "HUF", rate: 250, amount: 100 }],
}),
});
const result = await getRate("HUF");
expect(result).toBe(2.5);
});
});
});

View File

@@ -13,13 +13,31 @@ export async function buildApp() {
return app;
}
export function extractCookie(response: any, name: string): string | undefined {
/**
* Minimal structural shape of the only thing extractCookie needs from a
* response — its `set-cookie` header. Matches Fastify's `LightMyRequestResponse`
* (from `app.inject`) and a plain supertest response without coupling to either.
*/
interface ResponseWithCookies {
headers: Record<string, string | string[] | number | undefined>;
}
export function extractCookie(
response: ResponseWithCookies,
name: string,
): string | undefined {
const cookies = response.headers["set-cookie"];
if (!cookies) return undefined;
const arr = Array.isArray(cookies) ? cookies : [cookies];
for (const c of arr) {
if (typeof c !== "string") continue;
if (c.startsWith(`${name}=`)) {
return c.split(";")[0].split("=")[1];
// The value is everything from the first "=" up to the first ";".
// Split ONLY on the first "=" so a base64/JWT value containing "=" (e.g.
// padding) is not truncated — `split("=")[1]` would drop everything after
// the second "=".
const pair = c.split(";")[0];
return pair.slice(pair.indexOf("=") + 1);
}
}
return undefined;

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { computeAlertWindow } from "../services/invoice-alerts";
import { utcMidnightOfLocalDay } from "../utils/date";
// Pure tests — no DB rows are touched. due_date is @db.Date: Prisma truncates
// a Date used in a WHERE filter to its UTC date part, so the alert window
// boundaries must be UTC midnights of the LOCAL calendar day. The old code
// used local midnights (= 22:00/23:00 UTC of the PREVIOUS day), which shifted
// the [today, today+3] due_date window a day early — the "splatnost za 3 dny"
// advance alert (due == today+3) could then never fire. All assertions below
// are timezone-independent (inputs are built from local components).
describe("utcMidnightOfLocalDay", () => {
it("preserves the LOCAL calendar day shortly after local midnight (the night window)", () => {
// 00:30 local on 2098-07-01 — in Prague (UTC+2) this instant is
// 2098-06-30T22:30Z, i.e. its UTC date part is the PREVIOUS day, which is
// exactly what Prisma would truncate a bare Date to.
const justAfterMidnight = new Date(2098, 6, 1, 0, 30, 0);
expect(utcMidnightOfLocalDay(justAfterMidnight).getTime()).toBe(
Date.UTC(2098, 6, 1),
);
});
it("returns UTC midnight of the local day for a mid-day instant", () => {
const noon = new Date(2098, 0, 15, 12, 0, 0);
expect(utcMidnightOfLocalDay(noon).getTime()).toBe(Date.UTC(2098, 0, 15));
});
});
describe("computeAlertWindow", () => {
it("builds [today, today+3] as UTC midnights of the LOCAL day, with matching strings", () => {
// 00:30 local — the window where the old local-midnight computation
// produced yesterday's UTC date and the whole window slid a day early.
const now = new Date(2098, 6, 1, 0, 30, 0);
const w = computeAlertWindow(now);
expect(w.today.getTime()).toBe(Date.UTC(2098, 6, 1));
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 6, 4));
expect(w.todayStr).toBe("2098-07-01");
expect(w.in3daysStr).toBe("2098-07-04");
});
it("rolls the +3-day bound over a month boundary", () => {
const now = new Date(2098, 0, 30, 8, 0, 0); // 2098-01-30
const w = computeAlertWindow(now);
expect(w.in3days.getTime()).toBe(Date.UTC(2098, 1, 2)); // 2098-02-02
expect(w.in3daysStr).toBe("2098-02-02");
});
});

View File

@@ -0,0 +1,263 @@
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { Prisma } from "@prisma/client";
import prisma from "../config/database";
import {
invoiceTotalWithVat,
createInvoice,
updateInvoice,
listInvoices,
getInvoiceListTotals,
} from "../services/invoices.service";
import { UpdateInvoiceSchema } from "../schemas/invoices.schema";
// `invoiceTotalWithVat` receives a Prisma row whose money columns are real
// `Prisma.Decimal` instances (the model maps `@db.Decimal` → Decimal). Using
// real Decimals here — instead of the old `{ toNumber: () => N }` duck types —
// means a regression in genuine Decimal handling (e.g. swapping `.toNumber()`
// for `Number(decimal)`) is actually caught.
const dec = (n: number | string) => new Prisma.Decimal(n);
// Helper so each test reads as a small declarative table of lines.
function buildInvoice(opts: {
applyVat: boolean;
vatRate: number | null;
currency?: string;
items: Array<{
quantity: number | null;
unit_price: number | null;
vat_rate: number | null;
}>;
}) {
return {
apply_vat: opts.applyVat,
vat_rate: opts.vatRate === null ? null : dec(opts.vatRate),
currency: opts.currency ?? "CZK",
invoice_items: opts.items.map((i) => ({
quantity: i.quantity === null ? null : dec(i.quantity),
unit_price: i.unit_price === null ? null : dec(i.unit_price),
vat_rate: i.vat_rate === null ? null : dec(i.vat_rate),
})),
};
}
describe("invoiceTotalWithVat", () => {
it("calculates subtotal without VAT when apply_vat is false", () => {
const inv = buildInvoice({
applyVat: false,
vatRate: 21,
items: [{ quantity: 2, unit_price: 100, vat_rate: 21 }],
});
expect(invoiceTotalWithVat(inv)).toBe(200);
});
it("rounds each line VAT to 2 decimals before accumulation", () => {
// 3 items @ 33.33 with 21% VAT
// Line VAT = 33.33 * 0.21 = 6.9993 -> rounded to 7.00
// Total VAT = 7.00 * 3 = 21.00
// Subtotal = 33.33 * 3 = 99.99
// Total = 99.99 + 21.00 = 120.99
const inv = buildInvoice({
applyVat: true,
vatRate: 21,
items: Array.from({ length: 3 }, () => ({
quantity: 1,
unit_price: 33.33,
vat_rate: 21,
})),
});
expect(invoiceTotalWithVat(inv)).toBe(120.99);
});
it("treats a null quantity (and null unit_price) as 0 for that line", () => {
// The line is genuinely null here (not 0): quantity?.toNumber() === undefined
// -> Number(undefined) is NaN -> `|| 0` -> base 0. A second, well-formed
// line proves the null line contributes nothing while the rest still totals.
const inv = buildInvoice({
applyVat: true,
vatRate: 21,
items: [
{ quantity: null, unit_price: null, vat_rate: 21 },
{ quantity: 1, unit_price: 100, vat_rate: 21 }, // base 100, vat 21
],
});
expect(invoiceTotalWithVat(inv)).toBe(121);
});
it("returns 0 when every line is null", () => {
const inv = buildInvoice({
applyVat: true,
vatRate: 21,
items: [{ quantity: null, unit_price: null, vat_rate: null }],
});
expect(invoiceTotalWithVat(inv)).toBe(0);
});
it("applies mixed per-line vat_rate values, falling back to the invoice rate when a line rate is 0/absent", () => {
// Line 1: 2 x 100 @ 21% -> base 200, vat 42
// Line 2: 1 x 50 @ 12% -> base 50, vat 6
// Line 3: 1 x 100 @ 0% -> a line rate of 0 falls through to the invoice
// default (15%) per the `|| inv.vat_rate || 21` semantics -> vat 15
// Subtotal = 350, VAT = 63, Total = 413
const inv = buildInvoice({
applyVat: true,
vatRate: 15,
items: [
{ quantity: 2, unit_price: 100, vat_rate: 21 },
{ quantity: 1, unit_price: 50, vat_rate: 12 },
{ quantity: 1, unit_price: 100, vat_rate: 0 },
],
});
expect(invoiceTotalWithVat(inv)).toBe(413);
});
it("handles a negative (discount) line correctly", () => {
// Line 1: 1 x 1000 @ 21% -> base 1000, vat 210
// Line 2 (discount): 1 x -200 @ 21% -> base -200, vat -42
// Subtotal = 800, VAT = 168, Total = 968
const inv = buildInvoice({
applyVat: true,
vatRate: 21,
items: [
{ quantity: 1, unit_price: 1000, vat_rate: 21 },
{ quantity: 1, unit_price: -200, vat_rate: 21 },
],
});
expect(invoiceTotalWithVat(inv)).toBe(968);
});
});
/* -------------------------------------------------------------------------- */
/* PUT regression — billing_text must survive UpdateInvoiceSchema */
/* -------------------------------------------------------------------------- */
// The PUT route does `parseBody(UpdateInvoiceSchema, body)` and hands the
// PARSED data to `updateInvoice`. UpdateInvoiceSchema is a plain z.object,
// which STRIPS unknown keys — so a field missing from the schema is silently
// dropped before the service ever sees it, while the client still gets a
// success response. This block mirrors that exact flow against the real DB.
describe("updateInvoice — billing_text round-trips through UpdateInvoiceSchema", () => {
const invoiceIds: number[] = [];
afterEach(async () => {
for (const id of invoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
invoiceIds.length = 0;
});
it("persists an edited billing_text (the schema must not strip it)", async () => {
const draft = await createInvoice({ billing_text: "Původní text" });
invoiceIds.push(draft.id);
// Mirror the route: raw body -> UpdateInvoiceSchema -> updateInvoice.
const parsed = UpdateInvoiceSchema.parse({
billing_text: "Fakturujeme Vám dle objednávky č. 123",
});
expect(parsed.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
const res = await updateInvoice(draft.id, parsed);
expect("error" in res).toBe(false);
const row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBe("Fakturujeme Vám dle objednávky č. 123");
});
it("clears billing_text when null is sent, and leaves it untouched when absent", async () => {
const draft = await createInvoice({ billing_text: "Text na PDF" });
invoiceIds.push(draft.id);
// Absent from the body -> updateInvoice's `!== undefined` guard skips it.
await updateInvoice(draft.id, UpdateInvoiceSchema.parse({ notes: "x" }));
let row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBe("Text na PDF");
// Explicit null -> cleared (the strFields path maps falsy -> null).
await updateInvoice(
draft.id,
UpdateInvoiceSchema.parse({ billing_text: null }),
);
row = await prisma.invoices.findUnique({ where: { id: draft.id } });
expect(row?.billing_text).toBeNull();
});
});
/* -------------------------------------------------------------------------- */
/* Month filter boundaries — issue_date is @db.Date */
/* -------------------------------------------------------------------------- */
// issue_date is @db.Date: Prisma compares a Date filter by its UTC date part.
// The old LOCAL-midnight month bounds (= 22:00/23:00 UTC of the previous day)
// shifted the window a day back — the list/totals included the previous
// month's last day and DROPPED invoices issued on the selected month's LAST
// day. buildInvoiceWhere is shared by listInvoices AND getInvoiceListTotals,
// so both are exercised. Fixtures use the test-only "TST" currency + far-future
// 2098 dates so totals can be asserted exactly without colliding with other
// suites' rows.
describe("listInvoices / getInvoiceListTotals — month filter (@db.Date boundaries)", () => {
const cleanup = async () => {
// invoice_items cascade on invoice delete.
await prisma.invoices.deleteMany({ where: { currency: "TST" } });
};
beforeEach(cleanup);
afterEach(cleanup);
const listParams = {
page: 1,
limit: 100,
skip: 0,
sort: "id",
order: "asc" as const,
search: "",
};
async function createBoundaryFixtures() {
// Drafts: no invoice number is consumed, but buildInvoiceWhere has no
// status filter so they appear in the list/totals like any invoice.
const lastDayJan = await createInvoice({
status: "draft",
issue_date: "2098-01-31",
currency: "TST",
vat_rate: 21,
items: [{ quantity: 1, unit_price: 100, vat_rate: 21 }], // total 121
});
const firstDayFeb = await createInvoice({
status: "draft",
issue_date: "2098-02-01",
currency: "TST",
vat_rate: 21,
items: [{ quantity: 1, unit_price: 200, vat_rate: 21 }], // total 242
});
return { lastDayJan, firstDayFeb };
}
it("a month's list contains its own LAST day and not the neighbor month's first day", async () => {
const { lastDayJan, firstDayFeb } = await createBoundaryFixtures();
const jan = await listInvoices({ ...listParams, month: 1, year: 2098 });
const janIds = jan.data.map((i) => i.id);
expect(janIds).toContain(lastDayJan.id);
expect(janIds).not.toContain(firstDayFeb.id);
const feb = await listInvoices({ ...listParams, month: 2, year: 2098 });
const febIds = feb.data.map((i) => i.id);
expect(febIds).toContain(firstDayFeb.id);
expect(febIds).not.toContain(lastDayJan.id);
});
it("per-currency totals include the month's last day exactly once", async () => {
await createBoundaryFixtures();
const jan = await getInvoiceListTotals({ month: 1, year: 2098 });
const janTst = jan.totals.find((t) => t.currency === "TST");
// 100 + 21 % VAT — proves the Jan 31 invoice is counted and the
// Feb 1 invoice (242) is NOT pulled into January.
expect(janTst?.amount).toBe(121);
const feb = await getInvoiceListTotals({ month: 2, year: 2098 });
const febTst = feb.totals.find((t) => t.currency === "TST");
expect(febTst?.amount).toBe(242);
});
});

View File

@@ -0,0 +1,548 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
import Fastify from "fastify";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import {
generateIssuedOrderNumber,
previewIssuedOrderNumber,
releaseIssuedOrderNumber,
} from "../services/numbering.service";
import { CreateIssuedOrderSchema } from "../schemas/issued-orders.schema";
import {
computeIssuedOrderTotals,
createIssuedOrder,
getIssuedOrder,
listIssuedOrders,
updateIssuedOrder,
deleteIssuedOrder,
type IssuedOrderInput,
} from "../services/issued-orders.service";
import issuedOrdersRoutes from "../routes/admin/issued-orders";
import { renderIssuedOrderHtml } from "../routes/admin/issued-orders-pdf";
afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "issued_order" } });
});
describe("issued-order numbering", () => {
it("generates sequential numbers", async () => {
const year = new Date().getFullYear();
const a = await generateIssuedOrderNumber(year);
const b = await generateIssuedOrderNumber(year);
expect(Number(b.number)).toBe(Number(a.number) + 1);
});
it("preview does not consume the sequence", async () => {
const year = new Date().getFullYear();
const p1 = await previewIssuedOrderNumber(year);
const p2 = await previewIssuedOrderNumber(year);
expect(p1.number).toBe(p2.number);
});
it("release frees only the highest number", async () => {
const year = new Date().getFullYear();
await generateIssuedOrderNumber(year); // seq 1
await generateIssuedOrderNumber(year); // seq 2
const c = await generateIssuedOrderNumber(year); // seq 3 (current highest)
const beforeRelease = (await previewIssuedOrderNumber(year)).number; // seq 4
// Releasing a number that is NOT the current highest is a no-op.
await releaseIssuedOrderNumber(year, "00000000");
expect((await previewIssuedOrderNumber(year)).number).toBe(beforeRelease);
// Releasing the current highest reclaims it.
await releaseIssuedOrderNumber(year, c.number);
expect((await previewIssuedOrderNumber(year)).number).toBe(c.number);
});
});
describe("CreateIssuedOrderSchema", () => {
it("coerces string form numbers and rejects an out-of-range VAT", () => {
const ok = CreateIssuedOrderSchema.safeParse({
supplier_id: "5",
vat_rate: "21",
items: [{ description: "X", quantity: "2", unit_price: "100" }],
});
expect(ok.success).toBe(true);
if (ok.success) expect(ok.data.supplier_id).toBe(5);
const bad = CreateIssuedOrderSchema.safeParse({ vat_rate: "200" });
expect(bad.success).toBe(false);
});
});
const createdIds: number[] = [];
const createdSupplierIds: number[] = [];
afterEach(async () => {
// Orders first — the supplier FK is onDelete Restrict.
for (const id of createdIds)
await prisma.issued_orders.deleteMany({ where: { id } });
for (const id of createdSupplierIds)
await prisma.sklad_suppliers.deleteMany({ where: { id } });
createdIds.length = 0;
createdSupplierIds.length = 0;
});
async function makeSupplier(
data: Partial<{ name: string; ico: string; is_active: boolean }> = {},
) {
const s = await prisma.sklad_suppliers.create({
data: {
name: data.name ?? "io_test_Dodavatel s.r.o.",
ico: data.ico ?? null,
is_active: data.is_active ?? true,
},
});
createdSupplierIds.push(s.id);
return s;
}
/** createIssuedOrder + narrow the supplier-validation union + track cleanup. */
async function mkIssued(input: IssuedOrderInput = {}) {
const res = await createIssuedOrder(input);
if ("error" in res) throw new Error(`createIssuedOrder failed: ${res.error}`);
createdIds.push(res.id);
return res;
}
describe("computeIssuedOrderTotals (NET + VAT-on-top)", () => {
it("adds VAT on top of net, rounded per line", () => {
const t = computeIssuedOrderTotals(
[
{ quantity: 2, unit_price: 100, vat_rate: 21 },
{ quantity: 1, unit_price: 50, vat_rate: 12 },
],
true,
21,
);
expect(t.subtotal).toBe(250);
expect(t.vat_amount).toBe(48);
expect(t.total).toBe(298);
});
it("zeroes VAT when apply_vat is false but keeps the net subtotal", () => {
const t = computeIssuedOrderTotals(
[{ quantity: 3, unit_price: 100, vat_rate: 21 }],
false,
21,
);
expect(t).toEqual({ subtotal: 300, vat_amount: 0, total: 300 });
});
it("falls back to the document rate when a line rate is null", () => {
const t = computeIssuedOrderTotals(
[{ quantity: 1, unit_price: 100, vat_rate: null }],
true,
15,
);
expect(t.vat_amount).toBe(15);
});
});
describe("createIssuedOrder", () => {
it("defaults to draft with NO PO number, stores supplier_id + items", async () => {
const s = await makeSupplier();
const order = await mkIssued({
supplier_id: s.id,
items: [
{ description: "Materiál", quantity: 2, unit_price: 100, vat_rate: 21 },
],
});
// Deferred numbering: a draft carries no number.
expect(order.po_number).toBeNull();
expect(order.status).toBe("draft");
expect(order.supplier_id).toBe(s.id);
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
});
expect(items.length).toBe(1);
expect(Number(items[0].unit_price)).toBe(100);
});
it("numbers immediately when created already-finalized (status sent)", async () => {
const before = (await previewIssuedOrderNumber()).number;
const order = await mkIssued({ status: "sent" });
expect(order.po_number).toBe(before);
expect(order.status).toBe("sent");
});
it("rejects a nonexistent supplier_id instead of throwing P2003", async () => {
const res = await createIssuedOrder({ supplier_id: 99999999 });
expect("error" in res && res.error).toBe("supplier_not_found");
});
});
describe("updateIssuedOrder status transitions", () => {
it("allows draft -> sent and rejects draft -> completed", async () => {
const order = await mkIssued({});
const ok = await updateIssuedOrder(order.id, { status: "sent" });
expect("error" in ok).toBe(false);
const bad = await updateIssuedOrder(order.id, { status: "completed" });
expect("error" in bad && bad.error).toBe("invalid_transition");
});
it("locks items once confirmed", async () => {
const order = await mkIssued({
items: [{ description: "A", quantity: 1, unit_price: 10 }],
});
await updateIssuedOrder(order.id, { status: "sent" });
await updateIssuedOrder(order.id, { status: "confirmed" });
await updateIssuedOrder(order.id, {
items: [{ description: "B", quantity: 9, unit_price: 99 }],
});
const items = await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
});
expect(items.length).toBe(1);
expect(items[0].description).toBe("A");
});
it("rejects a nonexistent supplier_id on update", async () => {
const order = await mkIssued({});
const res = await updateIssuedOrder(order.id, { supplier_id: 99999999 });
expect("error" in res && res.error).toBe("supplier_not_found");
});
});
describe("getIssuedOrder", () => {
it("returns supplier, supplier_name and valid_transitions", async () => {
const s = await makeSupplier();
const order = await mkIssued({ supplier_id: s.id });
const detail = await getIssuedOrder(order.id);
expect(detail?.supplier_name).toBe("io_test_Dodavatel s.r.o.");
expect(detail?.supplier?.id).toBe(s.id);
expect(detail?.valid_transitions).toContain("sent");
});
});
describe("deleteIssuedOrder", () => {
it("deletes, cascades items, frees the latest number", async () => {
const order = await mkIssued({
items: [{ description: "X", quantity: 1, unit_price: 1 }],
});
// Finalize so the order has a real (consumed) number to free on delete.
const finalized = await updateIssuedOrder(order.id, { status: "sent" });
const before = "po_number" in finalized ? finalized.po_number : null;
expect(before).toBeTruthy();
await deleteIssuedOrder(order.id);
expect(
await prisma.issued_orders.findUnique({ where: { id: order.id } }),
).toBeNull();
expect(
(
await prisma.issued_order_items.findMany({
where: { issued_order_id: order.id },
})
).length,
).toBe(0);
expect((await previewIssuedOrderNumber()).number).toBe(before);
});
it("releases the number against the order's creation year, not the current year", async () => {
const priorYear = new Date().getFullYear() - 1;
// Allocate a real prior-year number (consumes that year's sequence: 0 -> 1)
// so the PO number matches the prior year's current highest.
const { number: poNumber } = await generateIssuedOrderNumber(priorYear);
const order = await mkIssued({ po_number: poNumber });
// Backdate creation into the prior year so delete derives that year.
await prisma.issued_orders.update({
where: { id: order.id },
data: { created_at: new Date(priorYear, 5, 1, 12, 0, 0) },
});
await deleteIssuedOrder(order.id);
// The prior-year sequence must be decremented (1 -> 0). With the old
// current-year bug it would stay at 1 (no current-year row to match).
const seq = await prisma.number_sequences.findFirst({
where: { type: "issued_order", year: priorYear },
});
expect(seq?.last_number).toBe(0);
});
});
describe("listIssuedOrders month filter", () => {
it("returns only orders whose order_date is in the given month", async () => {
const inMonth = await mkIssued({ order_date: "2026-03-15" });
const outMonth = await mkIssued({ order_date: "2026-04-15" });
const res = await listIssuedOrders({
page: 1,
limit: 50,
skip: 0,
sort: "id",
order: "desc",
month: 3,
year: 2026,
});
const ids = res.data.map((o) => o.id);
expect(ids).toContain(inMonth.id);
expect(ids).not.toContain(outMonth.id);
});
});
/* -------------------------------------------------------------------------- */
/* Route-level: suppliers lookup endpoint + supplier validation */
/* -------------------------------------------------------------------------- */
let app: ReturnType<typeof Fastify> | null = null;
let adminToken = "";
let noPermToken = "";
let noPermUserId = 0;
let noPermRoleId = 0;
function generateToken(user: {
id: number;
username: string;
roleName: string | null;
}): string {
return jwt.sign(
{ sub: user.id, username: user.username, role: user.roleName },
config.jwt.secret,
{ expiresIn: "15m" },
);
}
beforeAll(async () => {
app = Fastify({ logger: false });
await app.register(issuedOrdersRoutes, {
prefix: "/api/admin/issued-orders",
});
const admin = await prisma.users.findFirst({
where: { roles: { name: "admin" } },
});
if (!admin) throw new Error("Test setup: admin user not found");
adminToken = generateToken({
id: admin.id,
username: admin.username,
roleName: "admin",
});
// A role with NO orders permissions, to prove the lookup endpoint is gated.
const noPermRole = await prisma.roles.create({
data: {
name: "io_test_no_orders",
display_name: "Test No Orders",
description: "Test role without orders permissions",
},
});
noPermRoleId = noPermRole.id;
const noPermUser = await prisma.users.create({
data: {
username: "io_test_noperm",
email: "io_test_noperm@test.local",
password_hash:
"$2a$12$LJ3m4ys3Lg4oLBFnYP2amuPBzJnJBbGzCl5Y6X9Y8r0q5.s3L6OyO",
first_name: "No",
last_name: "Orders",
is_active: true,
role_id: noPermRole.id,
},
});
noPermUserId = noPermUser.id;
noPermToken = generateToken({
id: noPermUser.id,
username: noPermUser.username,
roleName: noPermRole.name,
});
});
afterAll(async () => {
if (noPermUserId)
await prisma.users.delete({ where: { id: noPermUserId } }).catch(() => {});
if (noPermRoleId)
await prisma.roles.delete({ where: { id: noPermRoleId } }).catch(() => {});
if (app) await app.close();
});
describe("GET /api/admin/issued-orders/suppliers", () => {
it("returns active suppliers only (with the picker fields)", async () => {
const active = await makeSupplier({
name: "io_test_Aktivní dodavatel",
ico: "12345678",
});
const inactive = await makeSupplier({
name: "io_test_Neaktivní dodavatel",
is_active: false,
});
const res = await app!.inject({
method: "GET",
url: "/api/admin/issued-orders/suppliers",
headers: { Authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
const ids = body.data.map((s: { id: number }) => s.id);
expect(ids).toContain(active.id);
expect(ids).not.toContain(inactive.id);
const row = body.data.find((s: { id: number }) => s.id === active.id);
expect(row.name).toBe("io_test_Aktivní dodavatel");
expect(row.ico).toBe("12345678");
// Lightweight lookup shape — all picker fields present.
expect(Object.keys(row).sort()).toEqual(
["address", "dic", "email", "ico", "id", "name", "phone"].sort(),
);
});
it("is denied (403) to a user with no orders permissions", async () => {
const res = await app!.inject({
method: "GET",
url: "/api/admin/issued-orders/suppliers",
headers: { Authorization: `Bearer ${noPermToken}` },
});
expect(res.statusCode).toBe(403);
expect(res.json().success).toBe(false);
});
});
describe("POST /api/admin/issued-orders supplier validation", () => {
it("returns 400 (not a P2003 500) for a nonexistent supplier_id", async () => {
const res = await app!.inject({
method: "POST",
url: "/api/admin/issued-orders",
headers: { Authorization: `Bearer ${adminToken}` },
payload: { supplier_id: 99999999 },
});
expect(res.statusCode).toBe(400);
const body = res.json();
expect(body.success).toBe(false);
expect(body.error).toBe("Dodavatel nenalezen");
});
});
/* -------------------------------------------------------------------------- */
/* PDF render */
/* -------------------------------------------------------------------------- */
describe("renderIssuedOrderHtml", () => {
const order = {
po_number: "26720001",
order_date: new Date("2026-06-09T12:00:00"),
delivery_date: null,
currency: "CZK",
apply_vat: true,
vat_rate: 21,
notes: "<p>Pozn</p><script>alert(1)</script>",
delivery_terms: null,
payment_terms: null,
issued_by: null,
};
const items = [
{
description: "Materiál",
item_description: "Detailní popis položky",
quantity: 2,
unit: "ks",
unit_price: 100,
vat_rate: 21,
},
];
const issuer = { name: "Jan Novák" };
// sklad_suppliers shape: single Text address blob + ico/dic columns.
const supplier = {
name: "Dodavatel s.r.o.",
ico: "12345678",
dic: "CZ12345678",
address: "Průmyslová 5\n190 00 Praha",
};
it("renders the PO number, items, and both party names (PO direction)", () => {
const html = renderIssuedOrderHtml(
order,
items,
supplier,
{ company_name: "Naše firma" },
"cs",
issuer,
);
expect(html).toContain("26720001");
expect(html).toContain("Materiál");
// Optional item sub-line.
expect(html).toContain("Detailní popis položky");
// PO direction: the sklad_suppliers record is the Dodavatel (supplier),
// our company is the Odběratel (buyer).
expect(html).toContain("Dodavatel s.r.o.");
expect(html).toContain("Naše firma");
expect(html).toContain("Dodavatel");
expect(html).toContain("Odběratel");
});
it("renders the supplier address (split on newlines) plus IČO and DIČ", () => {
const html = renderIssuedOrderHtml(
order,
items,
supplier,
null,
"cs",
issuer,
);
// The single Text address blob becomes one address line per newline.
expect(html).toContain('<div class="address-line">Průmyslová 5</div>');
expect(html).toContain('<div class="address-line">190 00 Praha</div>');
expect(html).toContain("IČ: 12345678");
expect(html).toContain("DIČ: CZ12345678");
});
it("renders a no-newline address as a single line and skips missing IČO/DIČ", () => {
const html = renderIssuedOrderHtml(
order,
items,
{ name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" },
null,
"cs",
issuer,
);
expect(html).toContain(
'<div class="address-line">Ulice 1, 100 00 Praha</div>',
);
expect(html).not.toContain("IČ: ");
expect(html).not.toContain("DIČ: ");
});
it("strips script tags from notes", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
expect(html).not.toContain("<script>");
expect(html).not.toContain("alert(1)");
});
it("with VAT: keeps the VAT columns and the DPH cell holds ONLY the line VAT", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
expect(html).toContain("%DPH");
expect(html).toContain(">DPH<");
// 2 × 100 @ 21 %: DPH cell = 42,00 (VAT only), Celkem cell = 242,00.
expect(html).toContain('<td class="right">42,00</td>');
expect(html).toContain('<td class="right total-cell">242,00</td>');
expect(html).not.toContain("Celkem bez DPH");
});
it("without VAT: hides the VAT columns and labels the total 'Celkem bez DPH'", () => {
const html = renderIssuedOrderHtml(
{ ...order, apply_vat: false },
items,
null,
null,
"cs",
issuer,
);
expect(html).not.toContain("%DPH");
expect(html).not.toContain(">DPH<");
// No per-line VAT cell, line total = netto.
expect(html).not.toContain('<td class="right">42,00</td>');
expect(html).toContain('<td class="right total-cell">200,00</td>');
expect(html).toContain("Celkem bez DPH");
// The subtotal detail row is dropped (it would duplicate the grand total).
expect(html).not.toContain("Mezisoučet");
});
it("footer shows the logged-in user's name, no e-mail, no Schválil column", () => {
const html = renderIssuedOrderHtml(order, items, null, null, "cs", issuer);
// Footer: Vystavil <name> from authData. No e-mail line.
expect(html).toContain("Jan Novák");
expect(html).not.toContain("E-mail:");
// The old two-column signature footer is gone.
expect(html).not.toContain("footer-row");
expect(html).not.toContain("Schválil:");
});
});

View File

@@ -0,0 +1,282 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createProject } from "../services/projects.service";
import {
createOrder,
createOrderFromQuotation,
deleteOrder,
} from "../services/orders.service";
import { previewProjectNumber } from "../services/numbering.service";
const YEAR = new Date().getFullYear();
const createdProjectIds: number[] = [];
const createdOrderIds: number[] = [];
const createdQuotationIds: number[] = [];
afterEach(async () => {
for (const id of createdProjectIds)
await prisma.projects.deleteMany({ where: { id } });
for (const id of createdOrderIds)
await prisma.orders.deleteMany({ where: { id } });
for (const id of createdQuotationIds)
await prisma.quotations.deleteMany({ where: { id } });
createdProjectIds.length = 0;
createdOrderIds.length = 0;
createdQuotationIds.length = 0;
// Orders and projects now draw from SEPARATE sequences — reset both.
await prisma.number_sequences.deleteMany({
where: { type: { in: ["shared", "project"] } },
});
});
/**
* Read the current `last_number` of a (type, current-year) sequence row, or 0
* when the row doesn't exist yet (the first allocation creates it at 1). Used to
* prove each generator advances its OWN row by exactly 1.
*/
async function seqLastNumber(type: "shared" | "project"): Promise<number> {
const row = await prisma.number_sequences.findFirst({
where: { type, year: YEAR },
select: { last_number: true },
});
return row?.last_number ?? 0;
}
const baseOrder = {
status: "prijata" as const,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1,
};
/**
* Fail loudly (instead of the old `if (!("id" in res)) return;`, which silently
* passed the test when a create failed) while still narrowing the union via the
* `in` operator so the success-branch fields stay typed.
*/
function failResult(res: unknown): never {
throw new Error(`expected a success result, got ${JSON.stringify(res)}`);
}
describe("createOrder (manual, optional linked project)", () => {
it("creates an order AND a project, each with its OWN number, linked by FK", async () => {
// Capture BOTH sequence counters before the create so we can prove each
// generator advanced its OWN row (not that the project consumed the shared
// counter). Format difference alone (OBJ-… vs 26730…) would let `not.toBe`
// pass even if the project drew from `shared` — the deltas catch that.
const sharedBefore = await seqLastNumber("shared");
const projectBefore = await seqLastNumber("project");
const res = await createOrder({
...baseOrder,
scope_title: "Ruční zakázka",
create_project: true,
});
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
expect(res.project_id).toBeTruthy();
const project = await prisma.projects.findUnique({
where: { id: res.project_id! },
});
if (project) createdProjectIds.push(project.id);
// The auto-created project now draws from the dedicated project sequence,
// so its number is distinct from the order's shared number. They relate
// only via the order_id FK, not by sharing a number.
expect(project?.project_number).toBeTruthy();
expect(project?.project_number).not.toBe(res.order_number);
expect(project?.order_id).toBe(res.id);
// Sequence isolation: the order consumed exactly one `shared` number and
// the project consumed exactly one `project` number — each generator
// advanced its OWN row by 1. A regression where the project draws from the
// `shared` counter would bump `shared` by 2 and leave `project` at 0.
const sharedAfter = await seqLastNumber("shared");
const projectAfter = await seqLastNumber("project");
expect(sharedAfter - sharedBefore).toBe(1);
expect(projectAfter - projectBefore).toBe(1);
});
it("creates only the order when create_project=false", async () => {
const res = await createOrder({ ...baseOrder, create_project: false });
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
expect(res.project_id).toBeUndefined();
});
});
describe("createOrderFromQuotation (auto-project gets its OWN number)", () => {
it("links a project whose number is from the project sequence, distinct from the order number", async () => {
// Seed a minimal quotation (no items/sections needed — createOrderFrom
// Quotation copies whatever is present and both arrays may be empty).
const quotation = await prisma.quotations.create({
data: {
quotation_number: `Q-FROMQ-${Date.now()}`,
status: "active",
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
},
});
createdQuotationIds.push(quotation.id);
const res = await createOrderFromQuotation({
quotationId: quotation.id,
customerOrderNumber: "PO-FROMQ",
});
if (!("data" in res) || !res.data) failResult(res);
const orderId = res.data.order_id;
createdOrderIds.push(orderId);
const order = await prisma.orders.findUnique({ where: { id: orderId } });
const project = await prisma.projects.findFirst({
where: { order_id: orderId },
});
if (project) createdProjectIds.push(project.id);
// Primary flow: the auto-created project must get a non-empty number from
// the dedicated `project` sequence (code 73), DISTINCT from the order's
// shared number (code 71). Pre-split they shared one number.
expect(project).toBeTruthy();
expect(project?.project_number).toBeTruthy();
expect(order?.order_number).toBeTruthy();
expect(project?.project_number).not.toBe(order?.order_number);
expect(project?.order_id).toBe(orderId);
});
});
describe("deleteOrder releases the project number (release regression)", () => {
it("frees the auto-created project's number back to the project sequence", async () => {
// The project sequence is reset in afterEach, so the preview before the
// create is the number createOrder will consume for the project.
const previewBefore = await previewProjectNumber();
const res = await createOrder({
...baseOrder,
scope_title: "Smazatelná zakázka",
create_project: true,
});
if (!("id" in res)) failResult(res);
const orderId = res.id!;
expect(res.project_id).toBeTruthy();
const project = await prisma.projects.findUnique({
where: { id: res.project_id! },
});
expect(project?.project_number).toBe(previewBefore);
// After the order delete, the project row is gone AND its number must have
// been released (decremented) so the preview returns the freed number — not
// a permanent gap. This is the fix #1 regression guard.
const del = await deleteOrder(orderId);
if ("error" in del) failResult(del);
const gone = await prisma.projects.findUnique({
where: { id: res.project_id! },
});
expect(gone).toBeNull();
const previewAfter = await previewProjectNumber();
expect(previewAfter).toBe(previewBefore);
});
});
describe("separate order vs project sequences (coherence)", () => {
it("order → standalone project → order produce three distinct numbers (from separate sequences)", async () => {
const o1 = await createOrder({ ...baseOrder, create_project: true });
if (!("id" in o1)) failResult(o1);
createdOrderIds.push(o1.id!);
if (o1.project_id) createdProjectIds.push(o1.project_id);
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
if (!("project_number" in p)) failResult(p);
createdProjectIds.push(p.id);
const o2 = await createOrder({ ...baseOrder, create_project: false });
if (!("id" in o2)) failResult(o2);
createdOrderIds.push(o2.id!);
// All three numbers must be present (non-null) AND distinct. Orders draw
// from the `shared` sequence (code 71); projects from the dedicated
// `project` sequence (code 73), so they never collide.
expect(o1.order_number).toBeTruthy();
expect(p.project_number).toBeTruthy();
expect(o2.order_number).toBeTruthy();
expect(
new Set([o1.order_number, p.project_number, o2.order_number]).size,
).toBe(3);
});
});
describe("createProject (manual, standalone)", () => {
it("assigns a project number and is not linked to an order", async () => {
const result = await createProject({
name: "Test projekt",
status: "aktivni",
});
if (!("project_number" in result)) failResult(result);
createdProjectIds.push(result.id);
expect(typeof result.project_number).toBe("string");
expect(result.project_number!.length).toBeGreaterThan(0);
expect(result.order_id).toBeNull();
});
it("gives consecutive standalone projects distinct numbers", async () => {
const a = await createProject({ name: "Projekt A", status: "aktivni" });
const b = await createProject({ name: "Projekt B", status: "aktivni" });
if ("project_number" in a) createdProjectIds.push(a.id);
if ("project_number" in b) createdProjectIds.push(b.id);
expect("project_number" in a && "project_number" in b).toBe(true);
if ("project_number" in a && "project_number" in b) {
expect(a.project_number).not.toBe(b.project_number);
}
});
});
describe("createOrder with price line item + attachment", () => {
it("stores a single line item with the entered price", async () => {
const res = await createOrder({
...baseOrder,
create_project: false,
items: [
{
description: "Práce",
quantity: 1,
unit_price: 12345,
is_included_in_total: true,
},
],
});
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
const items = await prisma.order_items.findMany({
where: { order_id: res.id },
});
expect(items.length).toBe(1);
expect(Number(items[0].unit_price)).toBe(12345);
});
it("stores an uploaded PO attachment with the exact bytes", async () => {
const buf = Buffer.from("%PDF-1.4 fake-attachment-bytes-éí");
const res = await createOrder(
{ ...baseOrder, create_project: false },
buf,
"po.pdf",
);
if (!("id" in res)) failResult(res);
createdOrderIds.push(res.id!);
const order = await prisma.orders.findUnique({
where: { id: res.id },
select: { attachment_name: true, attachment_data: true },
});
expect(order?.attachment_name).toBe("po.pdf");
// Verify the stored content/size, not just truthiness: round-trip the
// bytes and compare length + value against what we uploaded.
expect(order?.attachment_data).toBeTruthy();
const stored = Buffer.from(order!.attachment_data!);
expect(stored.length).toBe(buf.length);
expect(stored.equals(buf)).toBe(true);
});
});

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { NasDocumentManager } from "../services/nas-financials-manager";
/**
* Generic NAS archival manager backing both the invoices NAS (NAS_INVOICES) and
* the orders NAS (NAS_ORDERS). Uses an isolated temp dir via the constructor
* seam so it is deterministic and passes even where the real NAS (Z:) is not
* mounted. Layout: {base}/Vydané/YYYY/MM/<n>.pdf — diacritics preserved.
*/
describe("NasDocumentManager issued-PDF round-trip", () => {
let tmpBase: string;
let nas: NasDocumentManager;
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-doc-"));
nas = new NasDocumentManager(tmpBase);
});
afterEach(() => {
fs.rmSync(tmpBase, { recursive: true, force: true });
});
it("saves an issued PDF under Vydané/YYYY/MM/<number>.pdf and reads it back", () => {
const buf = Buffer.from("%PDF-1.4 fake order pdf bytes", "utf8");
const rel = nas.saveIssuedPdf("25P0001", 2026, 6, buf);
expect(rel).toBe("Vydané/2026/06/25P0001.pdf");
const onDisk = path.join(tmpBase, "Vydané", "2026", "06", "25P0001.pdf");
expect(fs.existsSync(onDisk)).toBe(true);
const read = nas.readIssued(rel!);
expect(read).not.toBeNull();
expect(read!.fileName).toBe("25P0001.pdf");
expect(read!.data.equals(buf)).toBe(true);
});
it("cleanIssued removes a previously-saved PDF across year/month folders", () => {
const buf = Buffer.from("pdf", "utf8");
const rel = nas.saveIssuedPdf("25P0002", 2026, 6, buf);
expect(nas.readIssued(rel!)).not.toBeNull();
nas.cleanIssued("25P0002");
expect(nas.readIssued(rel!)).toBeNull();
});
it("an unconfigured (empty basePath) manager is not configured and saves nothing", () => {
const blank = new NasDocumentManager("");
expect(blank.isConfigured()).toBe(false);
expect(
blank.saveIssuedPdf("25P0003", 2026, 6, Buffer.from("x")),
).toBeNull();
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { NasFileManager } from "../services/nas-file-manager";
describe("NasFileManager path traversal", () => {
const nas = new NasFileManager();
describe("deleteItem", () => {
it("rejects empty path", async () => {
const result = await nas.deleteItem("PRJ-001", "");
expect(result).toContain("kořenovou složku");
});
it("rejects root path /", async () => {
const result = await nas.deleteItem("PRJ-001", "/");
expect(result).toContain("kořenovou složku");
});
it("rejects current directory .", async () => {
const result = await nas.deleteItem("PRJ-001", ".");
expect(result).toContain("kořenovou složku");
});
it("rejects current directory ./", async () => {
const result = await nas.deleteItem("PRJ-001", "./");
expect(result).toContain("kořenovou složku");
});
it("rejects path traversal ..", async () => {
const result = await nas.deleteItem("PRJ-001", "../etc/passwd");
expect(result).toContain("Neplatná cesta");
});
});
describe("moveItem", () => {
it("rejects empty fromPath", async () => {
const result = await nas.moveItem("PRJ-001", "", "dest");
expect(result).toContain("kořenovou složku");
});
it("rejects root fromPath /", async () => {
const result = await nas.moveItem("PRJ-001", "/", "dest");
expect(result).toContain("kořenovou složku");
});
it("rejects current directory .", async () => {
const result = await nas.moveItem("PRJ-001", ".", "dest");
expect(result).toContain("kořenovou složku");
});
it("rejects path traversal in fromPath", async () => {
const result = await nas.moveItem("PRJ-001", "../secret", "dest");
expect(result).toContain("Neplatná cesta");
});
});
});
/**
* Positive-path coverage: a legitimate (non-traversal) path inside a real
* project folder is ACCEPTED — it passes resolveProjectPath validation and
* reaches the underlying fs op (success → null). Without this, a
* "reject-everything" regression in the traversal guard would still pass every
* rejection test above. Uses the constructor basePath seam + a temp dir so it
* is deterministic even where the real NAS (Z:) is not mounted.
*/
describe("NasFileManager accepts legitimate paths", () => {
let tmpBase: string;
let nas: NasFileManager;
const NUM = "29990001";
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-accept-"));
nas = new NasFileManager(tmpBase);
// Create a real project folder <number>_<name> so findProjectFolder resolves.
nas.createProjectFolder(NUM, "Test");
});
afterEach(() => {
fs.rmSync(tmpBase, { recursive: true, force: true });
});
it("deletes a legitimate file inside the project folder (path accepted)", async () => {
const folder = path.join(tmpBase, `${NUM}_Test`);
fs.writeFileSync(path.join(folder, "doc.pdf"), "hello");
expect(fs.existsSync(path.join(folder, "doc.pdf"))).toBe(true);
const result = await nas.deleteItem(NUM, "doc.pdf");
// null = success: the path was accepted and the file actually removed.
expect(result).toBeNull();
expect(fs.existsSync(path.join(folder, "doc.pdf"))).toBe(false);
});
it("moves a legitimate file to a legitimate destination (both accepted)", async () => {
const folder = path.join(tmpBase, `${NUM}_Test`);
fs.writeFileSync(path.join(folder, "from.pdf"), "data");
const result = await nas.moveItem(NUM, "from.pdf", "to.pdf");
expect(result).toBeNull();
expect(fs.existsSync(path.join(folder, "from.pdf"))).toBe(false);
expect(fs.existsSync(path.join(folder, "to.pdf"))).toBe(true);
});
it("a traversal delete is rejected AND deletes nothing (folder intact)", async () => {
const folder = path.join(tmpBase, `${NUM}_Test`);
fs.writeFileSync(path.join(folder, "keep.pdf"), "keep");
const result = await nas.deleteItem(NUM, "../keep.pdf");
expect(result).toContain("Neplatná cesta");
// The guard rejected the path before any fs op — nothing was removed.
expect(fs.existsSync(path.join(folder, "keep.pdf"))).toBe(true);
});
it("rejects a traversal DESTINATION even when the source is legitimate", async () => {
// With a real project folder, the source `from.pdf` resolves cleanly, so
// this isolates the DESTINATION check: `../escape.pdf` must be rejected and
// the source must stay put (no move outside the project folder).
const folder = path.join(tmpBase, `${NUM}_Test`);
fs.writeFileSync(path.join(folder, "from.pdf"), "data");
const result = await nas.moveItem(NUM, "from.pdf", "../escape.pdf");
expect(result).toContain("Neplatná cesta");
expect(fs.existsSync(path.join(folder, "from.pdf"))).toBe(true);
// Nothing escaped the project folder.
expect(fs.existsSync(path.join(tmpBase, "escape.pdf"))).toBe(false);
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "fs";
import os from "os";
import path from "path";
import { NasFileManager } from "../services/nas-file-manager";
/**
* Folder auto-creation primitive used by both the manual project-create path
* (projects.service createProject) and the order→project paths (orders.service
* createOrder / createOrderFromQuotation). Uses an isolated temp dir via the
* constructor seam so it is deterministic and passes even where the real NAS
* (Z:) is not mounted.
*/
describe("NasFileManager.createProjectFolder", () => {
let tmpBase: string;
let nas: NasFileManager;
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-proj-"));
nas = new NasFileManager(tmpBase);
});
afterEach(() => {
fs.rmSync(tmpBase, { recursive: true, force: true });
});
it("creates a <number>_<name> folder for a new project", () => {
const ok = nas.createProjectFolder("26710001", "Rekonstrukce haly");
expect(ok).toBe(true);
expect(
fs.existsSync(path.join(tmpBase, "26710001_Rekonstrukce_haly")),
).toBe(true);
});
it("is idempotent — a second call does not create a duplicate", () => {
expect(nas.createProjectFolder("26710002", "Hala")).toBe(true);
expect(nas.createProjectFolder("26710002", "Hala")).toBe(true);
const matches = fs
.readdirSync(tmpBase)
.filter((e) => e.startsWith("26710002_"));
expect(matches).toEqual(["26710002_Hala"]);
});
it("preserves Czech diacritics (no transliteration)", () => {
expect(nas.createProjectFolder("26710003", "Měření haly")).toBe(true);
expect(fs.existsSync(path.join(tmpBase, "26710003_Měření_haly"))).toBe(
true,
);
});
it("strips illegal characters and collapses spaces", () => {
expect(nas.createProjectFolder("26710004", "A / B: C")).toBe(true);
expect(fs.existsSync(path.join(tmpBase, "26710004_A_B_C"))).toBe(true);
});
it("handles an empty name without crashing (trailing underscore)", () => {
expect(nas.createProjectFolder("26710005", "")).toBe(true);
expect(fs.existsSync(path.join(tmpBase, "26710005_"))).toBe(true);
});
it("returns false and creates nothing when the NAS base is not mounted", () => {
const missing = new NasFileManager(
path.join(tmpBase, "does-not-exist-subdir"),
);
expect(missing.createProjectFolder("26710006", "X")).toBe(false);
expect(fs.existsSync(path.join(tmpBase, "does-not-exist-subdir"))).toBe(
false,
);
});
});
/**
* Folder deletion primitive used by the "delete folder" checkbox on both the
* order delete (orders.service deleteOrder) and project delete (projects.service
* deleteProject) paths. Matches the project by number prefix.
*/
describe("NasFileManager.deleteProjectFolder", () => {
let tmpBase: string;
let nas: NasFileManager;
beforeEach(() => {
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-del-"));
nas = new NasFileManager(tmpBase);
});
afterEach(() => {
fs.rmSync(tmpBase, { recursive: true, force: true });
});
it("removes an existing project folder (matched by number prefix)", async () => {
nas.createProjectFolder("26710010", "Hala");
expect(fs.existsSync(path.join(tmpBase, "26710010_Hala"))).toBe(true);
const ok = await nas.deleteProjectFolder("26710010");
expect(ok).toBe(true);
expect(fs.existsSync(path.join(tmpBase, "26710010_Hala"))).toBe(false);
});
it("is a no-op (returns true) when no folder exists for the number", async () => {
expect(await nas.deleteProjectFolder("26710011")).toBe(true);
});
it("returns false when the NAS base is not mounted", async () => {
const missing = new NasFileManager(path.join(tmpBase, "nope"));
expect(await missing.deleteProjectFolder("26710012")).toBe(false);
});
});

View File

@@ -1,21 +1,278 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
generateSharedNumber,
generateOfferNumber,
previewSharedNumber,
isSharedNumberTaken,
previewOfferNumber,
isOfferNumberTaken,
generateProjectNumber,
previewProjectNumber,
isProjectNumberTaken,
} from "../services/numbering.service";
import prisma from "../config/database";
const YEAR = new Date().getFullYear();
/**
* Assert two consecutive document numbers (a) keep an identical format / year
* prefix and (b) increment by exactly one — without hard-coding which
* `company_settings` pattern is configured.
*
* Some patterns are entirely digits ({YY}{CODE}{NNNN}), so a regex split of
* "trailing digit run" is ambiguous. Instead we find the longest common prefix
* of the two strings (the unchanged format/year portion) and parse the
* remaining differing suffixes as integers — these are the rendered sequences.
*/
function assertStrictIncrement(num1: string, num2: string) {
expect(num1).not.toBe(num2);
expect(num2.length).toBe(num1.length); // same width ⇒ same format
let i = 0;
while (i < num1.length && num1[i] === num2[i]) i++;
const commonPrefix = num1.slice(0, i);
// The format/year portion is shared and non-empty (the numbers don't differ
// from the very first character).
expect(commonPrefix.length).toBeGreaterThan(0);
const seq1 = Number(num1.slice(i));
const seq2 = Number(num2.slice(i));
expect(Number.isNaN(seq1)).toBe(false);
expect(Number.isNaN(seq2)).toBe(false);
expect(seq2).toBe(seq1 + 1); // strict +1, not merely !==
}
/**
* Seed the (type, year) sequence row to a high `last_number` so the next
* generated numbers land far above any real order/quotation in the test DB —
* the generator's skip-taken retry then never fires and the strict +1
* assertion is deterministic.
*/
async function seedSequence(type: string, lastNumber: number) {
await prisma.number_sequences.deleteMany({ where: { type } });
await prisma.number_sequences.create({
data: { type, year: YEAR, last_number: lastNumber },
});
}
describe("generateSharedNumber", () => {
it("returns correct format (YYtypeCode + 4 digits)", async () => {
beforeEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
});
afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
});
it("returns a non-empty string", async () => {
const num = await generateSharedNumber();
const yy = String(new Date().getFullYear()).slice(-2);
expect(num).toMatch(new RegExp(`^${yy}\\d{2,}\\d{4}$`));
expect(typeof num).toBe("string");
expect(num.length).toBeGreaterThan(0);
});
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
// Seed high so neither generated number collides with a real order/project
// → the skip-taken retry never fires and the increment is strictly +1.
await seedSequence("shared", 900000);
const num1 = await generateSharedNumber();
const num2 = await generateSharedNumber();
assertStrictIncrement(num1, num2);
});
});
describe("generateProjectNumber", () => {
beforeEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "project" } });
});
afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "project" } });
});
it("returns a non-empty string", async () => {
const num = await generateProjectNumber();
expect(typeof num).toBe("string");
expect(num.length).toBeGreaterThan(0);
});
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
// Seed high so neither generated number collides with a real project →
// the skip-taken retry never fires and the increment is strictly +1.
await seedSequence("project", 900000);
const num1 = await generateProjectNumber();
const num2 = await generateProjectNumber();
assertStrictIncrement(num1, num2);
});
it("previewProjectNumber returns a number not yet taken by a project", async () => {
const preview = await previewProjectNumber();
expect(typeof preview).toBe("string");
expect(preview.length).toBeGreaterThan(0);
expect(await isProjectNumberTaken(preview)).toBe(false);
});
});
describe("cross-sequence isolation (shared vs project)", () => {
beforeEach(async () => {
await prisma.number_sequences.deleteMany({
where: { type: { in: ["shared", "project"] } },
});
});
afterEach(async () => {
await prisma.number_sequences.deleteMany({
where: { type: { in: ["shared", "project"] } },
});
});
it("generateSharedNumber advances ONLY the shared row; generateProjectNumber ONLY the project row", async () => {
// Seed the two sequences to DISTINCT known highs so neither generated
// number collides with a real row (skip-taken retry never fires) and a
// cross-contamination bug is unambiguous.
await seedSequence("shared", 900000);
await seedSequence("project", 500000);
await generateSharedNumber();
// After a shared allocation only the shared row may have moved.
let shared = await prisma.number_sequences.findFirst({
where: { type: "shared", year: YEAR },
select: { last_number: true },
});
let project = await prisma.number_sequences.findFirst({
where: { type: "project", year: YEAR },
select: { last_number: true },
});
expect(shared?.last_number).toBe(900001);
expect(project?.last_number).toBe(500000);
await generateProjectNumber();
// After a project allocation only the project row may have moved; the
// shared row must still sit at 900001.
shared = await prisma.number_sequences.findFirst({
where: { type: "shared", year: YEAR },
select: { last_number: true },
});
project = await prisma.number_sequences.findFirst({
where: { type: "project", year: YEAR },
select: { last_number: true },
});
expect(shared?.last_number).toBe(900001);
expect(project?.last_number).toBe(500001);
});
});
describe("generateOfferNumber", () => {
it("returns correct format (YEAR/PREFIX/NNN)", async () => {
beforeEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
});
afterEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
});
it("returns a non-empty string", async () => {
const num = await generateOfferNumber();
const year = new Date().getFullYear();
expect(num).toMatch(new RegExp(`^${year}/[A-Z]+/\\d{3,}$`));
expect(typeof num).toBe("string");
expect(num.length).toBeGreaterThan(0);
});
it("increments the sequence by exactly 1 and preserves the year/format", async () => {
await seedSequence("offer", 900000);
const num1 = await generateOfferNumber();
const num2 = await generateOfferNumber();
assertStrictIncrement(num1, num2);
});
});
describe("previewSharedNumber", () => {
let createdProjectId: number | null = null;
beforeEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
});
afterEach(async () => {
if (createdProjectId !== null) {
await prisma.projects
.delete({ where: { id: createdProjectId } })
.catch(() => {});
createdProjectId = null;
}
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
});
it("returns a number not already used by an order or project", async () => {
const preview = await previewSharedNumber();
expect(typeof preview).toBe("string");
expect(await isSharedNumberTaken(preview)).toBe(false);
});
it("skips a number already taken when the sequence counter lags behind", async () => {
// Seed the counter HIGH so the preview lands far above any real
// order/project in the test DB. Without this, a real row could already
// hold the first sequence number — making the create collide on the unique
// column, or making `second !== first` pass for the wrong reason (because
// the counter happened to already point past `first`).
await seedSequence("shared", 900000);
const first = await previewSharedNumber();
// Preconditions: the seeded preview really is free (so the create below is
// safe) and is exactly what the generator would assign next.
expect(await isSharedNumberTaken(first)).toBe(false);
const project = await prisma.projects.create({
data: { project_number: first, name: "test-preview-skip-taken" },
});
createdProjectId = project.id;
// The counter still lags behind (no row was consumed), but `first` is now
// taken — the preview must advance past it instead of re-offering it.
const second = await previewSharedNumber();
expect(second).not.toBe(first);
expect(await isSharedNumberTaken(second)).toBe(false);
});
});
describe("previewOfferNumber", () => {
let createdQuotationId: number | null = null;
beforeEach(async () => {
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
});
afterEach(async () => {
if (createdQuotationId !== null) {
await prisma.quotations
.delete({ where: { id: createdQuotationId } })
.catch(() => {});
createdQuotationId = null;
}
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
});
it("returns a number not already used by a quotation", async () => {
const preview = await previewOfferNumber();
expect(typeof preview).toBe("string");
expect(await isOfferNumberTaken(preview)).toBe(false);
});
it("skips a number already taken when the sequence counter lags behind", async () => {
// Seed HIGH so the preview lands above any real quotation in the test DB
// (see the shared-number test for the rationale): the create can't collide
// and the skip-advance is genuinely exercised rather than passing because a
// real row already advanced the preview.
await seedSequence("offer", 900000);
const first = await previewOfferNumber();
expect(await isOfferNumberTaken(first)).toBe(false);
const quotation = await prisma.quotations.create({
data: { quotation_number: first },
});
createdQuotationId = quotation.id;
// The counter still lags, but `first` is now taken — the preview must
// advance past it instead of re-offering it.
const second = await previewOfferNumber();
expect(second).not.toBe(first);
expect(await isOfferNumberTaken(second)).toBe(false);
});
});

View File

@@ -0,0 +1,280 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createOffer, getOfferTotals } from "../services/offers.service";
import {
createInvoice,
getInvoiceListTotals,
} from "../services/invoices.service";
import { getReceivedInvoiceListTotals } from "../routes/admin/received-invoices";
// Per-currency total aggregation for the offers, issued-invoices and
// received-invoices lists. These hit the real `app_test` DB via the service
// layer (suite convention) and prove the totals fns sum each document's TOTAL
// per currency across the WHOLE filtered set, and that the filtering `where`
// (search / month-year / status) is respected — mirroring order-totals.test.ts.
//
// For invoices a far-future month/year is used so seeded/other-test rows can't
// fall in the window and skew the deterministic sums. Offers have NO date
// filter, so they are isolated by a unique `project_code` searched on instead.
// Received invoices store amount as GROSS (VAT-inclusive), summed directly.
const STATS_YEAR = 2097;
const STATS_MONTH = 8; // -> invoice issue_date window [2097-08-01, 2097-09-01)
// Unique marker so the offers test (which has no date filter) can scope its
// aggregation to only the rows it created via a `search` on project_code.
const OFFER_MARKER = `TOTALS-TEST-${STATS_YEAR}`;
const createdOfferIds: number[] = [];
const createdInvoiceIds: number[] = [];
const createdReceivedIds: number[] = [];
afterEach(async () => {
for (const id of createdOfferIds)
await prisma.quotations.deleteMany({ where: { id } });
for (const id of createdInvoiceIds)
await prisma.invoices.deleteMany({ where: { id } });
for (const id of createdReceivedIds)
await prisma.received_invoices.deleteMany({ where: { id } });
createdOfferIds.length = 0;
createdInvoiceIds.length = 0;
createdReceivedIds.length = 0;
// Finalized offers/invoices consume their sequences — reset so we don't leak
// a consumed number into sibling test files.
await prisma.number_sequences.deleteMany({
where: { type: { in: ["offer", "invoice"] } },
});
});
function amountFor(
totals: { currency: string; amount: number }[],
currency: string,
): number | undefined {
return totals.find((t) => t.currency === currency)?.amount;
}
describe("getOfferTotals per-currency aggregation", () => {
it("sums offer TOTAL incl. VAT per currency over the full filtered set", async () => {
// Two CZK offers + one EUR. 21% VAT applied on the whole subtotal
// (enrichQuotation math).
// CZK #1: 2 x 1000 = 2000 net -> +21% = 2420
// CZK #2: 1 x 500 = 500 net -> +21% = 605 => CZK total 3025
// EUR : 3 x 100 = 300 net -> +21% = 363 => EUR total 363
const mk = async (currency: string, qty: number, price: number) => {
const res = await createOffer({
status: "draft", // draft so no offer number is consumed
currency,
vat_rate: 21,
apply_vat: true,
project_code: OFFER_MARKER,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
if ("error" in res) throw new Error(JSON.stringify(res));
createdOfferIds.push(res.id);
return res.id;
};
await mk("CZK", 2, 1000);
await mk("CZK", 1, 500);
await mk("EUR", 3, 100);
const { totals } = await getOfferTotals({ search: OFFER_MARKER });
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
});
it("respects the where: a status filter narrows the result", async () => {
const mk = async (status: string) => {
const res = await createOffer({
status,
currency: "CZK",
vat_rate: 21,
apply_vat: true,
project_code: OFFER_MARKER,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
if ("error" in res) throw new Error(JSON.stringify(res));
createdOfferIds.push(res.id);
return res.id;
};
// Two drafts + one invalidated, all sharing the marker.
await mk("draft");
await mk("draft");
await mk("invalidated");
// Marker only: all three count -> 3 x 1210 = 3630.
const all = await getOfferTotals({ search: OFFER_MARKER });
expect(amountFor(all.totals, "CZK")).toBe(3630);
// Status filter narrows to the two drafts -> 2 x 1210 = 2420.
const onlyDraft = await getOfferTotals({
search: OFFER_MARKER,
status: "draft",
});
expect(amountFor(onlyDraft.totals, "CZK")).toBe(2420);
// Invalidated alone -> 1210.
const onlyInvalid = await getOfferTotals({
search: OFFER_MARKER,
status: "invalidated",
});
expect(amountFor(onlyInvalid.totals, "CZK")).toBe(1210);
});
});
describe("getInvoiceListTotals (issued invoices) per-currency aggregation", () => {
// issue_date is settable at create, so no post-create pin needed. VAT is
// applied per-line (computeInvoiceTotals) but with a single line per invoice
// here the result matches the whole-subtotal math.
const dateStr = `${STATS_YEAR}-0${STATS_MONTH}-15`;
const mk = async (
currency: string,
qty: number,
price: number,
status = "draft",
) => {
const inv = await createInvoice({
status,
currency,
vat_rate: 21,
apply_vat: true,
issue_date: dateStr,
items: [{ description: "X", quantity: qty, unit_price: price }],
});
createdInvoiceIds.push(inv.id);
return inv.id;
};
it("sums invoice TOTAL incl. VAT per currency over the full filtered set", async () => {
// CZK #1: 2 x 1000 @21% = 2420
// CZK #2: 1 x 500 @21% = 605 => CZK total 3025
// EUR : 3 x 100 @21% = 363 => EUR total 363
await mk("CZK", 2, 1000);
await mk("CZK", 1, 500);
await mk("EUR", 3, 100);
const { totals } = await getInvoiceListTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
});
it("respects the where: a different month and a status filter change the result", async () => {
const nextDateStr = `${STATS_YEAR}-0${STATS_MONTH + 1}-15`;
// Target month: one draft, one issued (both 1210). Next month: one draft.
await mk("CZK", 1, 1000, "draft");
// An already-issued invoice numbers immediately; that's fine for the sum.
const issued = await createInvoice({
status: "issued",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
issue_date: dateStr,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
createdInvoiceIds.push(issued.id);
const next = await createInvoice({
status: "draft",
currency: "CZK",
vat_rate: 21,
apply_vat: true,
issue_date: nextDateStr,
items: [{ description: "X", quantity: 1, unit_price: 1000 }],
});
createdInvoiceIds.push(next.id);
// Whole target month: both count -> 2 x 1210 = 2420.
const whole = await getInvoiceListTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(whole.totals, "CZK")).toBe(2420);
// Status filter narrows to the single 'issued' in the target month -> 1210.
const onlyIssued = await getInvoiceListTotals({
month: STATS_MONTH,
year: STATS_YEAR,
status: "issued",
});
expect(amountFor(onlyIssued.totals, "CZK")).toBe(1210);
// Next month sees only the one invoice there -> 1210.
const nextMonth = await getInvoiceListTotals({
month: STATS_MONTH + 1,
year: STATS_YEAR,
});
expect(amountFor(nextMonth.totals, "CZK")).toBe(1210);
});
});
describe("received-invoices list-totals per-currency aggregation (GROSS)", () => {
// No service create for received invoices — rows are written directly. The
// list/totals `where` filters by the month/year COLUMNS (not issue_date).
// amount is GROSS, summed directly per currency.
const mkReceived = async (currency: string, amount: number) => {
const row = await prisma.received_invoices.create({
data: {
month: STATS_MONTH,
year: STATS_YEAR,
supplier_name: "Totals Test Supplier",
amount,
currency,
vat_rate: 21,
status: "unpaid",
},
});
createdReceivedIds.push(row.id);
return row.id;
};
it("sums GROSS amount per currency over the full filtered set", async () => {
// Two CZK (2420 + 605) + one EUR (363). Amounts are GROSS — summed as-is.
await mkReceived("CZK", 2420);
await mkReceived("CZK", 605);
await mkReceived("EUR", 363);
const { totals } = await getReceivedInvoiceListTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(3025);
expect(amountFor(totals, "EUR")).toBe(363);
});
it("respects the where: a different month and a search filter change the result", async () => {
await mkReceived("CZK", 1000);
// A row in the NEXT month must not count toward the target month's total.
const other = await prisma.received_invoices.create({
data: {
month: STATS_MONTH + 1,
year: STATS_YEAR,
supplier_name: "Totals Test Supplier",
amount: 9999,
currency: "CZK",
vat_rate: 21,
status: "unpaid",
},
});
createdReceivedIds.push(other.id);
const { totals } = await getReceivedInvoiceListTotals({
month: STATS_MONTH,
year: STATS_YEAR,
});
expect(amountFor(totals, "CZK")).toBe(1000);
// Search on the supplier name still scopes to the target-month row.
const { totals: searched } = await getReceivedInvoiceListTotals({
month: STATS_MONTH,
year: STATS_YEAR,
search: "Totals Test Supplier",
});
expect(amountFor(searched, "CZK")).toBe(1000);
});
});

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