Compare commits

...

155 Commits

Author SHA1 Message Date
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
159 changed files with 28212 additions and 28646 deletions

3
.gitignore vendored
View File

@@ -13,3 +13,6 @@ dist-client/
# Claude worktrees
.claude/worktrees/
.claude/settings.local.json
# Superpowers brainstorm mockups
.superpowers/

View File

@@ -14,7 +14,7 @@ Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operation
| ORM | Prisma 6.19.2 → MySQL |
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
| Validation | Zod 4.3.6 |
| Frontend | React 18.3.1 + Vite 8.0.0 |
| Frontend | React 18.3.1 + Vite 8.0.0 + Material UI v7 (Emotion) |
| Testing | Vitest 4.1.0 + Supertest |
| PDF | Puppeteer 24.x |
| Email | nodemailer 8.x |
@@ -35,17 +35,21 @@ src/
├── 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 frontend (57 .tsx files)
├── admin/ # React 18 + Material UI frontend (~105 .tsx files)
│ ├── AdminApp.tsx # Router + lazy-loaded pages
│ ├── contexts/ # AuthContext, AlertContext
│ ├── components/ # Layout, modals, tables, editors
│ ├── 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
│ ├── pages/ # One file per page/feature
│ ├── hooks/ # useApiCall, useListData, useTableSort, etc.
│ ├── 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 (auth, numbering)
└── __tests__/ # Vitest tests (~14 files: auth, numbering, warehouse, plan, invoices, …)
prisma/
├── schema.prisma # 32 models, MySQL, snake_case columns
├── schema.prisma # 49 models, MySQL, snake_case columns
└── migrations/ # Applied migrations
dist/ # Compiled server (CommonJS, ES2022)
@@ -236,7 +240,7 @@ if ("error" in body) return error(reply, body.error, 400);
Tests live in `src/__tests__/`. They use Vitest + Supertest against a real test database (`.env.test`).
- Test coverage is minimal: only `auth` and `numbering` are tested.
- The suite spans ~14 files / 152 tests — auth, numbering, warehouse, plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create. Server-side only (no component tests yet).
- Use `buildApp()` helper to spin up the Fastify instance for tests.
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout.
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
@@ -250,9 +254,9 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
- 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.
- API calls go through `src/admin/utils/api.ts` which handles token refresh automatically (deduplicates concurrent refresh calls).
- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
- 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
@@ -274,6 +278,33 @@ When entity A embeds/references entity B, A's mutation handlers invalidate B's d
---
## 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`.
---
## Database Conventions
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.

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.

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.

914
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.9.8",
"version": "2.0.4",
"description": "",
"main": "dist/server.js",
"scripts": {
@@ -32,11 +32,15 @@
"@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",
"@mui/material": "^7.3.11",
"@mui/x-date-pickers": "^8.29.0",
"@prisma/client": "^6.19.2",
"@tanstack/react-query": "^5.100.5",
"@types/jsdom": "^28.0.1",
@@ -74,8 +78,8 @@
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@types/supertest": "^7.2.0",
"@vitejs/plugin-react": "^6.0.1",
"concurrently": "^9.2.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>
);
}

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

@@ -2,6 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
generateSharedNumber,
generateOfferNumber,
previewSharedNumber,
isSharedNumberTaken,
previewOfferNumber,
isOfferNumberTaken,
} from "../services/numbering.service";
import prisma from "../config/database";
@@ -48,3 +52,83 @@ describe("generateOfferNumber", () => {
expect(num1).not.toBe(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 () => {
// Fresh counter → preview points at the first sequence number. With the
// skip-taken logic this is guaranteed free, so we can safely claim it.
const first = await previewSharedNumber();
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 () => {
// Fresh counter → preview points at the first sequence number; with the
// skip-taken logic it's guaranteed free, so we can claim it.
const first = await previewOfferNumber();
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

@@ -5,29 +5,12 @@ import { AuthProvider } from "./context/AuthContext";
import { AlertProvider } from "./context/AlertContext";
import { queryClient } from "./lib/queryClient";
import ErrorBoundary from "./components/ErrorBoundary";
import AdminLayout from "./components/AdminLayout";
import AppShell from "./ui/AppShell";
import AlertContainer from "./components/AlertContainer";
import MuiProvider from "./ui/MuiProvider";
import { LoadingState } from "./ui";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import "./variables.css";
import "./base.css";
import "./forms.css";
import "./buttons.css";
import "./layout.css";
import "./components.css";
import "./tables.css";
import "./datepicker.css";
import "./filemanager.css";
import "./pagination.css";
import "./responsive.css";
import "./login.css";
import "./dashboard.css";
import "./attendance.css";
import "./plan.css";
import "./settings.css";
import "./offers.css";
import "./invoices.css";
import "./warehouse.css";
const Users = lazy(() => import("./pages/Users"));
const Attendance = lazy(() => import("./pages/Attendance"));
@@ -81,24 +64,23 @@ const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
export default function AdminApp() {
return (
<MuiProvider>
<AuthProvider>
<AlertProvider>
<QueryClientProvider client={queryClient}>
<AlertContainer />
<ErrorBoundary>
<Suspense
fallback={
<div className="admin-loading">
<div className="admin-spinner" />
</div>
}
>
<Suspense fallback={<LoadingState />}>
<Routes>
<Route path="login" element={<Login />} />
<Route element={<AdminLayout />}>
{import.meta.env.DEV && UiKit && (
<Route path="ui-kit" element={<UiKit />} />
)}
<Route element={<AppShell />}>
<Route index element={<Dashboard />} />
<Route path="users" element={<Users />} />
<Route path="attendance" element={<Attendance />} />
@@ -156,7 +138,10 @@ export default function AdminApp() {
<Route path="settings" element={<Settings />} />
<Route path="audit-log" element={<AuditLog />} />
<Route path="warehouse" element={<Warehouse />} />
<Route path="warehouse/items" element={<WarehouseItems />} />
<Route
path="warehouse/items"
element={<WarehouseItems />}
/>
<Route
path="warehouse/items/:id"
element={<WarehouseItemDetail />}
@@ -233,5 +218,6 @@ export default function AdminApp() {
</QueryClientProvider>
</AlertProvider>
</AuthProvider>
</MuiProvider>
);
}

162
src/admin/GlobalStyles.tsx Normal file
View File

@@ -0,0 +1,162 @@
import GlobalStyles from "@mui/material/GlobalStyles";
import { fonts } from "./theme";
const EASE = "cubic-bezier(0.4, 0, 0.2, 1)";
/**
* App-wide global styles, theme-aware via MUI's CSS variables.
*
* This replaces the former hand-written `base.css` (reset, typography,
* scrollbar, ::selection, the theme cross-fade timing, utility classes).
* It is rendered once inside MuiProvider so every rule resolves against the
* active color scheme (`theme.vars.*`). The pre-React bootstrap spinner lives
* inline in App.tsx instead, because it renders before MUI mounts.
*/
export default function AppGlobalStyles() {
return (
<GlobalStyles
styles={(theme) => ({
// ---- Reset ----
"*": { margin: 0, padding: 0, boxSizing: "border-box" },
html: { scrollBehavior: "smooth", overflowX: "hidden" },
"html, body, #root": { minHeight: "100dvh", maxWidth: "100vw" },
body: {
fontFamily: fonts.body,
fontSize: "16px",
lineHeight: 1.6,
color: theme.vars!.palette.text.primary,
background: theme.vars!.palette.background.default,
overflowX: "hidden",
overscrollBehaviorX: "none",
WebkitFontSmoothing: "antialiased",
MozOsxFontSmoothing: "grayscale",
transition: "background-color 0.3s ease, color 0.3s ease",
},
"#root": { overflowX: "hidden", touchAction: "pan-y pinch-zoom" },
// ---- Theme cross-fade (View Transitions API, see ThemeContext) ----
"::view-transition-old(root), ::view-transition-new(root)": {
animationDuration: "0.3s",
animationTimingFunction: EASE,
},
// ---- Typography ----
"h1, h2, h3, h4, h5, h6": {
fontFamily: fonts.heading,
fontWeight: 700,
lineHeight: 1.2,
color: theme.vars!.palette.text.primary,
},
h1: { fontSize: "clamp(2.5rem, 5vw, 4rem)" },
h2: { fontSize: "clamp(2rem, 4vw, 3rem)" },
h3: { fontSize: "clamp(1.25rem, 2vw, 1.5rem)" },
p: { color: theme.vars!.palette.text.secondary, lineHeight: 1.6 },
a: {
color: "inherit",
textDecoration: "none",
transition: `all 0.3s ${EASE}`,
},
img: { maxWidth: "100%", height: "auto" },
// ---- Scrollbar & selection ----
"::-webkit-scrollbar": { width: "8px" },
"::-webkit-scrollbar-track": {
background: theme.vars!.palette.background.paper,
},
"::-webkit-scrollbar-thumb": {
background: theme.vars!.palette.divider,
borderRadius: "4px",
},
"::-webkit-scrollbar-thumb:hover": {
background: theme.vars!.palette.text.secondary,
},
"::selection": {
background: theme.vars!.palette.primary.main,
color: "#fff",
},
// ---- Utility classes (legacy, still used across pages) ----
".text-warning": {
color: `${theme.vars!.palette.warning.main} !important`,
},
".text-danger": {
color: `${theme.vars!.palette.error.main} !important`,
},
".text-success": {
color: `${theme.vars!.palette.success.main} !important`,
},
".text-muted": {
color: `${theme.vars!.palette.text.secondary} !important`,
},
".text-secondary": {
color: `${theme.vars!.palette.text.secondary} !important`,
},
".text-tertiary": {
color: `${theme.vars!.palette.text.disabled} !important`,
},
".text-accent": {
color: `${theme.vars!.palette.primary.main} !important`,
},
".fw-500": { fontWeight: 500 },
".fw-600": { fontWeight: "600 !important" },
".link-accent": {
color: theme.vars!.palette.primary.main,
fontWeight: 500,
textDecoration: "none",
},
".link-accent:hover": { textDecoration: "underline" },
".flex-1": { flex: 1 },
".flex-row": { display: "flex", alignItems: "center" },
".flex-row-gap": {
display: "flex",
alignItems: "center",
gap: "0.75rem",
},
".flex-between": {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
".inline-flex": { display: "inline-flex", alignItems: "center" },
".mb-1": { marginBottom: "0.25rem" },
".mb-2": { marginBottom: "0.5rem" },
".mb-3": { marginBottom: "0.75rem" },
".mb-4": { marginBottom: "1rem" },
".mb-6": { marginBottom: "1.5rem" },
".mt-1": { marginTop: "0.25rem" },
".mt-2": { marginTop: "0.5rem" },
".mt-3": { marginTop: "0.75rem" },
".mt-6": { marginTop: "1.5rem" },
".gap-1": { gap: "0.25rem" },
".gap-2": { gap: "0.5rem" },
".gap-3": { gap: "0.75rem" },
".gap-4": { gap: "1rem" },
".gap-5": { gap: "1.25rem" },
".gap-6": { gap: "1.5rem" },
".text-right": { textAlign: "right" },
".text-center": { textAlign: "center" },
".text-xs": { fontSize: "0.75rem" },
".text-sm": { fontSize: "0.8125rem" },
".text-md": { fontSize: "0.875rem" },
".text-base": { fontSize: "1rem" },
".w-full": { width: "100%" },
".max-w-xs": { maxWidth: "120px" },
".max-w-sm": { maxWidth: "200px" },
".whitespace-nowrap": { whiteSpace: "nowrap" },
// ---- Reduced motion ----
"@media (prefers-reduced-motion: reduce)": {
"::view-transition-old(root), ::view-transition-new(root)": {
animation: "none",
},
"*, *::before, *::after": {
animationDuration: "0.01ms !important",
animationIterationCount: "1 !important",
transitionDuration: "0.01ms !important",
scrollBehavior: "auto !important",
},
},
})}
/>
);
}

View File

@@ -1,455 +0,0 @@
/* ============================================================================
Attendance Module
============================================================================ */
/* Layout */
.attendance-layout {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
@media (min-width: 1024px) {
.attendance-layout {
flex-direction: row;
align-items: flex-start;
}
}
.attendance-main {
flex: 1;
min-width: 0;
}
.attendance-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (min-width: 1024px) {
.attendance-sidebar {
width: 320px;
flex-shrink: 0;
}
}
/* Clock Card */
.attendance-clock-card {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: var(--border-radius);
padding: 2rem;
}
.attendance-clock-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.attendance-clock-status {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 500;
color: var(--text-secondary);
}
.attendance-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--text-muted);
}
.attendance-status-dot.active {
background: var(--success);
box-shadow: 0 0 8px color-mix(in srgb, var(--success) 50%, transparent);
animation: pulse 2s ease-in-out infinite;
}
.attendance-clock-time {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-heading);
}
/* Shift Info */
.attendance-shift-info {
margin-bottom: 2rem;
}
.attendance-shift-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
@media (max-width: 640px) {
.attendance-shift-row {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
.attendance-shift-item {
text-align: center;
padding: 1rem;
background: var(--bg-tertiary);
border-radius: var(--border-radius-sm);
}
.attendance-shift-label {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.attendance-shift-value {
display: block;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-secondary);
}
.attendance-shift-value.success {
color: var(--success);
}
/* Clock Actions */
.attendance-clock-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* Notes */
.attendance-notes {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.attendance-notes-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
/* Project Section */
.attendance-project-section {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-tertiary);
border-radius: var(--border-radius-sm);
}
.attendance-project-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.attendance-project-header .attendance-shift-label {
margin-bottom: 0;
}
.attendance-project-section .admin-form-select {
margin-bottom: 0;
}
.attendance-project-logs {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.attendance-project-log-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
padding: 0.375rem 0.5rem;
background: var(--bg-secondary);
border-radius: var(--border-radius-sm);
}
.attendance-project-log-name {
font-weight: 500;
color: var(--text-primary);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attendance-project-log-time {
color: var(--text-muted);
white-space: nowrap;
font-size: 0.75rem;
}
.attendance-project-log-duration {
color: var(--text-secondary);
font-weight: 600;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Balance Card */
.attendance-balance-card {
background: var(--gradient);
border-radius: var(--border-radius);
padding: 1.5rem;
color: #fff;
}
.attendance-balance-title {
font-size: 0.875rem;
font-weight: 500;
opacity: 0.9;
margin-bottom: 0.75rem;
color: inherit;
}
.attendance-balance-value {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 1rem;
}
.attendance-balance-number {
font-size: 3rem;
font-weight: 700;
line-height: 1;
color: inherit;
}
.attendance-balance-unit {
font-size: 1rem;
opacity: 0.9;
}
.attendance-balance-detail {
display: flex;
justify-content: space-between;
font-size: 0.8125rem;
opacity: 0.8;
margin-bottom: 0.75rem;
}
.attendance-balance-bar {
height: 6px;
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
overflow: hidden;
}
.attendance-balance-progress {
height: 100%;
background: rgba(255, 255, 255, 0.9);
border-radius: 3px;
transition: width 0.3s ease;
}
/* Quick Links */
.attendance-quick-links {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: var(--border-radius);
padding: 1rem;
}
.attendance-quick-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.attendance-quick-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
margin: -0.25rem -0.5rem;
border-radius: var(--border-radius-sm);
color: var(--text-secondary);
text-decoration: none;
transition: var(--transition);
}
.attendance-quick-link:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.attendance-quick-link span {
flex: 1;
}
.attendance-quick-link svg:last-child {
opacity: 0.5;
}
/* Leave Type Badges */
.attendance-leave-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.55rem;
border-radius: var(--border-radius-sm);
font-size: 0.75rem;
font-weight: 500;
line-height: 1.2;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.attendance-leave-badge.badge-vacation {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.attendance-leave-badge.badge-sick {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
.attendance-leave-badge.badge-holiday {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.attendance-leave-badge.badge-unpaid {
background: var(--muted-light);
color: var(--muted);
}
/* Working Status Badge */
.attendance-working-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 0.75rem;
font-weight: 600;
}
.attendance-working-badge.working {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.attendance-working-badge.finished {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
/* GPS Link */
.attendance-gps-link {
text-decoration: none;
font-size: 1rem;
}
.attendance-gps-link:hover {
transform: scale(1.1);
}
/* Location Page */
.attendance-location-map {
height: 400px;
border-radius: var(--border-radius-sm);
margin-bottom: 1.5rem;
background: var(--bg-secondary);
position: relative;
z-index: 0; /* stacking context - Leaflet z-indexy zustanou uvnitr */
}
.attendance-location-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
}
.attendance-location-card {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
padding: 1rem;
}
.attendance-location-card.empty {
opacity: 0.6;
}
.attendance-location-title {
font-size: 1rem;
margin-bottom: 0.5rem;
color: var(--accent-color);
font-weight: 600;
}
.attendance-location-time {
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.attendance-location-address {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.attendance-location-coords {
font-size: 0.8rem;
color: var(--text-muted);
font-family: monospace;
}
/* ============================================================================
Mobile (responsive)
============================================================================ */
/* The Leaflet map at a fixed 400px dominates a phone viewport — let it scale
with the viewport height (with sensible min/max) on small screens. */
@media (max-width: 640px) {
.attendance-location-map {
height: clamp(220px, 45vh, 400px);
}
}
/* The clock card's 2rem padding crushes content at ~360px — tighten it. */
@media (max-width: 480px) {
.attendance-clock-card {
padding: 1rem;
}
}

View File

@@ -1,411 +0,0 @@
/* ============================================================================
Reset & Base
============================================================================ */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
overflow-x: hidden;
}
html,
body,
#root {
min-height: 100%;
min-height: 100dvh;
max-width: 100vw;
}
body {
font-family: var(--font-body);
font-size: 16px;
line-height: 1.6;
color: var(--text-primary);
background: var(--bg-primary);
overflow-x: hidden;
overscroll-behavior-x: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
.admin-sidebar,
.admin-header,
.admin-card,
.admin-modal {
transition:
background-color 0.3s ease,
color 0.3s ease,
border-color 0.3s ease;
}
#root {
overflow-x: hidden;
touch-action: pan-y pinch-zoom;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading);
font-weight: 700;
line-height: 1.2;
color: var(--text-primary);
}
h1 {
font-size: clamp(2.5rem, 5vw, 4rem);
}
h2 {
font-size: clamp(2rem, 4vw, 3rem);
}
h3 {
font-size: clamp(1.25rem, 2vw, 1.5rem);
}
p {
color: var(--text-secondary);
line-height: 1.6;
}
a {
color: inherit;
text-decoration: none;
transition: var(--transition);
}
img {
max-width: 100%;
height: auto;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
::selection {
background: var(--accent-color);
color: #fff;
}
/* ============================================================================
Base / Utilities
============================================================================ */
.text-warning {
color: var(--warning) !important;
}
.text-danger {
color: var(--danger) !important;
}
.text-success {
color: var(--success) !important;
}
.text-muted {
color: var(--text-muted) !important;
}
.text-secondary {
color: var(--text-secondary) !important;
}
.text-tertiary {
color: var(--text-tertiary) !important;
}
.text-accent {
color: var(--accent-color) !important;
}
.fw-600 {
font-weight: 600 !important;
}
.link-accent {
color: var(--accent-color);
font-weight: 500;
text-decoration: none;
}
.link-accent:hover {
text-decoration: underline;
}
/* Layout utilities */
.flex-1 {
flex: 1;
}
.flex-row {
display: flex;
align-items: center;
}
.flex-row-gap {
display: flex;
align-items: center;
gap: var(--space-3);
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
/* Spacing utilities */
.mb-2 {
margin-bottom: var(--space-2);
}
.mb-4 {
margin-bottom: var(--space-4);
}
.mb-6 {
margin-bottom: var(--space-6);
}
.mt-2 {
margin-top: var(--space-2);
}
.mt-6 {
margin-top: var(--space-6);
}
.gap-2 {
gap: var(--space-2);
}
.gap-4 {
gap: var(--space-4);
}
.gap-5 {
gap: var(--space-5);
}
/* Typography utilities */
.fw-500 {
font-weight: 500;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
/* Spinner variant */
.admin-spinner-sm {
width: 16px;
height: 16px;
border-width: 2px;
}
/* Monospace for data values (times, dates, numbers, IDs) */
.admin-mono {
font-family: var(--font-mono);
font-size: 0.875em;
letter-spacing: -0.01em;
}
/* Drag handle */
.admin-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: none;
color: var(--text-muted);
cursor: grab;
border-radius: 4px;
padding: 0;
transition:
color 0.15s,
background 0.15s;
touch-action: none;
}
.admin-drag-handle:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
.admin-drag-handle:active {
cursor: grabbing;
}
/* Error stack (DEV only) */
.admin-error-stack {
max-width: 600px;
max-height: 200px;
overflow: auto;
padding: 0.75rem 1rem;
margin: 0;
border-radius: var(--border-radius-sm);
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--danger);
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
text-align: left;
white-space: pre-wrap;
word-break: break-word;
}
/* Keyboard shortcut badge */
.admin-kbd {
display: inline-block;
padding: 2px 7px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.4;
border-radius: 4px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
white-space: nowrap;
}
/* Loading & Animations */
.admin-spinner {
width: 32px;
height: 32px;
border: 2px solid var(--accent-color);
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.admin-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 256px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes float {
0%,
100% {
transform: translate(0, 0);
}
50% {
transform: translate(30px, -30px);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* ── Additional Utilities ─────────────────────────────────────────── */
/* Font sizes */
.text-xs {
font-size: 0.75rem;
}
.text-sm {
font-size: 0.8125rem;
}
.text-md {
font-size: 0.875rem;
}
.text-base {
font-size: 1rem;
}
/* Width utilities */
.w-full {
width: 100%;
}
.max-w-xs {
max-width: 120px;
}
.max-w-sm {
max-width: 200px;
}
/* Whitespace */
.whitespace-nowrap {
white-space: nowrap;
}
/* Additional gaps */
.gap-1 {
gap: 0.25rem;
}
.gap-3 {
gap: 0.75rem;
}
.gap-6 {
gap: 1.5rem;
}
/* Additional margins */
.mb-1 {
margin-bottom: 0.25rem;
}
.mb-3 {
margin-bottom: 0.75rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-3 {
margin-top: 0.75rem;
}
/* Display */
.inline-flex {
display: inline-flex;
align-items: center;
}
/* Prefers reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.admin-spinner {
animation-duration: 0.8s !important;
animation-iteration-count: infinite !important;
}
}

View File

@@ -1,153 +0,0 @@
/* ============================================================================
Buttons
============================================================================ */
.admin-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 8px 14px;
/* 1px transparent border on ALL buttons so switching between primary
and secondary doesn't cause a 1px layout shift (the border is always
1px — only the colour changes). */
border: 1px solid transparent;
border-radius: var(--border-radius-sm);
font-size: 13px;
font-weight: 550;
font-family: inherit;
cursor: pointer;
transition: var(--transition);
white-space: nowrap;
}
.admin-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
/* Prevent buttons from growing when text changes to spinner + loading text */
.admin-modal-footer .admin-btn {
min-width: 100px;
}
.admin-modal-footer .admin-btn .admin-spinner {
margin: -2px 0;
}
.admin-btn-sm {
padding: 6px 11px;
font-size: 12px;
}
.admin-btn-primary {
background: var(--accent-color);
border-color: var(--accent-color);
color: #fff;
}
.admin-btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(214, 48, 49, 0.3);
}
.admin-btn .admin-spinner {
width: 16px;
height: 16px;
border-width: 2px;
}
.admin-btn-primary .admin-spinner {
border-color: rgba(255, 255, 255, 0.3);
border-top-color: #fff;
}
.admin-btn-secondary .admin-spinner {
border-color: rgba(var(--text-secondary-rgb, 107, 114, 128), 0.3);
border-top-color: var(--text-secondary);
}
.admin-btn-secondary {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.admin-btn-secondary:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--border-color-hover);
color: var(--text-primary);
}
.admin-btn-danger {
background: var(--danger);
border-color: var(--danger);
color: #fff;
}
.admin-btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 88%, #000);
border-color: color-mix(in srgb, var(--danger) 88%, #000);
transform: translateY(-1px);
box-shadow: 0 4px 12px color-mix(in srgb, var(--danger) 30%, transparent);
}
.admin-btn-danger .admin-spinner {
border-color: rgba(255, 255, 255, 0.3);
border-top-color: #fff;
}
.admin-btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: 32px;
height: 32px;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: var(--transition);
}
.admin-btn-icon:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.admin-btn-icon.accent {
color: var(--info);
}
.admin-btn-icon.accent:hover {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.admin-btn-icon.danger {
color: var(--danger);
}
.admin-btn-icon.danger:hover {
background: var(--danger-light);
}
/* Touch targets - min 44px on mobile */
@media (max-width: 768px) {
.admin-btn {
min-height: 44px;
padding: 10px 16px;
}
.admin-btn-sm {
min-height: 36px;
}
.admin-btn-icon {
min-width: 44px;
min-height: 44px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,233 +0,0 @@
import { forwardRef } from "react";
import DatePicker, { registerLocale } from "react-datepicker";
import { cs } from "date-fns/locale";
import { parse, format } from "date-fns";
import "react-datepicker/dist/react-datepicker.css";
registerLocale("cs", cs);
// Ensure portal root exists
if (
typeof document !== "undefined" &&
!document.getElementById("datepicker-portal")
) {
const el = document.createElement("div");
el.id = "datepicker-portal";
document.body.appendChild(el);
}
const isTouchDevice = () =>
typeof window !== "undefined" &&
("ontouchstart" in window || navigator.maxTouchPoints > 0);
interface CustomInputProps {
value?: string;
onClick?: () => void;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
required?: boolean;
readOnly?: boolean;
disabled?: boolean;
}
const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
(
{ value, onClick, onChange, placeholder, required, readOnly, disabled },
ref,
) => (
<input
className="admin-form-input"
onClick={onClick}
onChange={onChange}
value={value}
placeholder={placeholder}
ref={ref}
required={required}
readOnly={readOnly}
disabled={disabled}
autoComplete="off"
/>
),
);
interface NativeInputProps {
mode: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
minDate?: string;
maxDate?: string;
disabled?: boolean;
placeholder?: string;
}
const modeToInputType: Record<string, string> = {
month: "month",
time: "time",
};
function NativeInput({
mode,
value,
onChange,
required,
minDate,
maxDate,
disabled,
placeholder,
}: NativeInputProps) {
const type = modeToInputType[mode] || "date";
// For time inputs, min/max must be in HH:mm format, not date format
const formatTimeMinMax = (val: string | undefined): string | undefined => {
if (!val) return undefined;
// If it looks like a date string (yyyy-MM-dd), extract time portion if present,
// otherwise it's not a valid time min/max — return undefined
if (val.includes("T")) return val.split("T")[1]?.substring(0, 5);
if (val.includes(":")) return val.substring(0, 5);
return undefined;
};
const minProp =
mode === "time" ? formatTimeMinMax(minDate) : minDate || undefined;
const maxProp =
mode === "time" ? formatTimeMinMax(maxDate) : maxDate || undefined;
return (
<input
type={type}
lang="cs"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="admin-form-input"
placeholder={placeholder}
required={required}
disabled={disabled}
min={minProp}
max={maxProp}
/>
);
}
interface AdminDatePickerProps {
mode?: "date" | "month" | "time";
value: string;
onChange: (value: string) => void;
minDate?: string;
maxDate?: string;
disabled?: boolean;
placeholder?: string;
required?: boolean;
}
export default function AdminDatePicker({
mode = "date",
value,
onChange,
required,
minDate,
maxDate,
disabled,
placeholder,
}: AdminDatePickerProps) {
const useNative = isTouchDevice();
// Mode-aware default placeholder so empty date fields always show a format
// hint (callers can still override, e.g. "Od"/"Do" on range filters).
const effectivePlaceholder =
placeholder ??
(mode === "time" ? "hh:mm" : mode === "month" ? "mm.rrrr" : "dd.mm.rrrr");
if (useNative) {
return (
<NativeInput
mode={mode}
value={value}
onChange={onChange}
required={required}
minDate={minDate}
maxDate={maxDate}
disabled={disabled}
placeholder={effectivePlaceholder}
/>
);
}
const toDate = (val: string | null | undefined): Date | null => {
if (!val) return null;
try {
if (mode === "date") return parse(val, "yyyy-MM-dd", new Date());
if (mode === "time") {
const [h, m] = val.split(":");
const d = new Date();
d.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
return d;
}
if (mode === "month") return parse(val, "yyyy-MM", new Date());
} catch {
return null;
}
return null;
};
const handleChange = (date: Date | null) => {
if (!date) {
onChange("");
return;
}
if (mode === "date") onChange(format(date, "yyyy-MM-dd"));
else if (mode === "time") onChange(format(date, "HH:mm"));
else if (mode === "month") onChange(format(date, "yyyy-MM"));
};
const parseMinMax = (val: string | undefined): Date | undefined => {
if (!val) return undefined;
try {
if (mode === "date") return parse(val, "yyyy-MM-dd", new Date());
if (mode === "month") return parse(val, "yyyy-MM", new Date());
} catch {
return undefined;
}
return undefined;
};
const customInput = (
<CustomInput
required={required}
placeholder={effectivePlaceholder}
disabled={disabled}
/>
);
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: "cs",
customInput,
placeholderText: effectivePlaceholder,
minDate: parseMinMax(minDate),
maxDate: parseMinMax(maxDate),
popperPlacement: "bottom-start" as const,
portalId: "datepicker-portal",
disabled,
};
if (mode === "time") {
return (
<DatePicker
{...commonProps}
showTimeSelect
showTimeSelectOnly
timeIntervals={5}
timeCaption="Čas"
dateFormat="HH:mm"
timeFormat="HH:mm"
/>
);
}
if (mode === "month") {
return (
<DatePicker {...commonProps} showMonthYearPicker dateFormat="MM/yyyy" />
);
}
return <DatePicker {...commonProps} dateFormat="dd.MM.yyyy" />;
}

View File

@@ -1,136 +0,0 @@
import { useState, useCallback } from "react";
import { Outlet, Navigate, useLocation } from "react-router-dom";
import { motion } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useTheme } from "../../context/ThemeContext";
import { setLogoutAlert } from "../utils/api";
import useModalLock from "../hooks/useModalLock";
import Sidebar from "./Sidebar";
import ShortcutsHelp from "./ShortcutsHelp";
export default function AdminLayout() {
const { isAuthenticated, loading, user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [loggingOut, setLoggingOut] = useState(false);
const location = useLocation();
// Session is managed by AuthProvider (initial check + proactive refresh via setTimeout).
// Do not call checkSession on route changes — concurrent refresh calls with token rotation
// would invalidate each other and kick the user out.
const handleLogout = useCallback(() => {
setLoggingOut(true);
setSidebarOpen(false);
setLogoutAlert();
setTimeout(() => logout(), 400);
}, [logout]);
useModalLock(sidebarOpen);
if (loading) {
return (
<div className="admin-layout">
<div className="admin-loading" style={{ width: "100%" }}>
<div className="admin-spinner" />
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// If 2FA is required but user hasn't enabled it, redirect to dashboard (where setup lives)
const needs2FASetup = user?.require2FA && !user?.totpEnabled;
if (needs2FASetup && location.pathname !== "/") {
return <Navigate to="/" replace />;
}
return (
<motion.div
className="admin-layout"
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] }}
>
<Sidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
onLogout={handleLogout}
/>
<div className="admin-main">
<header className="admin-header">
<button
onClick={() => setSidebarOpen(true)}
className="admin-menu-btn"
aria-label="Otevřít menu"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<div className="flex-1" />
<button
onClick={toggleTheme}
className="admin-header-theme-btn"
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
>
<span
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
</span>
<span
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</span>
</button>
</header>
<main className="admin-content">
<Outlet />
</main>
</div>
<ShortcutsHelp />
</motion.div>
);
}

View File

@@ -1,102 +1,29 @@
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
import Snackbar from "@mui/material/Snackbar";
import MuiAlert from "@mui/material/Alert";
import { useAlertState } from "../context/AlertContext";
const icons: Record<string, React.ReactNode> = {
success: (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
),
error: (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
),
warning: (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
),
info: (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
),
};
export default function AlertContainer() {
const { alerts, removeAlert } = useAlertState();
return (
<div className="admin-alert-container" role="status" aria-live="polite">
<AnimatePresence>
{alerts.map((alert) => (
<motion.div
<>
{alerts.map((alert, index) => (
<Snackbar
key={alert.id}
className={`admin-toast admin-toast-${alert.type}`}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ duration: 0.2 }}
open
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
sx={{ bottom: { xs: 16 + index * 72 } }}
>
<span className="admin-toast-icon">{icons[alert.type]}</span>
<span className="admin-toast-message">{alert.message}</span>
<button
className="admin-toast-close"
onClick={() => removeAlert(alert.id)}
aria-label="Zavřít"
<MuiAlert
severity={alert.type}
onClose={() => removeAlert(alert.id)}
variant="filled"
sx={{ width: "100%", minWidth: 280 }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</motion.div>
{alert.message}
</MuiAlert>
</Snackbar>
))}
</AnimatePresence>
</div>
</>
);
}

View File

@@ -1,4 +1,6 @@
import { Link } from "react-router-dom";
import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import {
formatDate,
formatDatetime,
@@ -6,8 +8,8 @@ import {
calculateWorkMinutes,
formatMinutes,
getLeaveTypeName,
getLeaveTypeBadgeClass,
} from "../utils/attendanceHelpers";
import { DataTable, StatusChip, EmptyState, type DataColumn } from "../ui";
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
interface AttendanceRecord extends HookAttendanceRecord {
@@ -23,6 +25,56 @@ interface AttendanceShiftTableProps {
onDelete: (record: AttendanceRecord) => void;
}
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
/** Map an attendance leave_type to a StatusChip semantic color (mirrors the
* legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue),
* sick→error(red), unpaid→default(gray). */
function leaveTypeChipColor(
type: string,
): "default" | "success" | "error" | "warning" | "info" {
switch (type) {
case "vacation":
return "info";
case "sick":
return "error";
case "unpaid":
return "default";
default:
return "info";
}
}
function formatBreak(record: AttendanceRecord): string {
if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
@@ -36,9 +88,7 @@ function formatBreak(record: AttendanceRecord): string {
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
if (record.project_logs && record.project_logs.length > 0) {
return (
<div
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
{record.project_logs.map((log, i) => {
let h: number,
m: number,
@@ -65,33 +115,32 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
}
}
return (
<span
<StatusChip
key={log.id ?? i}
className="admin-badge"
style={{
fontSize: "0.7rem",
display: "inline-block",
background: isActive ? "var(--accent-light)" : undefined,
}}
>
{log.project_name || `#${log.project_id}`}{" "}
{durationValid
color={isActive ? "info" : "default"}
label={`${log.project_name || `#${log.project_id}`} ${
durationValid
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
: "—"}
</span>
: "—"
}`}
sx={{ fontSize: "0.7rem", maxWidth: "100%" }}
/>
);
})}
</div>
</Box>
);
}
if (record.project_name) {
return (
<span
className="admin-badge admin-badge-wrap"
style={{ fontSize: "0.75rem" }}
>
{record.project_name}
</span>
<StatusChip
color="default"
label={record.project_name}
sx={{
fontSize: "0.75rem",
height: "auto",
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
}}
/>
);
}
return "—";
@@ -102,141 +151,164 @@ export default function AttendanceShiftTable({
onEdit,
onDelete,
}: AttendanceShiftTableProps) {
if (records.length === 0) {
const columns: DataColumn<AttendanceRecord>[] = [
{
key: "date",
header: "Datum",
width: "9%",
mono: true,
render: (record) => formatDate(record.shift_date),
},
{
key: "user",
header: "Zaměstnanec",
width: "14%",
render: (record) => record.user_name,
},
{
key: "type",
header: "Typ",
width: "10%",
render: (record) => {
const leaveType = record.leave_type || "work";
return (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
<StatusChip
color={leaveTypeChipColor(leaveType)}
label={getLeaveTypeName(leaveType)}
/>
);
}
return (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Zaměstnanec</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekt</th>
<th>GPS</th>
<th>Poznámka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
},
},
{
key: "arrival",
header: "Příchod",
width: "9%",
mono: true,
render: (record) => {
const isLeave = (record.leave_type || "work") !== "work";
return isLeave ? "—" : formatDatetime(record.arrival_time);
},
},
{
key: "break",
header: "Pauza",
width: "10%",
mono: true,
render: (record) => {
const isLeave = (record.leave_type || "work") !== "work";
return isLeave ? "—" : formatBreak(record);
},
},
{
key: "departure",
header: "Odchod",
width: "9%",
mono: true,
render: (record) => {
const isLeave = (record.leave_type || "work") !== "work";
return isLeave ? "—" : formatDatetime(record.departure_time);
},
},
{
key: "hours",
header: "Hodiny",
width: "9%",
mono: true,
render: (record) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
60
? (record.leave_hours != null ? Number(record.leave_hours) : 8) * 60
: calculateWorkMinutes({
...record,
notes: record.notes ?? undefined,
});
return workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—";
},
},
{
key: "project",
header: "Projekt",
width: "13%",
render: (record) => renderProjectCell(record),
},
{
key: "gps",
header: "GPS",
width: "5%",
render: (record) => {
const hasLocation =
(record.arrival_lat && record.arrival_lng) ||
(record.departure_lat && record.departure_lng);
return (
<tr key={record.id}>
<td className="admin-mono">{formatDate(record.shift_date)}</td>
<td>{record.user_name}</td>
<td>
<span
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">
{isLeave ? "—" : formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">
{isLeave ? "—" : formatBreak(record)}
</td>
<td className="admin-mono">
{isLeave ? "—" : formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td>
{hasLocation ? (
<Link
return hasLocation ? (
<Box
component={RouterLink}
to={`/attendance/location/${record.id}`}
className="attendance-gps-link"
title="Zobrazit polohu"
aria-label="Zobrazit polohu"
sx={{ textDecoration: "none" }}
>
<span aria-hidden="true">{"📍"}</span>
</Link>
</Box>
) : (
"—"
)}
</td>
<td
style={{
maxWidth: "100px",
);
},
},
{
key: "notes",
header: "Poznámka",
width: "9%",
render: (record) => (
<Box
component="span"
title={record.notes || ""}
sx={{
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={record.notes || ""}
>
{record.notes || ""}
</td>
<td>
<div className="admin-table-actions">
<button
</Box>
),
},
{
key: "actions",
header: "Akce",
width: "8%",
align: "right",
render: (record) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => onEdit(record)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
title="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
{EditIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => onDelete(record)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
title="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{DeleteIcon}
</IconButton>
</Box>
),
},
];
return (
<DataTable<AttendanceRecord>
columns={columns}
rows={records}
rowKey={(record) => record.id}
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
/>
);
}

View File

@@ -1,5 +1,6 @@
import FormModal from "./FormModal";
import AdminDatePicker from "./AdminDatePicker";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Modal, MonthField, TimeField, Field, CheckboxField } from "../ui";
interface BulkAttendanceForm {
month: string;
@@ -39,127 +40,136 @@ export default function BulkAttendanceModal({
toggleAllUsers,
}: BulkAttendanceModalProps) {
return (
<FormModal
<Modal
isOpen={isOpen}
onClose={onClose}
title="Vyplnit docházku za měsíc"
size="lg"
maxWidth="lg"
loading={submitting}
onSubmit={onSubmit}
submitLabel="Vyplnit měsíc"
submitText="Vyplnit měsíc"
submitDisabled={form.user_ids.length === 0}
>
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
marginBottom: "1rem",
fontSize: "0.875rem",
}}
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.25, mb: 2 }}
>
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
Existující záznamy se přeskočí.
</p>
</Typography>
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Měsíc</label>
<AdminDatePicker
mode="month"
<Field label="Měsíc">
<MonthField
value={form.month}
onChange={(val) => setForm({ ...form, month: val })}
/>
</div>
</Field>
<div className="admin-form-group">
<label className="admin-form-label">
<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
<button
</Typography>
<Typography
component="button"
type="button"
onClick={toggleAllUsers}
style={{
marginLeft: "0.75rem",
variant="caption"
sx={{
background: "none",
border: "none",
color: "var(--accent-color)",
p: 0,
cursor: "pointer",
fontSize: "0.8125rem",
fontWeight: 500,
padding: 0,
color: "primary.main",
}}
>
{form.user_ids.length === users.length
? "Odznačit vše"
: "Vybrat vše"}
</button>
</label>
<div
style={{
</Typography>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "0.375rem",
maxHeight: "200px",
gap: 0.25,
maxHeight: 200,
overflowY: "auto",
padding: "0.75rem",
background: "var(--bg-tertiary)",
borderRadius: "var(--border-radius-sm)",
border: "1px solid var(--border-color)",
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
border: 1,
borderColor: "divider",
}}
>
{users.map((user) => (
<label key={user.id} className="admin-form-checkbox">
<input
type="checkbox"
<CheckboxField
key={user.id}
label={user.name}
checked={form.user_ids.includes(String(user.id))}
onChange={() => toggleUser(user.id)}
/>
<span>{user.name}</span>
</label>
))}
</div>
<small className="admin-form-hint">
</Box>
<Typography variant="caption" color="text.secondary">
Vybráno: {form.user_ids.length} z {users.length}
</small>
</div>
</Typography>
</Box>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Příchod</label>
<AdminDatePicker
mode="time"
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Příchod">
<TimeField
value={form.arrival_time}
onChange={(val) => setForm({ ...form, arrival_time: val })}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Odchod</label>
<AdminDatePicker
mode="time"
</Field>
<Field label="Odchod">
<TimeField
value={form.departure_time}
onChange={(val) => setForm({ ...form, departure_time: val })}
/>
</div>
</div>
</Field>
</Box>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Začátek pauzy</label>
<AdminDatePicker
mode="time"
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Začátek pauzy">
<TimeField
value={form.break_start_time}
onChange={(val) => setForm({ ...form, break_start_time: val })}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Konec pauzy</label>
<AdminDatePicker
mode="time"
</Field>
<Field label="Konec pauzy">
<TimeField
value={form.break_end_time}
onChange={(val) => setForm({ ...form, break_end_time: val })}
/>
</div>
</div>
</div>
</FormModal>
</Field>
</Box>
</Modal>
);
}

View File

@@ -1,171 +0,0 @@
import { useEffect, type ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion";
import useReducedMotion from "../hooks/useReducedMotion";
import useModalLock from "../hooks/useModalLock";
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: ReactNode;
confirmText?: string;
cancelText?: string;
type?: "danger" | "warning" | "default" | "info";
confirmVariant?: "danger" | "primary";
loading?: boolean;
}
function ConfirmIcon({ type }: { type: string }) {
switch (type) {
case "danger":
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
);
case "info":
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);
case "warning":
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
default:
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);
}
}
export default function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "Potvrdit",
cancelText = "Zrušit",
type = "default",
confirmVariant,
loading,
}: ConfirmModalProps) {
const reducedMotion = useReducedMotion();
// Lock body scroll while open (ref-counted, nesting-safe) — matches FormModal.
useModalLock(isOpen);
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && !loading) {
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, loading, onClose]);
const duration = reducedMotion ? 0 : 0.22;
const ease = [0.16, 1, 0.3, 1] as const;
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.18 }}
>
<div
className="admin-modal-backdrop"
onClick={() => !loading && onClose()}
/>
<motion.div
className="admin-modal admin-confirm-modal"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
initial={{ opacity: 0, scale: 0.96, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration, ease }}
>
<div className="admin-modal-body admin-confirm-content">
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
<ConfirmIcon type={type} />
</div>
<h2 id="confirm-modal-title" className="admin-confirm-title">
{title}
</h2>
<p className="admin-confirm-message">{message}</p>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
{cancelText}
</button>
<button
type="button"
onClick={onConfirm}
className="admin-btn admin-btn-primary"
disabled={loading}
>
{loading ? "Zpracování..." : confirmText}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,4 +1,7 @@
import { Component, type ReactNode, type ErrorInfo } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Button } from "../ui";
interface Props {
children: ReactNode;
@@ -22,19 +25,25 @@ export default class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
return (
<div
className="admin-empty-state"
style={{ minHeight: "60vh", justifyContent: "center" }}
>
<h2>Něco se pokazilo</h2>
<p>{this.state.error?.message}</p>
<button
className="admin-btn admin-btn-primary"
onClick={() => window.location.reload()}
<Box
sx={{
minHeight: "60vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 2,
textAlign: "center",
}}
>
<Typography variant="h5">Něco se pokazilo</Typography>
<Typography variant="body2" color="text.secondary">
{this.state.error?.message}
</Typography>
<Button onClick={() => window.location.reload()}>
Obnovit stránku
</button>
</div>
</Button>
</Box>
);
}
return this.props.children;

View File

@@ -1,16 +1,26 @@
import { Link } from "react-router-dom";
import Box from "@mui/material/Box";
import { EmptyState, Button } from "../ui";
export default function Forbidden() {
return (
<div
className="admin-empty-state"
style={{ minHeight: "60vh", justifyContent: "center" }}
<Box
sx={{
minHeight: "60vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<h2>403</h2>
<p>Nemáte oprávnění pro přístup k této stránce.</p>
<Link to="/" className="admin-btn admin-btn-primary">
<EmptyState
title="403"
description="Nemáte oprávnění pro přístup k této stránce."
action={
<Button component={Link} to="/">
Zpět na Dashboard
</Link>
</div>
</Button>
}
/>
</Box>
);
}

View File

@@ -1,43 +0,0 @@
import {
type CSSProperties,
type ReactNode,
isValidElement,
cloneElement,
useId,
} from "react";
interface FormFieldProps {
label: ReactNode;
children: ReactNode;
error?: string;
required?: boolean;
style?: React.CSSProperties;
}
export default function FormField({
label,
children,
error,
required,
style,
}: FormFieldProps) {
const generatedId = useId();
const childProps = isValidElement(children)
? (children.props as Record<string, unknown>)
: null;
const childId = childProps?.id ? String(childProps.id) : generatedId;
const childWithId = isValidElement(children)
? cloneElement(children, { id: childId } as React.Attributes)
: children;
return (
<div className="admin-form-group" style={style}>
<label className="admin-form-label" htmlFor={childId}>
{label}
{required && <span className="admin-form-required"> *</span>}
</label>
{childWithId}
{error && <span className="admin-form-error">{error}</span>}
</div>
);
}

View File

@@ -1,201 +0,0 @@
import {
useEffect,
type ReactNode,
type FormEvent,
Children,
isValidElement,
cloneElement,
} from "react";
import { motion, AnimatePresence } from "framer-motion";
import useModalLock from "../hooks/useModalLock";
import useReducedMotion from "../hooks/useReducedMotion";
export interface FormModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit?: () => void; // called when user clicks primary button (only if provided)
title: string;
/** Optional muted line shown under the title in the header. */
subtitle?: ReactNode;
children: ReactNode; // modal body (form fields)
submitLabel?: string; // primary button text
cancelLabel?: string; // cancel button text
loading?: boolean;
/** Disable the primary (submit) button — e.g. until the form is valid.
* Keeps the button visibly greyed out instead of clickable-but-inert. */
submitDisabled?: boolean;
hideFooter?: boolean; // for "view-only" modals
size?: "sm" | "md" | "lg"; // optional
closeOnBackdrop?: boolean; // default true
closeOnEsc?: boolean; // default true
/** Slot for footer-left actions (e.g. delete). Rendered to the LEFT of
* the standard cancel/submit group in the modal footer. */
footerLeft?: ReactNode;
/** Hide the close × in the header. Default false. */
hideCloseButton?: boolean;
}
export default function FormModal({
isOpen,
onClose,
onSubmit,
title,
subtitle,
children,
submitLabel = "Uložit",
cancelLabel = "Zrušit",
loading = false,
submitDisabled = false,
hideFooter = false,
size = "md",
closeOnBackdrop = true,
closeOnEsc = true,
footerLeft,
hideCloseButton = false,
}: FormModalProps) {
useModalLock(isOpen);
const reducedMotion = useReducedMotion();
useEffect(() => {
if (!isOpen || !closeOnEsc) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && !loading) {
onClose();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, closeOnEsc, loading, onClose]);
const handleBackdropClick = () => {
if (closeOnBackdrop && !loading) onClose();
};
const handlePrimaryClick = (e: FormEvent) => {
e.preventDefault();
if (onSubmit) onSubmit();
};
const titleId = "form-modal-title";
const modalClassName =
size === "lg" ? "admin-modal admin-modal-lg" : "admin-modal";
// Snappier ease curve for modal entry — matches the "industrial/refined"
// tone. Roughly equivalent to the cubic-bezier(0.16, 1, 0.3, 1) used by
// a lot of design systems for "expo out" feel.
const ease = [0.16, 1, 0.3, 1] as const;
const duration = reducedMotion ? 0 : 0.22;
// Stagger the body children slightly for a "drawing-in" reveal. We cap
// the cascade so long forms don't drag on forever.
const childrenArray = Children.toArray(children).filter(isValidElement);
const stagger = reducedMotion ? 0 : Math.min(childrenArray.length, 5) * 0.03;
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.18 }}
>
<div className="admin-modal-backdrop" onClick={handleBackdropClick} />
<motion.div
className={modalClassName}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
initial={{ opacity: 0, scale: 0.96, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 8 }}
transition={{ duration, ease }}
>
<div className="admin-modal-header">
<div className="admin-modal-heading">
<h2 id={titleId} className="admin-modal-title">
{title}
</h2>
{subtitle && <p className="admin-modal-subtitle">{subtitle}</p>}
</div>
{!hideCloseButton && (
<button
type="button"
className="admin-modal-close"
onClick={() => !loading && onClose()}
aria-label="Zavřít"
title="Zavřít (Esc)"
disabled={loading}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
<div className="admin-modal-body">
{reducedMotion || stagger === 0
? children
: Children.map(children, (child, i) => {
if (!isValidElement(child)) return child;
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.2,
delay: 0.04 + i * 0.03,
ease: "easeOut",
}}
>
{cloneElement(child)}
</motion.div>
);
})}
</div>
{!hideFooter && (
<div className="admin-modal-footer">
<div className="admin-modal-footer-left">{footerLeft}</div>
<div className="admin-modal-footer-right">
<button
type="button"
onClick={() => !loading && onClose()}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
{cancelLabel}
</button>
{onSubmit && (
<button
type="button"
onClick={handlePrimaryClick}
className="admin-btn admin-btn-primary"
disabled={loading || submitDisabled}
>
{loading ? "Zpracování..." : submitLabel}
</button>
)}
</div>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,5 +1,10 @@
import { useState, useCallback } from "react";
import FormModal from "./FormModal";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import { Modal, Button, TextField, Field } from "../ui";
import { useAlert } from "../context/AlertContext";
interface ConfirmationItem {
@@ -35,6 +40,8 @@ export default function OrderConfirmationModal({
applyVat,
}: OrderConfirmationModalProps) {
const alert = useAlert();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [step, setStep] = useState<"choose" | "edit">("choose");
const [lang, setLang] = useState<string>("cs");
const [applyVatState, setApplyVatState] = useState(applyVat);
@@ -103,119 +110,205 @@ export default function OrderConfirmationModal({
}, [defaultVatRate]);
return (
<FormModal
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Potvrzení objednávky ${orderNumber}`}
size={step === "edit" ? "lg" : "md"}
maxWidth={step === "edit" ? "lg" : "md"}
loading={loading}
hideFooter
submitText={
step === "choose" ? "Použít položky z objednávky" : "Vygenerovat PDF"
}
submitDisabled={step === "edit" && items.length === 0}
onSubmit={step === "choose" ? handleUseExisting : handleEditGenerate}
>
{step === "choose" ? (
<div className="admin-form">
<div className="admin-form-group">
<label className="admin-form-label">Jazyk dokumentu</label>
<div className="flex-row gap-2">
<button
type="button"
<Box>
<Field label="Jazyk dokumentu">
<Box sx={{ display: "flex", gap: 1 }}>
<Button
size="small"
variant={lang === "cs" ? "contained" : "outlined"}
color={lang === "cs" ? "primary" : "inherit"}
onClick={() => setLang("cs")}
className={
lang === "cs"
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
Čeština
</button>
<button
type="button"
</Button>
<Button
size="small"
variant={lang === "en" ? "contained" : "outlined"}
color={lang === "en" ? "primary" : "inherit"}
onClick={() => setLang("en")}
className={
lang === "en"
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
English
</button>
</div>
</div>
</Button>
</Box>
</Field>
<div className="admin-form-group">
<label className="admin-form-label">DPH</label>
<div className="flex-row gap-2">
<button
type="button"
<Field label="DPH">
<Box sx={{ display: "flex", gap: 1 }}>
<Button
size="small"
variant={applyVatState ? "contained" : "outlined"}
color={applyVatState ? "primary" : "inherit"}
onClick={() => setApplyVatState(true)}
className={
applyVatState
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
S DPH
</button>
<button
type="button"
</Button>
<Button
size="small"
variant={!applyVatState ? "contained" : "outlined"}
color={!applyVatState ? "primary" : "inherit"}
onClick={() => setApplyVatState(false)}
className={
!applyVatState
? "admin-btn admin-btn-primary admin-btn-sm"
: "admin-btn admin-btn-secondary admin-btn-sm"
}
>
Bez DPH
</button>
</div>
</div>
</Button>
</Box>
</Field>
<div className="admin-form-group">
<label className="admin-form-label">Obsah potvrzení</label>
<p className="text-secondary" style={{ marginBottom: "0.75rem" }}>
<Field label="Obsah potvrzení">
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Jak chcete připravit potvrzení objednávky?
</p>
<button
onClick={handleUseExisting}
disabled={loading}
className="admin-btn admin-btn-primary w-full"
style={{ marginBottom: "0.5rem" }}
>
{loading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Generuji...
</>
) : (
"Použít položky z objednávky"
)}
</button>
<button
</Typography>
<Button
fullWidth
onClick={() => {
setItems(initialItems.length > 0 ? initialItems : []);
setStep("edit");
}}
disabled={loading}
className="admin-btn admin-btn-secondary w-full"
variant="outlined"
color="inherit"
>
Upravit položky
</button>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
</Button>
</Field>
</Box>
) : (
<Box>
<Box sx={{ mb: 1.5 }}>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={() => setStep("choose")}
disabled={loading}
>
Zrušit
</button>
</div>
</div>
Zt
</Button>
</Box>
{isMobile ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, i) => (
<Box
key={i}
sx={{
border: 1,
borderColor: "divider",
borderRadius: 2,
p: 1.5,
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Typography
variant="caption"
sx={{ color: "text.secondary", fontWeight: 700 }}
>
Položka {i + 1}
</Typography>
<Box sx={{ flex: 1 }} />
<IconButton
size="small"
color="error"
onClick={() => removeItem(i)}
title="Odstranit"
aria-label="Odstranit"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</IconButton>
</Box>
<TextField
label="Popis"
value={item.description}
onChange={(e) =>
updateItem(i, "description", e.target.value)
}
/>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: 1,
}}
>
<TextField
label="Množství"
type="number"
value={item.quantity}
onChange={(e) =>
updateItem(i, "quantity", Number(e.target.value) || 0)
}
slotProps={{ htmlInput: { step: "0.001" } }}
/>
<TextField
label="Jednotka"
value={item.unit}
onChange={(e) => updateItem(i, "unit", e.target.value)}
/>
<TextField
label="Cena"
type="number"
value={item.unit_price}
onChange={(e) =>
updateItem(i, "unit_price", Number(e.target.value) || 0)
}
slotProps={{ htmlInput: { step: "0.01" } }}
/>
<TextField
label="%DPH"
type="number"
value={item.vat_rate}
onChange={(e) =>
updateItem(i, "vat_rate", Number(e.target.value) || 0)
}
slotProps={{ htmlInput: { step: "1" } }}
/>
</Box>
</Box>
))}
</Box>
) : (
<div className="admin-form">
<div className="admin-table-responsive">
<table className="admin-table">
<Box sx={{ overflowX: "auto" }}>
<Box
component="table"
sx={{
width: "100%",
borderCollapse: "collapse",
"& th": {
textAlign: "left",
fontSize: ".7rem",
textTransform: "uppercase",
letterSpacing: ".05em",
fontWeight: 700,
color: "text.secondary",
pb: 1,
},
"& td": { py: 0.5, pr: 1, verticalAlign: "middle" },
}}
>
<thead>
<tr>
<th>Popis</th>
@@ -230,39 +323,40 @@ export default function OrderConfirmationModal({
{items.map((item, i) => (
<tr key={i}>
<td>
<input
type="text"
<TextField
value={item.description}
onChange={(e) =>
updateItem(i, "description", e.target.value)
}
className="admin-form-input"
style={{ minWidth: "200px" }}
sx={{ minWidth: 200 }}
/>
</td>
<td>
<input
<TextField
type="number"
value={item.quantity}
onChange={(e) =>
updateItem(i, "quantity", Number(e.target.value) || 0)
updateItem(
i,
"quantity",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "80px" }}
step="0.001"
sx={{ width: 90 }}
slotProps={{ htmlInput: { step: "0.001" } }}
/>
</td>
<td>
<input
type="text"
<TextField
value={item.unit}
onChange={(e) => updateItem(i, "unit", e.target.value)}
className="admin-form-input"
style={{ width: "60px" }}
onChange={(e) =>
updateItem(i, "unit", e.target.value)
}
sx={{ width: 70 }}
/>
</td>
<td>
<input
<TextField
type="number"
value={item.unit_price}
onChange={(e) =>
@@ -272,28 +366,32 @@ export default function OrderConfirmationModal({
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "100px" }}
step="0.01"
sx={{ width: 110 }}
slotProps={{ htmlInput: { step: "0.01" } }}
/>
</td>
<td>
<input
<TextField
type="number"
value={item.vat_rate}
onChange={(e) =>
updateItem(i, "vat_rate", Number(e.target.value) || 0)
updateItem(
i,
"vat_rate",
Number(e.target.value) || 0,
)
}
className="admin-form-input"
style={{ width: "70px" }}
step="1"
sx={{ width: 80 }}
slotProps={{ htmlInput: { step: "1" } }}
/>
</td>
<td>
<button
<IconButton
size="small"
color="error"
onClick={() => removeItem(i)}
className="admin-btn-icon danger"
title="Odstranit"
aria-label="Odstranit"
>
<svg
width="16"
@@ -306,47 +404,26 @@ export default function OrderConfirmationModal({
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</IconButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
</Box>
</Box>
)}
<Box sx={{ mt: 1.5 }}>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={addItem}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat položku
</button>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setStep("choose")}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zpět
</button>
<button
type="button"
onClick={handleEditGenerate}
className="admin-btn admin-btn-primary"
disabled={loading || items.length === 0}
>
{loading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Generuji...
</>
) : (
"Vygenerovat PDF"
</Button>
</Box>
</Box>
)}
</button>
</div>
</div>
)}
</FormModal>
</Modal>
);
}

View File

@@ -1,113 +0,0 @@
interface PaginationProps {
pagination: {
total: number;
page: number;
per_page: number;
total_pages: number;
} | null;
onPageChange: (page: number) => void;
onPerPageChange?: (perPage: number) => void;
}
export default function Pagination({
pagination,
onPageChange,
onPerPageChange,
}: PaginationProps) {
if (!pagination || pagination.total_pages <= 1) return null;
const { page, total_pages, total } = pagination;
const getPages = () => {
const pages: (number | string)[] = [];
const delta = 2;
for (let i = 1; i <= total_pages; i++) {
if (
i === 1 ||
i === total_pages ||
(i >= page - delta && i <= page + delta)
) {
pages.push(i);
} else if (pages[pages.length - 1] !== "...") {
pages.push("...");
}
}
return pages;
};
return (
<div
className="admin-pagination"
role="navigation"
aria-label="Stránkování"
>
<div className="admin-pagination-info">{total} záznamů</div>
<div className="admin-pagination-controls">
<button
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
className="admin-pagination-page"
aria-label="Předchozí stránka"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
{getPages().map((p, i) =>
typeof p === "string" ? (
<span key={`dots-${i}`} className="admin-pagination-ellipsis">
...
</span>
) : (
<button
key={p}
onClick={() => onPageChange(p)}
className={`admin-pagination-page ${p === page ? "active" : ""}`}
aria-label={`Stránka ${p}`}
aria-current={p === page ? "page" : undefined}
>
{p}
</button>
),
)}
<button
disabled={page >= total_pages}
onClick={() => onPageChange(page + 1)}
className="admin-pagination-page"
aria-label="Další stránka"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</div>
{onPerPageChange && (
<select
value={pagination.per_page}
onChange={(e) => onPerPageChange(Number(e.target.value))}
className="admin-pagination-select"
>
{[10, 25, 50, 100].map((n) => (
<option key={n} value={n}>
{n} / stránka
</option>
))}
</select>
)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import FormModal from "./FormModal";
import { useState, type ChangeEvent, type FocusEvent } from "react";
import Box from "@mui/material/Box";
import { Modal, TextField, Button } from "../ui";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { PlanCategory } from "../lib/queries/plan";
@@ -91,23 +92,44 @@ export default function PlanCategoriesModal({
const busy =
createCat.isPending || updateCat.isPending || deleteCat.isPending;
// Shared layout for a category row: color swatch | text input | actions.
const rowSx = {
display: "flex",
alignItems: "center",
gap: 1,
py: 1,
} as const;
// Native color picker, styled to match the row (was the .plan-cat-color rule).
const colorInputSx = {
width: 34,
height: 34,
p: 0,
border: 1,
borderColor: "divider",
borderRadius: "6px",
background: "none",
cursor: "pointer",
flex: "0 0 auto",
} as const;
return (
<FormModal
<Modal
isOpen={isOpen}
onClose={onClose}
onSubmit={onClose}
title="Správa kategorií"
hideFooter
size="md"
submitText="Zavřít"
hideCancel
maxWidth="md"
>
<div className="plan-cat-manager">
<Box>
{categories.map((c) => (
<div
key={c.id}
className={`plan-cat-row${c.is_active ? "" : " plan-cat-row--inactive"}`}
>
<input
<Box key={c.id} sx={{ ...rowSx, opacity: c.is_active ? 1 : 0.55 }}>
<Box
component="input"
type="color"
className="plan-cat-color"
sx={colorInputSx}
// Uncontrolled + patch on blur (like the rename input): the
// native color picker fires onChange on every drag tick, which
// would spam PATCH requests and the audit log. onBlur commits
@@ -115,96 +137,102 @@ export default function PlanCategoriesModal({
defaultValue={c.color}
key={c.color}
disabled={busy}
onBlur={(e) => {
onBlur={(e: FocusEvent<HTMLInputElement>) => {
if (e.target.value !== c.color) {
patch(c.id, { color: e.target.value });
}
}}
aria-label={`Barva ${c.label}`}
/>
<input
type="text"
className="admin-form-input"
<TextField
defaultValue={c.label}
disabled={busy}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== c.label) patch(c.id, { label: v });
}}
aria-label={`Název ${c.label}`}
inputProps={{ "aria-label": `Název ${c.label}` }}
/>
{confirmDeleteId === c.id ? (
<>
<button
type="button"
className="admin-btn admin-btn-danger admin-btn-sm"
<Button
variant="contained"
color="error"
size="small"
disabled={busy}
onClick={() => handleDelete(c.id)}
>
Opravdu smazat
</button>
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
</Button>
<Button
variant="outlined"
color="inherit"
size="small"
disabled={busy}
onClick={() => setConfirmDeleteId(null)}
>
Zrušit
</button>
</Button>
</>
) : (
<>
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
<Button
variant="outlined"
color="inherit"
size="small"
disabled={busy}
onClick={() => patch(c.id, { is_active: !c.is_active })}
>
{c.is_active ? "Skrýt" : "Obnovit"}
</button>
<button
type="button"
className="admin-btn admin-btn-danger admin-btn-sm plan-cat-delete"
</Button>
<Button
variant="contained"
color="error"
size="small"
disabled={busy}
onClick={() => setConfirmDeleteId(c.id)}
aria-label={`Smazat ${c.label}`}
title="Smazat kategorii"
>
Smazat
</button>
</Button>
</>
)}
</div>
</Box>
))}
<div className="plan-cat-row plan-cat-row--new">
<input
<Box
sx={{ ...rowSx, mt: 1, pt: 2, borderTop: 1, borderColor: "divider" }}
>
<Box
component="input"
type="color"
className="plan-cat-color"
sx={colorInputSx}
value={newColor}
disabled={busy}
onChange={(e) => setNewColor(e.target.value)}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setNewColor(e.target.value)
}
aria-label="Barva nové kategorie"
/>
<input
type="text"
className="admin-form-input"
<TextField
placeholder="Nová kategorie…"
value={newLabel}
disabled={busy}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
<button
type="button"
className="admin-btn admin-btn-primary admin-btn-sm"
<Button
variant="contained"
color="primary"
size="small"
disabled={busy}
onClick={handleAdd}
>
Přidat
</button>
</div>
</div>
</FormModal>
</Button>
</Box>
</Box>
</Modal>
);
}

View File

@@ -1,9 +1,18 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import FormModal from "./FormModal";
import FormField from "./FormField";
import AdminDatePicker from "./AdminDatePicker";
import ConfirmModal from "./ConfirmModal";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import {
Modal,
ConfirmDialog,
Field,
TextField,
Select,
DateField,
CheckboxField,
Button,
Card,
} from "../ui";
import useReducedMotion from "../hooks/useReducedMotion";
import {
ResolvedCell,
@@ -77,6 +86,26 @@ interface Props {
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
}
const TrashIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
);
export default function PlanCellModal(props: Props) {
const { open, mode, onClose } = props;
if (!open || mode.kind === "closed") return null;
@@ -213,128 +242,111 @@ function EditForm(props: Props) {
}
};
// If the row's current category was retired/hidden, still show it (marked)
// so the select reflects reality instead of silently snapping to the first
// active option. The server only re-checks the category if it changes, so
// keeping it saves fine.
const showRetiredCategory =
category && !activeCategories.some((c) => c.key === category);
return (
<>
<FormModal
<Modal
isOpen
title={title}
onClose={onClose}
onSubmit={handleSubmit}
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
submitText={mode.kind === "create" ? "Vytvořit" : "Uložit"}
loading={submitting}
footerLeft={
mode.kind !== "create" ? (
<button
type="button"
className="admin-btn admin-btn-secondary"
>
{mode.kind !== "create" && (
<Box sx={{ mb: 2 }}>
<Button
variant="outlined"
color="error"
startIcon={TrashIcon}
onClick={() => setConfirmDelete(true)}
disabled={submitting}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
Smazat
</button>
) : null
}
>
<FormField label="Datum od">
<AdminDatePicker
</Button>
</Box>
)}
<Field label="Datum od">
<DateField
value={dateFrom}
onChange={setDateFrom}
disabled={isOverride}
/>
</FormField>
</Field>
{!isOverride && (
<FormField label="Rozsah dnů">
<label className="admin-form-checkbox">
<input
type="checkbox"
<Field label="Rozsah dnů">
<CheckboxField
label="Více dní"
checked={isRange}
onChange={(e) => setIsRange(e.target.checked)}
onChange={setIsRange}
/>
<span>Více dní</span>
</label>
</FormField>
</Field>
)}
{!isOverride && isRange && (
<FormField label="Datum do">
<AdminDatePicker value={dateTo} onChange={setDateTo} />
</FormField>
<Field label="Datum do">
<DateField value={dateTo} onChange={setDateTo} />
</Field>
)}
<FormField label="Projekt">
<select
className="admin-form-select"
value={projectId ?? ""}
onChange={(e) =>
setProjectId(e.target.value ? Number(e.target.value) : null)
}
>
<option value=""> bez projektu </option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</FormField>
<FormField label="Kategorie">
<select
className="admin-form-select"
<Field label="Projekt">
<Select
value={projectId === null ? "" : String(projectId)}
onChange={(val) => setProjectId(val ? Number(val) : null)}
options={[
{ value: "", label: "— bez projektu —" },
...projects.map((p) => ({
value: String(p.id),
label: p.name,
})),
]}
/>
</Field>
<Field label="Kategorie">
<Select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
{/* If the row's current category was retired/hidden, still show it
(marked) so the select reflects reality instead of silently
snapping to the first active option. The server only re-checks
the category if it changes, so keeping it saves fine. */}
{category && !activeCategories.some((c) => c.key === category) && (
<option value={category}>
{planCategoryLabel(
onChange={(val) => setCategory(val)}
options={[
...(showRetiredCategory
? [
{
value: category,
label: `${planCategoryLabel(
category,
Object.fromEntries(props.categories.map((x) => [x.key, x])),
)}{" "}
(skrytá)
</option>
)}
{activeCategories.map((c) => (
<option key={c.key} value={c.key}>
{c.label}
</option>
))}
</select>
</FormField>
<FormField label="Text poznámky (volitelný)">
<textarea
className="admin-form-textarea"
Object.fromEntries(
props.categories.map((x) => [x.key, x]),
),
)} (skrytá)`,
},
]
: []),
...activeCategories.map((c) => ({
value: c.key,
label: c.label,
})),
]}
/>
</Field>
<Field label="Text poznámky (volitelný)">
<TextField
multiline
minRows={3}
value={note}
onChange={(e) => setNote(e.target.value)}
maxLength={500}
rows={3}
inputProps={{ maxLength: 500 }}
/>
</FormField>
</FormModal>
</Field>
</Modal>
<ConfirmModal
<ConfirmDialog
isOpen={confirmDelete}
title="Smazat záznam plánu?"
message="Tato akce je nevratná (záznam bude soft-delete)."
confirmText="Smazat"
type="danger"
confirmVariant="danger"
loading={submitting}
onClose={() => setConfirmDelete(false)}
@@ -376,26 +388,26 @@ function DayInRangeModal(
];
return (
<FormModal
<Modal
isOpen
title="Den je součástí rozsahu"
onClose={onClose}
hideFooter
size="md"
onSubmit={onClose}
submitText="Zavřít — ponechat rozsah beze změny"
maxWidth="md"
>
<p className="plan-modal-intro">
<Typography variant="body2" sx={{ mb: 2 }}>
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
<strong>
{mode.range.date_from} {mode.range.date_to}
</strong>
. Vyberte, co chcete udělat:
</p>
<ul className="plan-choices">
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
<AnimatePresence>
{choices.map((c, i) => (
<motion.li
<motion.div
key={c.key}
className={`plan-choice plan-choice-${c.tone}`}
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
@@ -405,35 +417,42 @@ function DayInRangeModal(
ease: [0.16, 1, 0.3, 1],
}}
>
<div className="plan-choice-body">
<div className="plan-choice-title">{c.title}</div>
<div className="plan-choice-desc">{c.desc}</div>
</div>
<button
type="button"
className={
c.tone === "primary"
? "admin-btn admin-btn-primary"
: "admin-btn admin-btn-secondary"
}
<Card
variant="outlined"
sx={{
borderColor:
c.tone === "primary" ? "primary.main" : "divider",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
}}
>
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography sx={{ fontWeight: 600 }}>{c.title}</Typography>
<Typography variant="body2" color="text.secondary">
{c.desc}
</Typography>
</Box>
<Button
variant={c.tone === "primary" ? "contained" : "outlined"}
color={c.tone === "primary" ? "primary" : "inherit"}
onClick={c.onClick}
>
{c.cta}
</button>
</motion.li>
</Button>
</Box>
</Card>
</motion.div>
))}
</AnimatePresence>
</ul>
<div className="plan-modal-actions plan-modal-actions--solo">
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={onClose}
>
Zavřít ponechat rozsah beze změny
</button>
</div>
</FormModal>
</Box>
</Modal>
);
}
@@ -454,29 +473,36 @@ function ViewModal(props: Props) {
: fallback.name
: null);
return (
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
<p>
<Modal
isOpen
title="Detail plánu"
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít"
hideCancel
>
<Typography sx={{ mb: 1 }}>
<strong>Datum:</strong> {formatDate(c.shift_date)}
</p>
<p>
</Typography>
<Typography sx={{ mb: 1 }}>
<strong>Kategorie:</strong>{" "}
{planCategoryLabel(
c.category,
Object.fromEntries(categories.map((x) => [x.key, x])),
)}
</p>
<p>
</Typography>
<Typography sx={{ mb: 1 }}>
<strong>Projekt:</strong> {projectLabel || "—"}
</p>
<p>
</Typography>
<Typography sx={{ mb: 1 }}>
<strong>Text:</strong> {c.note || "—"}
</p>
</Typography>
{c.rangeFrom && c.rangeTo && (
<p>
<Typography sx={{ mb: 1 }}>
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} {" "}
{formatDate(c.rangeTo)}
</p>
</Typography>
)}
</FormModal>
</Modal>
);
}

View File

@@ -1,5 +1,7 @@
import { useMemo } from "react";
import type { CSSProperties } from "react";
import { styled } from "@mui/material/styles";
import Box from "@mui/material/Box";
import {
GridData,
ResolvedCell,
@@ -9,6 +11,357 @@ import {
} from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects";
import PlanRangeChips from "./PlanRangeChips";
import { LoadingState } from "../ui";
import { fonts } from "../theme";
/**
* The planner surface, fully MUI/theme-driven (replaces the bespoke plan.css
* grid styling). All colors come from the theme palette via `theme.vars`, so it
* adapts to light/dark automatically; category colors come from the DB and are
* injected per cell as the `--cat-color` CSS variable. Class names from the JSX
* below are styled here as scoped nested selectors.
*/
const PlanGridRoot = styled(Box)(({ theme }) => ({
position: "relative",
overflow: "auto",
WebkitOverflowScrolling: "touch",
overscrollBehavior: "contain",
background: theme.vars!.palette.background.default,
border: `1px solid ${theme.vars!.palette.divider}`,
borderRadius: 14,
boxShadow: theme.shadows[2],
maxHeight: "calc(100dvh - 240px)",
isolation: "isolate",
"& .plan-grid": {
position: "relative",
zIndex: 1,
borderCollapse: "separate",
borderSpacing: 0,
width: "100%",
fontSize: "12.5px",
color: theme.vars!.palette.text.primary,
fontFamily: fonts.body,
},
"& col.plan-grid-date-col": { width: 88 },
"& tbody tr td + td": {
borderLeft: `1px solid ${theme.vars!.palette.divider}`,
},
// Sticky header
"& thead th": {
position: "sticky",
top: 0,
zIndex: 3,
padding: "14px 12px 12px",
background: theme.vars!.palette.background.paper,
color: theme.vars!.palette.text.primary,
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
textAlign: "left",
fontWeight: 600,
whiteSpace: "nowrap",
},
"& thead th.plan-grid-date-col": {
left: 0,
zIndex: 4,
minWidth: 88,
padding: "12px 10px 10px 14px",
fontSize: "10.5px",
fontWeight: 700,
letterSpacing: "0.18em",
textTransform: "uppercase",
color: theme.vars!.palette.text.secondary,
},
"& .plan-person-head": {
display: "inline-flex",
alignItems: "center",
gap: 9,
minWidth: 0,
},
"& .plan-person-dot": {
flexShrink: 0,
width: 9,
height: 9,
borderRadius: "50%",
background: theme.vars!.palette.action.disabled,
},
"& .plan-person-name": {
display: "inline-flex",
flexDirection: "column",
minWidth: 0,
lineHeight: 1.1,
},
"& .plan-person-name strong": {
fontWeight: 600,
fontSize: 13,
color: theme.vars!.palette.text.primary,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: 180,
},
"& .plan-person-name small": {
fontSize: 10,
fontWeight: 500,
color: theme.vars!.palette.text.disabled,
letterSpacing: "0.04em",
textTransform: "uppercase",
fontFamily: fonts.mono,
marginTop: 1,
},
// Sticky date column
"& tbody td.plan-grid-date-col": {
position: "sticky",
left: 0,
zIndex: 1,
background: theme.vars!.palette.background.paper,
fontWeight: 500,
color: theme.vars!.palette.text.secondary,
whiteSpace: "nowrap",
minWidth: 88,
padding: "9px 10px 9px 14px",
borderRight: `1px solid ${theme.vars!.palette.divider}`,
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
},
"& .plan-date-stamp": {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
lineHeight: 1.1,
gap: 1,
},
"& .plan-date-daynum": {
fontFamily: fonts.mono,
fontSize: 17,
fontWeight: 600,
color: theme.vars!.palette.text.primary,
fontVariantNumeric: "tabular-nums",
lineHeight: 1.05,
},
"& .plan-date-dow": {
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.14em",
textTransform: "uppercase",
color: theme.vars!.palette.text.disabled,
lineHeight: 1.3,
},
// Today
"& tbody tr.is-today td.plan-grid-date-col": {
background: theme.vars!.palette.background.paper,
"&::before": {
content: '""',
position: "absolute",
left: 0,
top: 6,
bottom: 6,
width: 3,
borderRadius: "0 2px 2px 0",
background: theme.vars!.palette.primary.main,
boxShadow: `0 0 14px rgba(${theme.vars!.palette.primary.mainChannel} / 0.3)`,
},
"& .plan-date-daynum": { color: theme.vars!.palette.primary.main },
},
"& tbody tr.is-today td:not(.plan-grid-date-col)": {
background: `rgba(${theme.vars!.palette.primary.mainChannel} / 0.06)`,
},
// Body cells
"& tbody td": {
padding: 0,
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
verticalAlign: "stretch",
minWidth: 168,
background: theme.vars!.palette.background.paper,
},
// Weekend
"& tr.plan-grid-weekend td": {
background: `rgba(${theme.vars!.palette.text.primaryChannel} / 0.03)`,
},
"& tr.plan-grid-weekend td.plan-grid-date-col": {
background: theme.vars!.palette.background.paper,
color: theme.vars!.palette.text.disabled,
"& .plan-date-dow": { color: theme.vars!.palette.warning.main },
"& .plan-date-daynum": { color: theme.vars!.palette.text.secondary },
},
// The cell button + marker tape
"& .plan-cell": {
position: "relative",
display: "block",
width: "100%",
height: "100%",
minHeight: 60,
textAlign: "left",
background: "transparent",
border: 0,
borderRadius: 0,
padding: "9px 11px 10px 16px",
cursor: "pointer",
font: "inherit",
color: "inherit",
transition: "background 150ms ease, transform 150ms ease",
"&::before": {
content: '""',
position: "absolute",
left: 4,
top: 8,
bottom: 8,
width: 3,
borderRadius: 2,
background: `var(--cat-color, ${theme.vars!.palette.divider})`,
transition:
"background 150ms ease, box-shadow 150ms ease, transform 150ms ease",
},
"&:hover": { background: theme.vars!.palette.action.hover },
"&:hover::before": {
transform: "scaleX(1.4)",
boxShadow: "0 0 12px currentColor",
},
"&:active": { transform: "translateY(0.5px)" },
"&:focus-visible": {
outline: "none",
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, inset 0 0 0 4px ${theme.vars!.palette.background.paper}`,
},
},
"& .plan-cell--readonly": {
cursor: "default",
"&:hover": { background: "transparent" },
"&:hover::before": { transform: "none", boxShadow: "none" },
},
// A read-only cell that HAS data is still clickable (view mode).
"& .plan-cell--readonly:not(.plan-cell--empty):not(.plan-cell--past)": {
cursor: "pointer",
"&:hover": { background: theme.vars!.palette.action.hover },
"&:hover::before": {
transform: "scaleX(1.4)",
boxShadow: "0 0 12px currentColor",
},
},
"& .plan-cell--past": {
cursor: "default",
opacity: 0.55,
"&:hover": { background: "transparent" },
"&:hover::before": { transform: "none", boxShadow: "none" },
},
// Empty cells: dashed tape + a "+" hover hint.
"& .plan-cell--empty::before": {
background: "transparent",
border: `1px dashed ${theme.vars!.palette.divider}`,
width: 2,
left: 5,
},
"& .plan-cell--empty:hover::before": {
background: theme.vars!.palette.primary.main,
borderColor: theme.vars!.palette.primary.main,
transform: "scaleX(1.6)",
},
"& .plan-cell--empty::after": {
content: '"+"',
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
color: theme.vars!.palette.text.disabled,
opacity: 0,
fontSize: 18,
fontWeight: 300,
pointerEvents: "none",
transition: "opacity 150ms ease",
},
"& .plan-cell--empty:hover::after": {
opacity: 0.7,
color: theme.vars!.palette.primary.main,
},
"& .plan-cell--empty.plan-cell--readonly::after": { content: "none" },
// One-shot mutation pulse
"@keyframes plan-cell-pulse": {
"0%": {
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, 0 0 0 0 rgba(${theme.vars!.palette.primary.mainChannel} / 0.4)`,
},
"60%": {
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, 0 0 0 6px transparent`,
},
"100%": { boxShadow: "inset 0 0 0 2px transparent, 0 0 0 0 transparent" },
},
"& .plan-cell--pulse": { animation: "plan-cell-pulse 600ms ease-out" },
// Chips inside a cell
"& .plan-chip-block": {
display: "flex",
flexDirection: "column",
gap: 4,
textAlign: "left",
width: "100%",
minWidth: 0,
},
"& .plan-chip-line": {
display: "flex",
alignItems: "center",
gap: 7,
flexWrap: "wrap",
minWidth: 0,
},
"& .plan-chip": {
display: "inline-flex",
alignItems: "center",
gap: 5,
borderRadius: 4,
padding: "2px 7px",
fontSize: "10.5px",
fontWeight: 600,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: `var(--cat-color, ${theme.vars!.palette.text.primary})`,
background: `color-mix(in srgb, var(--cat-color, transparent) 12%, ${theme.vars!.palette.background.paper})`,
border: `1px solid color-mix(in srgb, var(--cat-color, ${theme.vars!.palette.divider}) 35%, transparent)`,
whiteSpace: "nowrap",
},
"& .plan-chip-project": {
fontFamily: fonts.mono,
fontSize: "12.5px",
fontWeight: 600,
color: theme.vars!.palette.text.primary,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "100%",
},
"& .plan-cell-note": {
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
marginTop: 1,
fontSize: "11.5px",
color: theme.vars!.palette.text.secondary,
lineHeight: 1.35,
fontStyle: "italic",
},
"@media (max-width: 480px)": {
"& col.plan-grid-date-col": { width: 72 },
"& tbody td": { minWidth: 132 },
"& .plan-cell": { minHeight: 56 },
},
// Touch: show a faint persistent "+" on empty editable cells.
"@media (hover: none)": {
"& .plan-cell--empty:not(.plan-cell--readonly)::after": {
opacity: 0.26,
color: theme.vars!.palette.text.disabled,
},
},
"@media (prefers-reduced-motion: reduce)": {
"& .plan-cell, & .plan-cell::before, & .plan-cell::after": {
transition: "none",
},
"& .plan-cell--pulse": { animation: "none" },
},
}));
interface Props {
data: GridData | undefined;
@@ -107,12 +460,7 @@ export default function PlanGrid({
const today = useMemo(() => todayIso(), []);
const catMap = useMemo(() => categoryMap(categories), [categories]);
if (!data)
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
if (!data) return <LoadingState />;
const days = eachDay(data.date_from, data.date_to);
const users = data.users;
// pulseKey?.nonce is included in the data-pulse attribute so a second
@@ -124,7 +472,7 @@ export default function PlanGrid({
: undefined;
return (
<div className="plan-grid-wrap" data-pulse={pulseAttr}>
<PlanGridRoot data-pulse={pulseAttr}>
<table className="plan-grid">
{/* The colgroup is what actually controls column width in a
table — `min-width` on `<th>`/`<td>` is just a floor that
@@ -255,6 +603,6 @@ export default function PlanGrid({
})}
</tbody>
</table>
</div>
</PlanGridRoot>
);
}

View File

@@ -1,8 +1,21 @@
import { useState, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import { projectFilesOptions } from "../lib/queries/projects";
import { useAlert } from "../context/AlertContext";
import ConfirmModal from "./ConfirmModal";
import {
Card,
Button,
TextField,
ConfirmDialog,
EmptyState,
LoadingState,
DataTable,
type DataColumn,
} from "../ui";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
@@ -122,7 +135,7 @@ function getFileIcon(type: string, extension?: string) {
};
const cfg = iconMap[ext] || {
color: "var(--text-muted)",
color: "var(--mui-palette-text-secondary)",
path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z",
};
@@ -143,7 +156,16 @@ function getFileIcon(type: string, extension?: string) {
function SymlinkBadge({ target }: { target?: string }) {
return (
<span className="fm-symlink-badge" title={target || "Odkaz"}>
<Box
component="span"
title={target || "Odkaz"}
sx={{
display: "inline-flex",
alignItems: "center",
ml: 0.5,
color: "text.secondary",
}}
>
<svg
width="12"
height="12"
@@ -155,7 +177,7 @@ function SymlinkBadge({ target }: { target?: string }) {
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</span>
</Box>
);
}
@@ -168,26 +190,53 @@ function FileNameCell({
}) {
if (item.type === "folder") {
return (
<span className="fm-name-cell">
<button
<Box
component="span"
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<Box
component="button"
type="button"
className="fm-folder-link"
onClick={() => onFolderClick(item.name)}
sx={{
background: "none",
border: "none",
p: 0,
font: "inherit",
color: "primary.main",
cursor: "pointer",
textAlign: "left",
"&:hover": { textDecoration: "underline" },
}}
>
{item.name}
</button>
</Box>
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
{item.item_count !== undefined && (
<span className="fm-item-count">{item.item_count}</span>
<Box
component="span"
sx={{
fontSize: ".7rem",
color: "text.secondary",
bgcolor: "action.hover",
borderRadius: 1,
px: 0.75,
}}
>
{item.item_count}
</Box>
)}
</span>
</Box>
);
}
return (
<span className="fm-name-cell">
<span className="fm-file-name">{item.name}</span>
<Box
component="span"
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<Box component="span">{item.name}</Box>
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
</span>
</Box>
);
}
@@ -469,257 +518,58 @@ export default function ProjectFileManager({
};
if (filesLoading && items.length === 0 && !errorMessage) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
if (errorMessage) {
return (
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
<div className="fm-empty">
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Soubory
</Typography>
<EmptyState
icon={
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-tertiary)"
stroke="currentColor"
strokeWidth="1.5"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>{errorMessage}</span>
</div>
</div>
</div>
}
title={errorMessage}
/>
</Card>
);
}
return (
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
{/* Toolbar */}
<div className="fm-toolbar">
<div className="fm-breadcrumb">
{breadcrumb.map((segment, i) => (
<span key={i} className="fm-breadcrumb-segment">
{i > 0 && <span className="fm-breadcrumb-sep">/</span>}
<button
type="button"
className={`fm-breadcrumb-btn ${i === breadcrumb.length - 1 ? "active" : ""}`}
onClick={() => handleBreadcrumbClick(i)}
>
{i === 0 ? projectNumber : segment}
</button>
</span>
))}
</div>
{fullPath && (
<span className="fm-full-path" title={fullPath}>
{fullPath}
</span>
)}
{canManage && (
<div className="fm-toolbar-actions">
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => {
setNewFolderMode(!newFolderMode);
setNewFolderName("");
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
Složka
</button>
<button
type="button"
className="admin-btn admin-btn-primary admin-btn-sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Nahrávání...
</>
) : (
<>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Nahrát
</>
)}
</button>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={handleFileInputChange}
/>
</div>
)}
</div>
{/* New folder input */}
{newFolderMode && (
<div className="fm-new-folder">
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
className="admin-form-input"
placeholder="Název složky..."
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateFolder();
if (e.key === "Escape") {
setNewFolderMode(false);
setNewFolderName("");
}
}}
style={{ fontSize: "12px", padding: "6px 10px" }}
/>
<button
type="button"
className="admin-btn admin-btn-primary admin-btn-sm"
onClick={handleCreateFolder}
disabled={creatingFolder || !newFolderName.trim()}
>
{creatingFolder ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
"Vytvořit"
)}
</button>
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => {
setNewFolderMode(false);
setNewFolderName("");
}}
>
Zrušit
</button>
</div>
)}
{/* Drop zone + table */}
<div
className={`fm-content ${dragOver ? "fm-drag-over" : ""}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{dragOver && (
<div className="fm-dropzone-overlay">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span>Přetáhněte soubory sem</span>
</div>
)}
{items.length === 0 && !filesLoading ? (
<div className="fm-empty">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="var(--text-tertiary)"
strokeWidth="1.5"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span>
{hasNasFolder
? "Složka je prázdná"
: "Složka projektu zatím neexistuje"}
</span>
{canManage && !hasNasFolder && (
<span style={{ fontSize: "11px" }}>
Nahrání souboru ji automaticky vytvoří
</span>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: "30px" }}></th>
<th>Název</th>
<th style={{ width: "90px" }}>Velikost</th>
<th style={{ width: "120px" }}>Změněno</th>
{canManage && (
<th style={{ width: "100px", textAlign: "right" }}>
Akce
</th>
)}
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.name}>
<td style={{ textAlign: "center" }}>
const columns: DataColumn<FileItem>[] = [
{
key: "icon",
header: "",
width: 30,
align: "left",
render: (item) => (
<Box sx={{ textAlign: "center" }}>
{getFileIcon(item.type, item.extension)}
</td>
<td>
{renamingItem === item.name ? (
<input
type="text"
</Box>
),
},
{
key: "name",
header: "Název",
render: (item) =>
renamingItem === item.name ? (
<TextField
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
className="admin-form-input"
style={{
fontSize: "11px",
padding: "3px 8px",
maxWidth: "300px",
}}
autoFocus
sx={{ maxWidth: 300 }}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -743,24 +593,49 @@ export default function ProjectFileManager({
}}
/>
) : (
<FileNameCell
item={item}
onFolderClick={handleFolderClick}
/>
)}
</td>
<td className="fm-meta">
{item.type === "file" ? item.size_formatted : "\u2014"}
</td>
<td className="fm-meta">{item.modified || "\u2014"}</td>
{canManage && (
<td style={{ textAlign: "right" }}>
<div className="fm-actions">
<FileNameCell item={item} onFolderClick={handleFolderClick} />
),
},
{
key: "size",
header: "Velikost",
width: 90,
render: (item) => (
<Typography variant="caption" color="text.secondary">
{item.type === "file" ? item.size_formatted : "—"}
</Typography>
),
},
{
key: "modified",
header: "Změněno",
width: 120,
render: (item) => (
<Typography variant="caption" color="text.secondary">
{item.modified || "—"}
</Typography>
),
},
...(canManage
? [
{
key: "actions",
header: "Akce",
width: 100,
align: "right" as const,
render: (item: FileItem) => (
<Box
sx={{
display: "flex",
gap: 0.5,
justifyContent: "flex-end",
}}
>
{item.type === "file" && (
<button
type="button"
className="admin-btn-icon"
<IconButton
size="small"
title="Stáhnout"
aria-label="Stáhnout"
onClick={() => handleDownload(item)}
>
<svg
@@ -775,12 +650,12 @@ export default function ProjectFileManager({
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
</IconButton>
)}
<button
type="button"
className="admin-btn-icon"
<IconButton
size="small"
title="Přejmenovat"
aria-label="Přejmenovat"
onClick={() => startRename(item)}
>
<svg
@@ -794,11 +669,12 @@ export default function ProjectFileManager({
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
type="button"
className="admin-btn-icon danger"
</IconButton>
<IconButton
size="small"
color="error"
title="Smazat"
aria-label="Smazat"
onClick={() => setDeleteTarget(item)}
>
<svg
@@ -813,20 +689,286 @@ export default function ProjectFileManager({
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</IconButton>
</Box>
),
},
]
: []),
];
<ConfirmModal
return (
<Card>
<Typography variant="h6" sx={{ mb: 2 }}>
Soubory
</Typography>
{/* Toolbar */}
<Box
sx={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 1,
mb: 2,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 0.25,
}}
>
{breadcrumb.map((segment, i) => (
<Box
component="span"
key={i}
sx={{ display: "inline-flex", alignItems: "center" }}
>
{i > 0 && (
<Box component="span" sx={{ mx: 0.25, color: "text.disabled" }}>
/
</Box>
)}
<Box
component="button"
type="button"
onClick={() => handleBreadcrumbClick(i)}
sx={{
background: "none",
border: "none",
p: 0,
font: "inherit",
cursor: "pointer",
color:
i === breadcrumb.length - 1
? "text.primary"
: "primary.main",
fontWeight: i === breadcrumb.length - 1 ? 600 : 400,
"&:hover": { textDecoration: "underline" },
}}
>
{i === 0 ? projectNumber : segment}
</Box>
</Box>
))}
</Box>
{fullPath && (
<Typography
variant="caption"
color="text.secondary"
title={fullPath}
sx={{
maxWidth: 320,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{fullPath}
</Typography>
)}
{canManage && (
<Box sx={{ display: "flex", gap: 1, ml: "auto" }}>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={() => {
setNewFolderMode(!newFolderMode);
setNewFolderName("");
}}
startIcon={
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
}
>
Složka
</Button>
<Button
size="small"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
startIcon={
uploading ? (
<CircularProgress size={14} color="inherit" />
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
)
}
>
{uploading ? "Nahrávání..." : "Nahrát"}
</Button>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={handleFileInputChange}
/>
</Box>
)}
</Box>
{/* New folder input */}
{newFolderMode && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mb: 2,
}}
>
<TextField
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Název složky..."
autoFocus
sx={{ maxWidth: 320 }}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateFolder();
if (e.key === "Escape") {
setNewFolderMode(false);
setNewFolderName("");
}
}}
/>
<Button
size="small"
onClick={handleCreateFolder}
disabled={creatingFolder || !newFolderName.trim()}
>
{creatingFolder ? (
<CircularProgress size={14} color="inherit" />
) : (
"Vytvořit"
)}
</Button>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={() => {
setNewFolderMode(false);
setNewFolderName("");
}}
>
Zrušit
</Button>
</Box>
)}
{/* Drop zone + table */}
<Box
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
sx={{
position: "relative",
borderRadius: 2,
...(dragOver
? {
outline: "2px dashed",
outlineColor: "primary.main",
outlineOffset: -4,
}
: {}),
}}
>
{dragOver && (
<Box
sx={{
position: "absolute",
inset: 0,
zIndex: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 1,
bgcolor: "action.hover",
color: "primary.main",
borderRadius: 2,
pointerEvents: "none",
}}
>
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span>Přetáhněte soubory sem</span>
</Box>
)}
{items.length === 0 && !filesLoading ? (
<EmptyState
icon={
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
}
title={
hasNasFolder
? "Složka je prázdná"
: "Složka projektu zatím neexistuje"
}
description={
canManage && !hasNasFolder
? "Nahrání souboru ji automaticky vytvoří"
: undefined
}
/>
) : (
<DataTable
columns={columns}
rows={items}
rowKey={(item) => item.name}
/>
)}
</Box>
<ConfirmDialog
isOpen={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
@@ -836,9 +978,9 @@ export default function ProjectFileManager({
message={`Opravdu chcete smazat "${deleteTarget?.name}"?${deleteTarget?.type === "folder" ? " Složka bude smazána včetně veškerého obsahu." : ""}`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
confirmVariant="danger"
loading={deleting}
/>
</div>
</Card>
);
}

View File

@@ -1,6 +1,7 @@
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css";
import { RichEditorRoot } from "../ui/RichText";
const COLORS = [
"#000000",
@@ -104,8 +105,7 @@ export default function RichEditor({
}, []);
return (
<div
className="admin-rich-editor"
<RichEditorRoot
style={{ "--re-min-height": minHeight } as React.CSSProperties}
>
<ReactQuill
@@ -118,6 +118,6 @@ export default function RichEditor({
placeholder={placeholder}
readOnly={readOnly}
/>
</div>
</RichEditorRoot>
);
}

View File

@@ -1,5 +1,16 @@
import AdminDatePicker from "./AdminDatePicker";
import FormModal from "./FormModal";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import {
Modal,
Button,
Select,
TextField,
DateField,
TimeField,
Field,
Alert,
} from "../ui";
import {
calcFormWorkMinutes,
calcProjectMinutesTotal,
@@ -80,6 +91,19 @@ export interface ShiftFormModalProps {
editingRecord: EditingRecord | null;
}
const RemoveIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
);
// ---------- ProjectTimeStatus ----------
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
@@ -100,30 +124,14 @@ function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
const isMatch = remaining === 0;
return (
<div
style={{
padding: "0.5rem 0.75rem",
marginBottom: "0.5rem",
borderRadius: "6px",
fontSize: "0.8rem",
background: isMatch
? "var(--success-bg, rgba(34,197,94,0.1))"
: "var(--danger-bg, rgba(239,68,68,0.1))",
color: isMatch
? "var(--success-color, #16a34a)"
: "var(--danger-color, #dc2626)",
border: `1px solid ${
isMatch
? "var(--success-border, rgba(34,197,94,0.3))"
: "var(--danger-border, rgba(239,68,68,0.3))"
}`,
}}
>
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m | Přiřazeno:{" "}
{Math.floor(totalProject / 60)}h {totalProject % 60}m | Zbývá:{" "}
{Math.floor(Math.abs(remaining) / 60)}h {Math.abs(remaining) % 60}m{" "}
{remaining < 0 ? "(překročeno)" : ""}
</div>
<Box sx={{ mb: 1 }}>
<Alert severity={isMatch ? "success" : "error"}>
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m |
Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m |
Zbývá: {Math.floor(Math.abs(remaining) / 60)}h{" "}
{Math.abs(remaining) % 60}m {remaining < 0 ? "(překročeno)" : ""}
</Alert>
</Box>
);
}
@@ -137,65 +145,65 @@ function ProjectLogRow({
onRemove,
}: ProjectLogRowProps) {
return (
<div className="flex-row gap-2 mb-2">
<select
value={log.project_id}
onChange={(e) => onUpdate(index, "project_id", e.target.value)}
className="admin-form-select"
style={{ flex: 3, marginBottom: 0 }}
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mb: 1,
}}
>
<option value=""> Projekt </option>
{projectList.map((p) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
<input
<Box sx={{ flex: 3, minWidth: 0 }}>
<Select
value={String(log.project_id)}
onChange={(val) => onUpdate(index, "project_id", val)}
options={[
{ value: "", label: "— Projekt —" },
...projectList.map((p) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
]}
/>
</Box>
<Box sx={{ width: 70, flexShrink: 0 }}>
<TextField
type="number"
min="0"
max="24"
value={log.hours}
onChange={(e) => onUpdate(index, "hours", e.target.value)}
className="admin-form-input"
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
placeholder="h"
slotProps={{
htmlInput: { min: 0, max: 24, style: { textAlign: "center" } },
}}
/>
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
</Box>
<Typography variant="body2" color="text.secondary">
h
</span>
<input
</Typography>
<Box sx={{ width: 70, flexShrink: 0 }}>
<TextField
type="number"
min="0"
max="59"
value={log.minutes}
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
className="admin-form-input"
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
placeholder="m"
slotProps={{
htmlInput: { min: 0, max: 59, style: { textAlign: "center" } },
}}
/>
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
</Box>
<Typography variant="body2" color="text.secondary">
m
</span>
<button
type="button"
</Typography>
<IconButton
size="small"
onClick={() => onRemove(index)}
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{ padding: "0.375rem", flexShrink: 0 }}
aria-label="Odebrat"
title="Odebrat"
sx={{ flexShrink: 0 }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{RemoveIcon}
</IconButton>
</Box>
);
}
@@ -245,7 +253,7 @@ export default function ShiftFormModal({
};
return (
<FormModal
<Modal
isOpen={isOpen}
onClose={onClose}
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
@@ -254,169 +262,172 @@ export default function ShiftFormModal({
? `${editingRecord.user_name}${formatDate(editingRecord.shift_date)}`
: undefined
}
size="lg"
maxWidth="lg"
onSubmit={onSubmit}
submitLabel="Uložit"
cancelLabel="Zrušit"
submitText="Uložit"
cancelText="Zrušit"
>
<div className="admin-form">
{isCreate ? (
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label required">Zaměstnanec</label>
<select
value={form.user_id}
onChange={(e) => updateField("user_id", e.target.value)}
className="admin-form-select"
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label required">Datum směny</label>
<AdminDatePicker
mode="date"
<Field label="Zaměstnanec" required>
<Select
value={form.user_id}
onChange={(val) => updateField("user_id", val)}
options={[
{ value: "", label: "Vyberte zaměstnance" },
...users.map((user) => ({
value: String(user.id),
label: user.name,
})),
]}
/>
</Field>
<Field label="Datum směny" required>
<DateField
value={form.shift_date}
onChange={(val) => onShiftDateChange(val)}
/>
</div>
</div>
</Field>
</Box>
) : (
<div className="admin-form-group">
<label className="admin-form-label">Datum směny</label>
<AdminDatePicker
mode="date"
<Field label="Datum směny">
<DateField
value={form.shift_date}
onChange={(val) => updateField("shift_date", val)}
/>
</div>
</Field>
)}
<div className="admin-form-group">
<label className="admin-form-label">Typ záznamu</label>
<select
<Field label="Typ záznamu">
<Select
value={form.leave_type}
onChange={(e) => updateField("leave_type", e.target.value)}
className="admin-form-select"
>
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="unpaid">Neplacené volno</option>
</select>
</div>
onChange={(val) => updateField("leave_type", val)}
options={[
{ value: "work", label: "Práce" },
{ value: "vacation", label: "Dovolená" },
{ value: "sick", label: "Nemoc" },
{ value: "unpaid", label: "Neplacené volno" },
]}
/>
</Field>
{!isWorkType && (
<div className="admin-form-group">
<label className="admin-form-label">Počet hodin</label>
<input
<Field
label="Počet hodin"
hint={isCreate ? "8 hodin = celý den" : undefined}
>
<TextField
type="number"
inputMode="decimal"
value={form.leave_hours}
onChange={(e) =>
updateField("leave_hours", parseFloat(e.target.value))
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
/>
{isCreate && (
<small className="admin-form-hint">8 hodin = celý den</small>
)}
</div>
</Field>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Příchod - datum</label>
<AdminDatePicker
mode="date"
{/* Arrival */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Příchod - datum">
<DateField
value={form.arrival_date}
onChange={(val) => updateField("arrival_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příchod - čas</label>
<AdminDatePicker
mode="time"
</Field>
<Field label="Příchod - čas">
<TimeField
value={form.arrival_time}
onChange={(val) => updateField("arrival_time", val)}
/>
</div>
</div>
</Field>
</Box>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">
Začátek pauzy - datum
</label>
<AdminDatePicker
mode="date"
{/* Break start */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Začátek pauzy - datum">
<DateField
value={form.break_start_date}
onChange={(val) => updateField("break_start_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Začátek pauzy - čas</label>
<AdminDatePicker
mode="time"
</Field>
<Field label="Začátek pauzy - čas">
<TimeField
value={form.break_start_time}
onChange={(val) => updateField("break_start_time", val)}
/>
</div>
</div>
</Field>
</Box>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Konec pauzy - datum</label>
<AdminDatePicker
mode="date"
{/* Break end */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Konec pauzy - datum">
<DateField
value={form.break_end_date}
onChange={(val) => updateField("break_end_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Konec pauzy - čas</label>
<AdminDatePicker
mode="time"
</Field>
<Field label="Konec pauzy - čas">
<TimeField
value={form.break_end_time}
onChange={(val) => updateField("break_end_time", val)}
/>
</div>
</div>
</Field>
</Box>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Odchod - datum</label>
<AdminDatePicker
mode="date"
{/* Departure */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Odchod - datum">
<DateField
value={form.departure_date}
onChange={(val) => updateField("departure_date", val)}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Odchod - čas</label>
<AdminDatePicker
mode="time"
</Field>
<Field label="Odchod - čas">
<TimeField
value={form.departure_time}
onChange={(val) => updateField("departure_time", val)}
/>
</div>
</div>
</Field>
</Box>
</>
)}
{isWorkType && projectList.length > 0 && (
<div className="admin-form-group">
<label className="admin-form-label">Projekty</label>
<Field label="Projekty">
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
{projectLogs.map((log, i) => (
<ProjectLogRow
@@ -428,26 +439,25 @@ export default function ShiftFormModal({
onRemove={removeProjectLog}
/>
))}
<button
type="button"
<Button
variant="outlined"
color="inherit"
size="small"
onClick={addProjectLog}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat projekt
</button>
</div>
</Button>
</Field>
)}
<div className="admin-form-group">
<label className="admin-form-label">Poznámka</label>
<textarea
<Field label="Poznámka">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) => updateField("notes", e.target.value)}
className="admin-form-textarea"
rows={3}
/>
</div>
</div>
</FormModal>
</Field>
</Modal>
);
}

View File

@@ -1,36 +0,0 @@
interface SortIconProps {
column: string;
sort: string | null;
order: string;
}
export default function SortIcon({ column, sort, order }: SortIconProps) {
if (sort !== column) {
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ opacity: 0.3, marginLeft: 4 }}
>
<path d="M7 15l5 5 5-5M7 9l5-5 5 5" />
</svg>
);
}
return (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginLeft: 4 }}
>
{order === "asc" ? <path d="M7 15l5 5 5-5" /> : <path d="M7 9l5-5 5 5" />}
</svg>
);
}

View File

@@ -1,4 +1,8 @@
import { Link } from "react-router-dom";
import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Card, Button, EmptyState } from "../../ui";
import { iconBadgeSx, type IconBadgeColor } from "../../theme";
import {
ENTITY_TYPE_LABELS,
getActivityIconClass,
@@ -18,6 +22,16 @@ interface DashActivityFeedProps {
activities: Activity[] | null;
}
// Maps the legacy activity-icon class to the MUI palette key used to tint the
// circular icon badge (danger → error, accent → primary, muted → default).
const ACTIVITY_BADGE_COLOR: Record<string, string> = {
success: "success",
info: "info",
danger: "error",
accent: "primary",
muted: "default",
};
function getActivityIcon(action: string) {
switch (action) {
case "create":
@@ -103,37 +117,95 @@ export default function DashActivityFeed({
}
return (
<div className="admin-card dash-activity-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Audit log</h2>
<Link
<Card sx={{ display: "flex", flexDirection: "column" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<Typography variant="h6">Audit log</Typography>
<Button
component={RouterLink}
to="/audit-log"
className="admin-btn admin-btn-primary admin-btn-sm"
size="small"
sx={{ flexShrink: 0 }}
>
Detail &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{activities.map((act) => (
<div key={act.id} className="dash-activity-row">
<div
className={`dash-activity-icon ${getActivityIconClass(act.action)}`}
</Button>
</Box>
{activities.length === 0 ? (
<EmptyState title="Žádná aktivita" />
) : (
<Box sx={{ display: "flex", flexDirection: "column" }}>
{activities.map((act) => {
const badgeColor =
ACTIVITY_BADGE_COLOR[getActivityIconClass(act.action)] ??
"default";
const isDefault = badgeColor === "default";
return (
<Box
key={act.id}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
"&:last-of-type": { borderBottom: 0 },
}}
>
<Box
sx={[
{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: "50%",
flexShrink: 0,
},
iconBadgeSx(
isDefault ? "default" : (badgeColor as IconBadgeColor),
),
]}
>
{getActivityIcon(act.action)}
</div>
<div className="dash-activity-main">
<div className="dash-activity-text">{act.description}</div>
<div className="dash-activity-sub">
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body2"
sx={{ fontWeight: 500 }}
noWrap
title={act.description}
>
{act.description}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{act.username || "Systém"} ·{" "}
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
</div>
</div>
<div className="dash-activity-time admin-mono">
</Typography>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
flexShrink: 0,
}}
>
{formatActivityTime(act.created_at)}
</div>
</div>
))}
</div>
</div>
</Typography>
</Box>
);
})}
</Box>
)}
</Card>
);
}

View File

@@ -1,9 +1,9 @@
import { Link } from "react-router-dom";
import {
LEAVE_TYPE_LABELS,
STATUS_DOT_CLASS,
STATUS_LABELS,
} from "../../utils/dashboardHelpers";
import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Avatar from "@mui/material/Avatar";
import { Card, Button, StatusChip, EmptyState } from "../../ui";
import { LEAVE_TYPE_LABELS, STATUS_LABELS } from "../../utils/dashboardHelpers";
interface AttendanceUser {
user_id: number | string;
@@ -22,6 +22,19 @@ interface DashAttendanceTodayProps {
attendance: AttendanceData | null;
}
// Maps the attendance status to the StatusChip / avatar tint color.
// Mirrors the legacy dash-status-* dot classes: in→success, away→warning,
// out→default, leave→error.
const STATUS_COLOR: Record<
string,
"default" | "success" | "warning" | "error"
> = {
in: "success",
away: "warning",
out: "default",
leave: "error",
};
export default function DashAttendanceToday({
attendance,
}: DashAttendanceTodayProps) {
@@ -30,42 +43,96 @@ export default function DashAttendanceToday({
}
return (
<div className="admin-card dash-attendance-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Docházka dnes</h2>
<Link
<Card sx={{ display: "flex", flexDirection: "column" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<Typography variant="h6">Docházka dnes</Typography>
<Button
component={RouterLink}
to="/attendance/admin"
className="admin-btn admin-btn-primary admin-btn-sm"
size="small"
sx={{ flexShrink: 0 }}
>
Detail &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{attendance.users.map((u, i) => (
<div key={`${u.user_id}-${i}`} className="dash-presence-row">
<div
className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}
</Button>
</Box>
{attendance.users.length === 0 ? (
<EmptyState title="Žádná docházka" />
) : (
<Box sx={{ display: "flex", flexDirection: "column" }}>
{attendance.users.map((u, i) => {
const color = STATUS_COLOR[u.status] ?? "default";
const isDefault = color === "default";
return (
<Box
key={`${u.user_id}-${i}`}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
"&:last-of-type": { borderBottom: 0 },
}}
>
<Avatar
sx={{
width: 36,
height: 36,
fontSize: "0.8rem",
fontWeight: 700,
bgcolor: isDefault ? "grey.600" : `${color}.main`,
color: "common.white",
}}
>
{u.initials || "?"}
</div>
<div className="dash-presence-name">{u.name}</div>
<div className="dash-presence-end">
<span
className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}
</Avatar>
<Typography
variant="body2"
sx={{ fontWeight: 500, flex: 1, minWidth: 0 }}
noWrap
>
{u.status === "leave"
{u.name}
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexShrink: 0,
}}
>
<StatusChip
color={color}
label={
u.status === "leave"
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
: STATUS_LABELS[u.status]}
</span>
: STATUS_LABELS[u.status]
}
/>
{u.arrived_at && (
<span className="admin-mono dash-presence-time">
<Typography
variant="caption"
color="text.secondary"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{u.arrived_at}
</span>
</Typography>
)}
</div>
</div>
))}
</div>
</div>
</Box>
</Box>
);
})}
</Box>
)}
</Card>
);
}

View File

@@ -1,4 +1,7 @@
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { StatCard, type StatCardColor } from "../../ui";
import { formatCurrency } from "../../utils/formatters";
interface KpiCard {
@@ -108,11 +111,13 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
};
}
const KPI_CLASS_MAP: Record<number, string> = {
4: "admin-kpi-4",
3: "admin-kpi-3",
2: "admin-kpi-2",
1: "admin-kpi-1",
// Maps the legacy KPI color tokens to the StatCard color palette
// (danger → error; the rest are 1:1).
const KPI_COLOR_MAP: Record<string, StatCardColor> = {
success: "success",
info: "info",
warning: "warning",
danger: "error",
};
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
@@ -121,36 +126,50 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
return null;
}
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "admin-kpi-4";
return (
<motion.div
className={`admin-kpi-grid ${kpiClass}`}
<Box
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
lg: `repeat(${Math.min(kpiCards.length, 4)}, 1fr)`,
},
gap: 2,
mb: 3,
}}
>
{kpiCards.map((kpi) => (
<div key={kpi.label} className={`admin-stat-card ${kpi.color}`}>
<div className="admin-stat-label">{kpi.label}</div>
<div className="admin-stat-value admin-mono">
<StatCard
key={kpi.label}
color={KPI_COLOR_MAP[kpi.color] ?? "default"}
label={kpi.label}
value={
<>
{kpi.value}
{kpi.sub && (
<small
className="text-muted"
style={{
<Typography
component="span"
color="text.secondary"
sx={{
fontSize: "0.75em",
fontWeight: 500,
marginLeft: "0.25rem",
ml: 0.5,
fontFamily: "inherit",
}}
>
{kpi.sub}
</small>
</Typography>
)}
</div>
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
</div>
</>
}
footer={kpi.footer ?? undefined}
/>
))}
</motion.div>
</Box>
);
}

View File

@@ -1,9 +1,18 @@
import { useState, useRef } from "react";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
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 { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext";
import apiFetch from "../../utils/api";
import FormModal from "../FormModal";
import { iconBadgeSx } from "../../theme";
import { Card, Button, Modal, Field, TextField } from "../../ui";
import useDialogScrollLock from "../../ui/useDialogScrollLock";
const API_BASE = "/api/admin";
@@ -37,6 +46,34 @@ interface ProfileFormData {
last_name: string;
}
const EditIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const CopyIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);
export default function DashProfile({
totpEnabled,
totpLoading,
@@ -61,6 +98,10 @@ export default function DashProfile({
const alert = useAlert();
const totpSetupRef = useRef<HTMLInputElement>(null);
// The 2FA setup dialog is bespoke (multi-step: setup → backup codes) and
// locks <html> scroll like the kit dialogs.
useDialogScrollLock(show2FASetup);
const [showModal, setShowModal] = useState(false);
const [formData, setFormData] = useState<ProfileFormData>({
username: "",
@@ -133,81 +174,96 @@ export default function DashProfile({
return totpEnabled ? "Aktivní" : "Neaktivní";
}
const profileItems: Array<{ label: string; value: string }> = [
{ label: "Uživatel", value: user?.username || "" },
{ label: "E-mail", value: user?.email || "" },
{ label: "Jméno", value: user?.fullName || "" },
{
label: "Role",
value: user?.roleDisplay || String(user?.role || ""),
},
];
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Váš účet</h2>
<button
onClick={openEditModal}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
Upravit
</button>
</div>
<div className="admin-card-body">
<div className="dash-profile-grid">
<div className="dash-profile-item">
<span className="dash-profile-label">Uživatel</span>
<span className="dash-profile-value">{user?.username}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">E-mail</span>
<span className="dash-profile-value">{user?.email}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Jméno</span>
<span className="dash-profile-value">{user?.fullName}</span>
</div>
<div className="dash-profile-item">
<span className="dash-profile-label">Role</span>
<span className="dash-profile-value">
{user?.roleDisplay || String(user?.role || "")}
</span>
</div>
</div>
{/* 2FA Section */}
<div
style={{
borderTop: "1px solid var(--border-color)",
marginTop: "1rem",
paddingTop: "1rem",
<Card>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<div className="flex-between">
<div className="flex-row-gap">
<div
style={{
<Typography variant="h6">Váš účet</Typography>
<Button
onClick={openEditModal}
variant="outlined"
color="inherit"
size="small"
startIcon={EditIcon}
>
Upravit
</Button>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 1.5,
}}
>
{profileItems.map((item) => (
<Box key={item.label}>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
textTransform: "uppercase",
letterSpacing: ".05em",
fontWeight: 600,
}}
>
{item.label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{item.value}
</Typography>
</Box>
))}
</Box>
{/* 2FA Section */}
<Box sx={{ borderTop: 1, borderColor: "divider", mt: 2, pt: 2 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1.5,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={[
{
width: 36,
height: 36,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: totpEnabled
? "var(--success-light)"
: "rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)",
color: totpEnabled
? "var(--success)"
: "var(--text-secondary)",
}}
flexShrink: 0,
},
iconBadgeSx(totpEnabled ? "success" : "default"),
]}
>
<svg
width="18"
@@ -220,179 +276,164 @@ export default function DashProfile({
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div>
<div style={{ fontWeight: 500, fontSize: "0.875rem" }}>
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
Dvoufaktorové ověření (2FA)
</div>
<div
className={totpEnabled ? "text-success" : "text-secondary"}
style={{ fontSize: "0.75rem" }}
</Typography>
<Typography
variant="caption"
sx={{
color: totpEnabled ? "success.main" : "text.secondary",
}}
>
{getTotpStatusText()}
</div>
</div>
</div>
</Typography>
</Box>
</Box>
{!totpLoading &&
(totpEnabled ? (
<button
<Button
onClick={() => {
setDisableCode("");
setShow2FADisable(true);
}}
className="admin-btn admin-btn-primary admin-btn-sm"
size="small"
sx={{ flexShrink: 0 }}
>
Deaktivovat
</button>
</Button>
) : (
<button
<Button
onClick={onStart2FASetup}
disabled={totpSubmitting}
className="admin-btn admin-btn-primary admin-btn-sm"
size="small"
sx={{ flexShrink: 0 }}
>
{totpSubmitting ? "Generuji..." : "Aktivovat"}
</button>
</Button>
))}
</div>
</div>
</div>
</Box>
</Box>
</Card>
</motion.div>
{/* Edit Profile Modal */}
<FormModal
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="Upravit profil"
size="md"
maxWidth="sm"
onSubmit={handleSubmit}
submitLabel="Uložit změny"
submitText="Uložit změny"
>
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">Jméno</label>
<input
type="text"
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Jméno">
<TextField
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
setFormData({ ...formData, first_name: e.target.value })
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Příjmení</label>
<input
type="text"
</Field>
<Field label="Příjmení">
<TextField
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
setFormData({ ...formData, last_name: e.target.value })
}
required
className="admin-form-input"
/>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Uživatelské jméno</label>
<input
type="text"
</Field>
</Box>
<Field label="Uživatelské jméno">
<TextField
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">E-mail</label>
<input
</Field>
<Field label="E-mail">
<TextField
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
className="admin-form-input"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Aktuální heslo</label>
<input
</Field>
<Field label="Aktuální heslo">
<TextField
type="password"
value={formData.current_password}
onChange={(e) =>
setFormData({
...formData,
current_password: e.target.value,
})
setFormData({ ...formData, current_password: e.target.value })
}
className="admin-form-input"
placeholder="Pro změnu hesla, zadejte aktuální heslo"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Nové heslo</label>
<input
</Field>
<Field label="Nové heslo">
<TextField
type="password"
value={formData.new_password}
onChange={(e) =>
setFormData({
...formData,
new_password: e.target.value,
})
setFormData({ ...formData, new_password: e.target.value })
}
className="admin-form-input"
placeholder="Pro změnu hesla, zadejte nové heslo"
/>
</div>
</div>
</FormModal>
</Field>
</Modal>
{/* 2FA Setup Modal — multi-step: setup → backup codes */}
<FormModal
isOpen={show2FASetup}
onClose={() => {
// Only reachable on the setup step (backdrop/ESC/× are locked while
// backupCodes is set — that branch is unreachable so removed).
{/* 2FA Setup Dialog — bespoke, multi-step: setup → backup codes.
On the backup-codes step the footer + close are hidden and
backdrop/ESC are locked; the single deliberate exit lives in the body
and also clears the codes (so unsaved codes can't be silently lost). */}
<Dialog
open={show2FASetup}
onClose={
backupCodes
? undefined
: () => {
setShow2FASetup(false);
setTotpCode("");
}}
title={backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
size="md"
loading={backupCodes ? false : totpSubmitting}
onSubmit={
backupCodes
? () => {
setShow2FASetup(false);
setBackupCodes(null);
}
: onConfirm2FA
}
submitLabel={
backupCodes ? "Rozumím, uložil jsem si kódy" : "Aktivovat 2FA"
}
submitDisabled={backupCodes ? false : totpCode.length !== 6}
cancelLabel="Zrušit"
// On the backup-codes step the cancel/× would silently dismiss unsaved
// codes, so hide the footer + × and lock backdrop/ESC; the explicit
// in-body primary is the single deliberate exit.
hideFooter={!!backupCodes}
hideCloseButton={!!backupCodes}
closeOnBackdrop={!backupCodes}
closeOnEsc={!backupCodes}
fullWidth
maxWidth="sm"
disableScrollLock
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
>
<DialogTitle>
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
</DialogTitle>
<DialogContent>
{backupCodes ? (
<div>
<div className="admin-role-locked-notice mb-4">
<Box>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
gap: 1,
p: 1.5,
mb: 2,
borderRadius: 2,
bgcolor:
"rgba(var(--mui-palette-warning-mainChannel) / 0.12)",
color: "warning.main",
fontSize: "0.875rem",
}}
>
<Box sx={{ flexShrink: 0, mt: "2px" }}>
<svg
width="16"
height="16"
@@ -405,161 +446,134 @@ export default function DashProfile({
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze
jednou. Po zavření tohoto okna je již neuvidíte.
</div>
<div
style={{
</Box>
<span>
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít
pouze jednou. Po zavření tohoto okna je již neuvidíte.
</span>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: "0.5rem",
padding: "1rem",
background: "var(--bg-secondary)",
borderRadius: "0.5rem",
fontFamily: "monospace",
gap: 1,
p: 2,
bgcolor: "action.hover",
borderRadius: 2,
fontFamily: "'DM Mono', Menlo, monospace",
fontSize: "1rem",
}}
>
{backupCodes.map((code) => (
<div
<Box
key={code}
style={{
padding: "0.25rem 0.5rem",
sx={{
px: 1,
py: 0.5,
textAlign: "center",
color: "var(--text-primary)",
color: "text.primary",
}}
>
{code}
</div>
</Box>
))}
</div>
<div style={{ marginTop: "0.75rem" }}>
<button
</Box>
<Box sx={{ mt: 1.5 }}>
<Button
onClick={() => {
navigator.clipboard?.writeText(backupCodes.join("\n"));
alert.success("Kódy zkopírovány");
}}
className="admin-btn admin-btn-secondary admin-btn-sm"
variant="outlined"
color="inherit"
size="small"
startIcon={CopyIcon}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Kopírovat kódy
</button>
</div>
{/* Footer is hidden on this step; the single deliberate exit lives
in the body and also clears the codes. */}
<div style={{ marginTop: "1.25rem", textAlign: "right" }}>
<button
onClick={() => {
setShow2FASetup(false);
setBackupCodes(null);
}}
className="admin-btn admin-btn-primary"
>
Rozumím, uložil jsem si kódy
</button>
</div>
</div>
</Button>
</Box>
</Box>
) : (
<div>
<p
className="text-secondary"
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
>
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
Authy, Microsoft Authenticator apod.)
</p>
</Typography>
{totpQrUri && (
<div style={{ textAlign: "center", marginBottom: "1rem" }}>
<img
<Box sx={{ textAlign: "center", mb: 2 }}>
<Box
component="img"
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
alt="TOTP QR Code"
style={{
sx={{
width: 200,
height: 200,
borderRadius: "0.5rem",
border: "1px solid var(--border-color)",
borderRadius: 2,
border: 1,
borderColor: "divider",
}}
/>
</div>
</Box>
)}
{totpSecret && (
<div className="mb-4">
<label
className="admin-form-label"
style={{ fontSize: "0.75rem" }}
<Box sx={{ mb: 2 }}>
<Typography
component="label"
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 0.5 }}
>
Nebo zadejte klíč ručně:
</label>
<div
style={{
padding: "0.5rem 0.75rem",
background: "var(--bg-secondary)",
borderRadius: "0.375rem",
fontFamily: "monospace",
fontSize: "0.875rem",
wordBreak: "break-all",
color: "var(--text-primary)",
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.5rem",
gap: 1,
px: 1.5,
py: 1,
bgcolor: "action.hover",
borderRadius: 1.5,
fontFamily: "'DM Mono', Menlo, monospace",
fontSize: "0.875rem",
wordBreak: "break-all",
color: "text.primary",
}}
>
<span>{totpSecret}</span>
<button
<IconButton
onClick={() => {
navigator.clipboard?.writeText(totpSecret);
alert.success("Klíč zkopírován");
}}
className="admin-btn-icon"
size="small"
title="Kopírovat"
aria-label="Kopírovat"
style={{ flexShrink: 0 }}
sx={{ flexShrink: 0 }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
</div>
</div>
{CopyIcon}
</IconButton>
</Box>
</Box>
)}
<div className="admin-form-group">
<label className="admin-form-label">
Ověřovací kód z aplikace
</label>
<input
ref={totpSetupRef}
type="text"
<Field label="Ověřovací kód z aplikace">
<TextField
inputRef={totpSetupRef}
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
onChange={(e) =>
setTotpCode(e.target.value.replace(/\D/g, ""))
}
placeholder="000000"
className="admin-form-input"
style={{
slotProps={{ htmlInput: { maxLength: 6, pattern: "[0-9]*" } }}
sx={{
"& input": {
textAlign: "center",
fontSize: "1.25rem",
letterSpacing: "0.4rem",
fontFamily: "monospace",
fontFamily: "'DM Mono', Menlo, monospace",
},
}}
onKeyDown={(e) => {
if (e.key === "Enter" && totpCode.length === 6) {
@@ -567,59 +581,86 @@ export default function DashProfile({
}
}}
/>
</div>
</div>
</Field>
</Box>
)}
</FormModal>
</DialogContent>
{/* Footer is hidden on the backup-codes step; the single deliberate exit
lives in the body actions there. */}
<DialogActions sx={{ px: 3, pb: 2 }}>
{backupCodes ? (
<Button
onClick={() => {
setShow2FASetup(false);
setBackupCodes(null);
}}
>
Rozumím, uložil jsem si kódy
</Button>
) : (
<>
<Button
onClick={() => {
setShow2FASetup(false);
setTotpCode("");
}}
variant="text"
color="inherit"
disabled={totpSubmitting}
>
Zrušit
</Button>
<Button
onClick={onConfirm2FA}
disabled={totpSubmitting || totpCode.length !== 6}
>
{totpSubmitting ? "Ukládám…" : "Aktivovat 2FA"}
</Button>
</>
)}
</DialogActions>
</Dialog>
{/* 2FA Disable Modal */}
<FormModal
<Modal
isOpen={show2FADisable}
onClose={() => setShow2FADisable(false)}
title="Deaktivovat 2FA"
size="md"
maxWidth="sm"
loading={totpSubmitting}
onSubmit={onDisable2FA}
submitDisabled={disableCode.length !== 6}
submitLabel="Deaktivovat 2FA"
cancelLabel="Zrušit"
>
<p
style={{
color: "var(--text-secondary)",
fontSize: "0.875rem",
marginBottom: "1rem",
}}
submitText="Deaktivovat 2FA"
cancelText="Zrušit"
>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
autentizační aplikace.
</p>
<div className="admin-form-group">
<label className="admin-form-label">Ověřovací kód</label>
<input
type="text"
</Typography>
<Field label="Ověřovací kód">
<TextField
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={disableCode}
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ""))}
placeholder="000000"
className="admin-form-input"
style={{
autoFocus
slotProps={{ htmlInput: { maxLength: 6, pattern: "[0-9]*" } }}
sx={{
"& input": {
textAlign: "center",
fontSize: "1.25rem",
letterSpacing: "0.4rem",
fontFamily: "monospace",
fontFamily: "'DM Mono', Menlo, monospace",
},
}}
onKeyDown={(e) => {
if (e.key === "Enter" && disableCode.length === 6) {
onDisable2FA();
}
}}
autoFocus
/>
</div>
</FormModal>
</Field>
</Modal>
</>
);
}

View File

@@ -1,12 +1,12 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext";
import { formatKm, todayLocalStr } from "../../utils/formatters";
import AdminDatePicker from "../AdminDatePicker";
import { Button, Modal, Field, Select, DateField, TextField } from "../../ui";
import apiFetch from "../../utils/api";
import FormModal from "../FormModal";
const API_BASE = "/api/admin";
@@ -46,6 +46,14 @@ interface DashQuickActionsProps {
onPunch: () => void;
}
// Maps the legacy quick-action color tokens to the MUI Button palette color.
const ACTION_COLOR: Record<string, "success" | "error" | "info" | "warning"> = {
success: "success",
danger: "error",
info: "info",
warning: "warning",
};
export default function DashQuickActions({
dashData,
punching,
@@ -284,252 +292,192 @@ export default function DashQuickActions({
return (
<>
<motion.div
className="dash-quick-actions"
<Box
component={motion.div}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
md: "repeat(4, 1fr)",
},
gap: 2,
mb: 3,
}}
>
{quickActions.map((action) =>
action.onClick ? (
<button
{quickActions.map((action) => {
const color = ACTION_COLOR[action.color] ?? "primary";
const common = {
variant: "contained" as const,
color,
startIcon: action.icon,
sx: { py: 1.5, justifyContent: "flex-start" },
};
return action.onClick ? (
<Button
key={action.label}
onClick={action.onClick}
disabled={action.disabled}
className={`dash-quick-btn dash-quick-btn-${action.color}`}
{...common}
>
{action.icon}
<span>{action.label}</span>
</button>
{action.label}
</Button>
) : (
<Link
<Button
key={action.label}
component={RouterLink}
to={action.path!}
className={`dash-quick-btn dash-quick-btn-${action.color}`}
{...common}
>
{action.icon}
<span>{action.label}</span>
</Link>
),
)}
</motion.div>
{action.label}
</Button>
);
})}
</Box>
<FormModal
<Modal
isOpen={showTripModal}
onClose={() => setShowTripModal(false)}
title="Přidat jízdu"
size="lg"
maxWidth="md"
onSubmit={handleTripSubmit}
submitLabel="Uložit"
submitText="Uložit"
loading={tripSubmitting}
>
<div className="admin-form">
<div className="admin-form-row">
<div
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
>
<label className="admin-form-label required">Vozidlo</label>
<select
value={tripForm.vehicle_id}
onChange={(e) => {
handleTripVehicleChange(e.target.value);
setTripErrors((prev) => ({
...prev,
vehicle_id: undefined,
}));
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
className="admin-form-select"
>
<option value="">Vyberte vozidlo</option>
{tripVehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
{tripErrors.vehicle_id && (
<span className="admin-form-error">
{tripErrors.vehicle_id}
</span>
)}
</div>
<div
className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
>
<label className="admin-form-label required">Datum jízdy</label>
<AdminDatePicker
mode="date"
<Field label="Vozidlo" required error={tripErrors.vehicle_id}>
<Select
value={tripForm.vehicle_id}
onChange={(val) => {
handleTripVehicleChange(val);
setTripErrors((prev) => ({ ...prev, vehicle_id: undefined }));
}}
options={[
{ value: "", label: "Vyberte vozidlo" },
...tripVehicles.map((v) => ({
value: String(v.id),
label: `${v.spz} - ${v.name}`,
})),
]}
/>
</Field>
<Field label="Datum jízdy" required error={tripErrors.trip_date}>
<DateField
value={tripForm.trip_date}
onChange={(val: string) => {
onChange={(val) => {
setTripForm((prev) => ({ ...prev, trip_date: val }));
setTripErrors((prev) => ({
...prev,
trip_date: undefined,
}));
setTripErrors((prev) => ({ ...prev, trip_date: undefined }));
}}
/>
{tripErrors.trip_date && (
<span className="admin-form-error">{tripErrors.trip_date}</span>
)}
</div>
</div>
</Field>
</Box>
<div className="admin-form-row admin-form-row-3">
<div
className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" },
gap: 2,
}}
>
<label className="admin-form-label required">
Počáteční stav km
</label>
<input
<Field label="Počáteční stav km" required error={tripErrors.start_km}>
<TextField
type="number"
inputMode="numeric"
value={tripForm.start_km}
onChange={(e) => {
setTripForm((prev) => ({
...prev,
start_km: e.target.value,
}));
setTripErrors((prev) => ({
...prev,
start_km: undefined,
}));
setTripForm((prev) => ({ ...prev, start_km: e.target.value }));
setTripErrors((prev) => ({ ...prev, start_km: undefined }));
}}
className="admin-form-input"
min="0"
slotProps={{ htmlInput: { min: 0 } }}
/>
{tripErrors.start_km && (
<span className="admin-form-error">{tripErrors.start_km}</span>
)}
</div>
<div
className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Konečný stav km
</label>
<input
</Field>
<Field label="Konečný stav km" required error={tripErrors.end_km}>
<TextField
type="number"
inputMode="numeric"
value={tripForm.end_km}
onChange={(e) => {
setTripForm((prev) => ({
...prev,
end_km: e.target.value,
}));
setTripErrors((prev) => ({
...prev,
end_km: undefined,
}));
setTripForm((prev) => ({ ...prev, end_km: e.target.value }));
setTripErrors((prev) => ({ ...prev, end_km: undefined }));
}}
className="admin-form-input"
min="0"
slotProps={{ htmlInput: { min: 0 } }}
/>
{tripErrors.end_km && (
<span className="admin-form-error">{tripErrors.end_km}</span>
)}
</div>
<div className="admin-form-group">
<label className="admin-form-label">Vzdálenost</label>
<input
type="text"
</Field>
<Field label="Vzdálenost">
<TextField
value={`${formatKm(tripDistance())} km`}
className="admin-form-input"
readOnly
InputProps={{ readOnly: true }}
disabled
/>
</div>
</div>
</Field>
</Box>
<div className="admin-form-row">
<div
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<label className="admin-form-label required">Místo odjezdu</label>
<input
type="text"
<Field label="Místo odjezdu" required error={tripErrors.route_from}>
<TextField
value={tripForm.route_from}
onChange={(e) => {
setTripForm((prev) => ({
...prev,
route_from: e.target.value,
}));
setTripErrors((prev) => ({
...prev,
route_from: undefined,
}));
setTripErrors((prev) => ({ ...prev, route_from: undefined }));
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
{tripErrors.route_from && (
<span className="admin-form-error">
{tripErrors.route_from}
</span>
)}
</div>
<div
className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`}
>
<label className="admin-form-label required">
Místo příjezdu
</label>
<input
type="text"
</Field>
<Field label="Místo příjezdu" required error={tripErrors.route_to}>
<TextField
value={tripForm.route_to}
onChange={(e) => {
setTripForm((prev) => ({
...prev,
route_to: e.target.value,
}));
setTripErrors((prev) => ({
...prev,
route_to: undefined,
}));
setTripForm((prev) => ({ ...prev, route_to: e.target.value }));
setTripErrors((prev) => ({ ...prev, route_to: undefined }));
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
{tripErrors.route_to && (
<span className="admin-form-error">{tripErrors.route_to}</span>
)}
</div>
</div>
</Field>
</Box>
<div className="admin-form-group">
<label className="admin-form-label">Typ jízdy</label>
<select
value={tripForm.is_business}
onChange={(e) =>
setTripForm((prev) => ({
...prev,
is_business: parseInt(e.target.value),
}))
<Field label="Typ jízdy">
<Select
value={String(tripForm.is_business)}
onChange={(val) =>
setTripForm((prev) => ({ ...prev, is_business: parseInt(val) }))
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</div>
options={[
{ value: "1", label: "Služební" },
{ value: "0", label: "Soukromá" },
]}
/>
</Field>
<div className="admin-form-group">
<label className="admin-form-label">Poznámky</label>
<textarea
<Field label="Poznámky">
<TextField
multiline
minRows={2}
value={tripForm.notes}
onChange={(e) =>
setTripForm((prev) => ({
...prev,
notes: e.target.value,
}))
setTripForm((prev) => ({ ...prev, notes: e.target.value }))
}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
/>
</div>
</div>
</FormModal>
</Field>
</Modal>
</>
);
}

View File

@@ -1,9 +1,12 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import { useAlert } from "../../context/AlertContext";
import ConfirmModal from "../ConfirmModal";
import useModalLock from "../../hooks/useModalLock";
import { Card, Button, StatusChip, ConfirmDialog } from "../../ui";
import apiFetch from "../../utils/api";
import { formatSessionDate } from "../../utils/dashboardHelpers";
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
@@ -76,8 +79,6 @@ export default function DashSessions() {
const [deleteAllModal, setDeleteAllModal] = useState(false);
const [deleting, setDeleting] = useState(false);
useModalLock(deleteAllModal);
const handleDeleteSession = async () => {
if (!deleteModal.session) {
return;
@@ -127,85 +128,114 @@ export default function DashSessions() {
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
>
<div
className="admin-card-header"
style={{
<Card sx={{ display: "flex", flexDirection: "column" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "0.75rem",
gap: 1.5,
mb: 2,
}}
>
<h2 className="admin-card-title">Přihlášená zařízení</h2>
<Typography variant="h6">Přihlášená zařízení</Typography>
{sessions.filter((s) => !s.is_current).length > 0 && (
<button
<Button
onClick={() => setDeleteAllModal(true)}
className="admin-btn admin-btn-secondary admin-btn-sm"
variant="outlined"
color="inherit"
size="small"
sx={{ flexShrink: 0 }}
>
Odhlásit ostatní
</button>
</Button>
)}
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
</Box>
{sessionsLoading ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{sessions.length === 0 && (
<div
className="text-secondary"
style={{
padding: "1.5rem",
textAlign: "center",
fontSize: "0.875rem",
}}
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress size={28} />
</Box>
) : sessions.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: "center", py: 3 }}
>
Žádné aktivní relace
</div>
)}
{sessions.length > 0 && (
<div className="dash-sessions-list">
</Typography>
) : (
<Box sx={{ display: "flex", flexDirection: "column" }}>
{sessions.map((session) => (
<div
<Box
key={session.id}
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
"&:last-of-type": { borderBottom: 0 },
...(session.is_current
? { bgcolor: "action.hover", borderRadius: 2, px: 1 }
: {}),
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "text.secondary",
flexShrink: 0,
}}
>
<div className="dash-session-icon">
{getDeviceIcon(session.device_info?.icon)}
</div>
<div className="dash-session-info">
<div className="dash-session-device">
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{session.device_info?.browser} na{" "}
{session.device_info?.os}
</Typography>
{session.is_current && (
<span
className="admin-badge admin-badge-success"
style={{ marginLeft: "0.5rem" }}
>
Aktuální
</span>
<StatusChip color="success" label="Aktuální" />
)}
</div>
<div className="dash-session-meta">
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
color: "text.secondary",
fontSize: "0.75rem",
}}
>
<span>{session.ip_address}</span>
<span className="dash-session-meta-separator">|</span>
<span>|</span>
<span>{formatSessionDate(session.created_at)}</span>
</div>
</div>
<div className="dash-session-actions">
</Box>
</Box>
<Box sx={{ flexShrink: 0 }}>
{!session.is_current && (
<button
<IconButton
onClick={() =>
setDeleteModal({ isOpen: true, session })
}
className="admin-btn-icon danger"
color="error"
size="small"
title="Ukončit relaci"
aria-label="Ukončit relaci"
>
@@ -221,19 +251,17 @@ export default function DashSessions() {
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
</IconButton>
)}
</div>
</div>
</Box>
</Box>
))}
</div>
</Box>
)}
</>
)}
</div>
</Card>
</motion.div>
<ConfirmModal
<ConfirmDialog
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, session: null })}
onConfirm={handleDeleteSession}
@@ -241,10 +269,10 @@ export default function DashSessions() {
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
confirmText="Ukončit"
cancelText="Zrušit"
type="danger"
confirmVariant="danger"
loading={deleting}
/>
<ConfirmModal
<ConfirmDialog
isOpen={deleteAllModal}
onClose={() => setDeleteAllModal(false)}
onConfirm={handleDeleteAllSessions}
@@ -252,7 +280,7 @@ export default function DashSessions() {
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
confirmText="Odhlásit vše"
cancelText="Zrušit"
type="warning"
confirmVariant="primary"
loading={deleting}
/>
</>

View File

@@ -0,0 +1,110 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Card } from "../../ui";
import { formatDate } from "../../utils/formatters";
/** The logged-in user's resolved work plan for today (see GET /api/admin/dashboard). */
export interface TodayPlan {
shift_date: string;
category: string;
category_label: string;
category_color: string | null;
project_id: number | null;
project_number: string | null;
project_name: string | null;
note: string;
source: "entry" | "override";
rangeFrom: string | null;
rangeTo: string | null;
}
function projectLabel(p: TodayPlan): string {
const parts = [p.project_number, p.project_name].filter(Boolean);
return parts.length > 0 ? parts.join(" — ") : "Bez projektu";
}
/**
* "Vaše dnešní zařazení" — shows the logged-in user's work-plan entry for today
* (category + project/site + note, with a range badge when the day is part of a
* multi-day range). `plan === null` = the user has plan access but nothing is
* scheduled today (muted empty state). Rendered near the clock-in on the
* dashboard; the caller gates it on the plan permission.
*/
export default function DashTodayPlan({ plan }: { plan: TodayPlan | null }) {
const color = plan?.category_color || "var(--mui-palette-primary-main)";
return (
<Card sx={{ mb: 3 }}>
<Typography
variant="subtitle2"
sx={{ mb: 1.5, fontWeight: 600, color: "text.secondary" }}
>
Vaše dnešní zařazení
</Typography>
{plan ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.75,
px: 1.25,
py: 0.5,
borderRadius: 999,
border: 1,
borderColor: "divider",
bgcolor: `color-mix(in srgb, ${color} 12%, transparent)`,
}}
>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: color,
flexShrink: 0,
}}
/>
<Typography
component="span"
sx={{ fontWeight: 700, fontSize: "0.8rem" }}
>
{plan.category_label}
</Typography>
</Box>
{plan.rangeFrom &&
plan.rangeTo &&
plan.rangeFrom !== plan.rangeTo && (
<Typography variant="caption" color="text.secondary">
součást rozsahu {formatDate(plan.rangeFrom)} {" "}
{formatDate(plan.rangeTo)}
</Typography>
)}
</Box>
<Typography variant="h6" sx={{ lineHeight: 1.25 }}>
{projectLabel(plan)}
</Typography>
{plan.note && (
<Typography variant="body2" color="text.secondary">
{plan.note}
</Typography>
)}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Pro dnešek nemáte naplánováno.
</Typography>
)}
</Card>
);
}

View File

@@ -1,74 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { jsonQuery } from "../../lib/apiAdapter";
interface Batch {
id: number;
quantity: number;
unit_price: number;
received_at: string;
is_consumed: boolean;
}
interface BatchesResponse {
batches: Batch[];
total_stock: number;
reserved_quantity: number;
available_quantity: number;
}
interface BatchPickerProps {
itemId: number | null;
value: number | null;
onChange: (batchId: number, unitPrice: number) => void;
}
export default function BatchPicker({
itemId,
value,
onChange,
}: BatchPickerProps) {
const { data: response } = useQuery({
queryKey: ["warehouse", "batches", itemId],
queryFn: () =>
jsonQuery<BatchesResponse>(
`/api/admin/warehouse/items/${itemId}/batches`,
),
enabled: !!itemId,
});
const batches = response?.batches ?? [];
return (
<div>
{response && response.reserved_quantity > 0 && (
<div
style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)",
marginBottom: "0.25rem",
}}
>
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
{response.reserved_quantity} ks)
</div>
)}
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => {
const batchId = Number(e.target.value);
const batch = batches.find((b) => b.id === batchId);
if (batch) onChange(batch.id, Number(batch.unit_price));
}}
>
<option value="">-- auto FIFO --</option>
{batches.map((b) => (
<option key={b.id} value={b.id}>
{new Date(b.received_at).toLocaleDateString("cs-CZ")} | {b.quantity}{" "}
ks | {Number(b.unit_price).toFixed(2)}
</option>
))}
</select>
</div>
);
}

View File

@@ -1,7 +1,14 @@
import { useState, useRef, useEffect, useCallback, useId } from "react";
import { createPortal } from "react-dom";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { warehouseItemListOptions } from "../../lib/queries/warehouse";
import Autocomplete from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
import CircularProgress from "@mui/material/CircularProgress";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import {
warehouseItemListOptions,
type WarehouseItem,
} from "../../lib/queries/warehouse";
interface ItemPickerProps {
value: number | null;
@@ -9,220 +16,88 @@ interface ItemPickerProps {
itemName?: string;
}
/** Minimal option shape used to display a selected value that isn't in the
* current search results (edit/prefill via `itemName`). */
type ItemOption = Pick<WarehouseItem, "id" | "name"> & Partial<WarehouseItem>;
export default function ItemPicker({
value,
onChange,
itemName,
}: ItemPickerProps) {
const [search, setSearch] = useState(itemName ?? "");
const [open, setOpen] = useState(false);
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
const items = data?.data ?? [];
const [inputValue, setInputValue] = useState(itemName ?? "");
const containerRef = useRef<HTMLDivElement>(null);
const activeIndexRef = useRef<number>(-1);
const listboxId = useId();
const [activeIndex, setActiveIndex] = useState<number>(-1);
const { data, isFetching } = useQuery(
warehouseItemListOptions({ search: inputValue, perPage: 20 }),
);
const options: ItemOption[] = data?.data ?? [];
const [dropdownStyle, setDropdownStyle] = useState<{
position: "fixed";
top: number;
left: number;
width: number;
zIndex: number;
} | null>(null);
const updatePosition = useCallback(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setDropdownStyle({
position: "fixed",
top: rect.bottom + 2,
left: rect.left,
width: rect.width,
zIndex: 100,
// Show the selected value even when it isn't in the current search results
// (e.g. prefilled via `itemName`) by synthesizing a fallback option.
const selectedOption: ItemOption | null =
value == null
? null
: (options.find((o) => o.id === value) ?? {
id: value,
name: itemName ?? "",
});
}, []);
// Reset active index when items change
useEffect(() => {
if (activeIndex >= items.length) {
activeIndexRef.current = -1;
setActiveIndex(-1);
}
}, [items, activeIndex]);
useEffect(() => {
if (open) {
updatePosition();
const onScroll = () => updatePosition();
const onClose = () => setOpen(false);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onClose);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onClose);
};
} else {
setDropdownStyle(null);
activeIndexRef.current = -1;
setActiveIndex(-1);
}
}, [open, updatePosition]);
// Close on click-outside via mousedown on document
useEffect(() => {
if (!open) return;
const onDocMouseDown = (e: MouseEvent) => {
const target = e.target;
if (containerRef.current && target instanceof Node) {
if (!containerRef.current.contains(target)) {
// Don't close if click landed on an option in the portal
const listEl = document.getElementById(listboxId);
if (listEl && listEl.contains(target)) return;
setOpen(false);
}
}
};
document.addEventListener("mousedown", onDocMouseDown);
return () => document.removeEventListener("mousedown", onDocMouseDown);
}, [open, listboxId]);
const handleSelect = (itemId: number, name: string) => {
onChange(itemId);
setOpen(false);
setSearch(name);
};
const setActive = (index: number) => {
activeIndexRef.current = index;
setActiveIndex(index);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) {
setOpen(true);
if (items.length > 0) setActive(0);
return;
}
const next =
activeIndexRef.current < items.length - 1
? activeIndexRef.current + 1
: 0;
setActive(next);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!open) {
setOpen(true);
if (items.length > 0) setActive(items.length - 1);
return;
}
const prev =
activeIndexRef.current > 0
? activeIndexRef.current - 1
: items.length - 1;
setActive(prev);
} else if (e.key === "Enter") {
if (
open &&
activeIndexRef.current >= 0 &&
activeIndexRef.current < items.length
) {
e.preventDefault();
const item = items[activeIndexRef.current];
handleSelect(item.id, item.name);
}
} else if (e.key === "Escape") {
if (open) {
e.preventDefault();
setOpen(false);
}
} else if (e.key === "Home") {
if (open && items.length > 0) {
e.preventDefault();
setActive(0);
}
} else if (e.key === "End") {
if (open && items.length > 0) {
e.preventDefault();
setActive(items.length - 1);
}
} else if (e.key === "Tab") {
setOpen(false);
}
};
const activeId =
activeIndex >= 0 ? `${listboxId}-option-${activeIndex}` : undefined;
return (
<div
ref={containerRef}
style={{ position: "relative" }}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-owns={listboxId}
>
<input
type="text"
className="admin-form-input"
<Autocomplete<ItemOption>
value={selectedOption}
inputValue={inputValue}
onInputChange={(_, newInput) => setInputValue(newInput)}
onChange={(_, opt) => {
if (opt) onChange(opt.id);
}}
options={options}
loading={isFetching}
filterOptions={(x) => x}
getOptionLabel={(o) => o.name}
isOptionEqualToValue={(o, v) => o.id === v.id}
noOptionsText="Žádné položky"
renderOption={(props, option) => {
const { key, ...rest } = props;
return (
<Box component="li" key={key} {...rest}>
<Box>
<Typography variant="body2">{option.name}</Typography>
{(option.item_number ||
option.available_quantity !== undefined) && (
<Typography variant="caption" color="text.secondary">
{option.item_number}
{option.item_number && option.available_quantity !== undefined
? " · "
: ""}
{option.available_quantity !== undefined
? `${option.available_quantity} ${option.unit ?? ""}`
: ""}
</Typography>
)}
</Box>
</Box>
);
}}
renderInput={(params) => (
<TextField
{...params}
size="small"
placeholder="Hledat položku..."
value={search}
role="searchbox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={activeId}
onChange={(e) => {
setSearch(e.target.value);
setOpen(true);
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{isFetching ? (
<CircularProgress color="inherit" size={18} />
) : null}
{params.InputProps.endAdornment}
</>
),
},
}}
onFocus={() => {
setOpen(true);
updatePosition();
}}
onKeyDown={handleKeyDown}
/>
{open &&
items.length > 0 &&
dropdownStyle &&
createPortal(
<ul
id={listboxId}
role="listbox"
className="admin-item-picker-list"
style={dropdownStyle}
>
{items.map((item, index) => (
<li
key={item.id}
id={`${listboxId}-option-${index}`}
role="option"
aria-selected={value === item.id}
className={`admin-item-picker-item ${activeIndex === index ? "active" : ""}`}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(item.id, item.name);
}}
>
<span className="admin-item-picker-name">{item.name}</span>
{item.item_number && (
<span className="admin-item-picker-number">
{item.item_number}
</span>
)}
{item.available_quantity !== undefined && (
<span className="admin-item-picker-qty">
{item.available_quantity} {item.unit}
</span>
)}
</li>
))}
</ul>,
document.body,
)}
</div>
/>
);
}

View File

@@ -1,31 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import {
warehouseLocationListOptions,
type WarehouseLocation,
} from "../../lib/queries/warehouse";
interface LocationSelectProps {
value: number | null;
onChange: (locationId: number | null) => void;
}
export default function LocationSelect({
value,
onChange,
}: LocationSelectProps) {
const { data: locations } = useQuery(warehouseLocationListOptions());
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
>
<option value="">-- bez lokace --</option>
{locations?.map((loc: WarehouseLocation) => (
<option key={loc.id} value={loc.id}>
{loc.code} - {loc.name}
</option>
))}
</select>
);
}

View File

@@ -1,4 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import MenuItem from "@mui/material/MenuItem";
import { Select } from "../../ui";
import { warehouseReservationListOptions } from "../../lib/queries/warehouse";
interface ReservationPickerProps {
@@ -26,11 +28,9 @@ export default function ReservationPicker({
const reservations = result?.data ?? [];
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => {
const val = e.target.value;
<Select
value={value == null ? "" : String(value)}
onChange={(val) => {
if (!val) {
onChange(null, 0);
return;
@@ -42,13 +42,13 @@ export default function ReservationPicker({
}
}}
>
<option value=""> žádná rezervace </option>
<MenuItem value=""> žádná rezervace </MenuItem>
{reservations.map((r) => (
<option key={r.id} value={r.id}>
<MenuItem key={r.id} value={String(r.id)}>
R{r.id} {r.project?.name ?? `Projekt #${r.project_id}`} zbývá:{" "}
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
</option>
</MenuItem>
))}
</select>
</Select>
);
}

View File

@@ -1,33 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import {
warehouseSupplierListOptions,
type WarehouseSupplier,
} from "../../lib/queries/warehouse";
interface SupplierSelectProps {
value: number | null;
onChange: (supplierId: number | null) => void;
}
export default function SupplierSelect({
value,
onChange,
}: SupplierSelectProps) {
const { data } = useQuery(warehouseSupplierListOptions({ perPage: 100 }));
const suppliers = data?.data ?? [];
return (
<select
className="admin-form-select"
value={value ?? ""}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
>
<option value="">-- bez dodavatele --</option>
{suppliers.map((s: WarehouseSupplier) => (
<option key={s.id} value={s.id}>
{s.name}
{s.ico ? ` (${s.ico})` : ""}
</option>
))}
</select>
);
}

View File

@@ -1,249 +0,0 @@
import { ReactNode, useId, useRef } from "react";
import ItemPicker from "./ItemPicker";
import LocationSelect from "./LocationSelect";
import BatchPicker from "./BatchPicker";
export interface MovementItem {
key: string;
item_id: number | null;
item_name?: string;
quantity: number;
unit_price: number;
location_id: number | null;
batch_id: number | null;
reservation_id: number | null;
notes: string | null;
}
export type MovementRowRenderer<T> = (
item: T,
index: number,
updateItem: (field: string, value: unknown) => void,
removeItem: () => void,
) => ReactNode;
interface WarehouseMovementTableProps<T extends { key: string }> {
items: T[];
onChange: (items: T[]) => void;
mode: "receipt" | "issue" | "inventory";
defaultItem?: () => Omit<T, "key">;
renderRow?: MovementRowRenderer<T>;
renderHeader?: () => ReactNode;
}
function parseDecimal(raw: string): number {
// Strip leading minus if you want negative support; here we want only positive
const cleaned = raw.replace(/[eE+\-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
const builtInDefaultMovementItem = (): Omit<MovementItem, "key"> => ({
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
});
export default function WarehouseMovementTable<T extends { key: string }>({
items,
onChange,
mode,
defaultItem,
renderRow,
renderHeader,
}: WarehouseMovementTableProps<T>) {
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const addItem = () => {
const factory =
defaultItem ??
(builtInDefaultMovementItem as unknown as () => Omit<T, "key">);
onChange([...items, { ...(factory() as object), key: nextKey() } as T]);
};
const removeItem = (index: number) => {
onChange(items.filter((_, i) => i !== index));
};
const updateItem = (index: number, field: string, value: unknown) => {
const updated = [...items];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
// If a custom row renderer is supplied, the caller is fully in charge of
// the row layout (and typically the column headers too — see inventory mode).
if (renderRow) {
return (
<div>
<div className="admin-warehouse-movement-table">
<table className="admin-table">
{renderHeader && (
<thead>
<tr>{renderHeader()}</tr>
</thead>
)}
<tbody>
{items.map((item, index) => (
<tr key={item.key}>
{renderRow(
item,
index,
(field, value) => updateItem(index, field, value),
() => removeItem(index),
)}
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={addItem}
>
+ Přidat řádek
</button>
</div>
);
}
// Default rendering: only valid for "receipt" | "issue" — narrow at runtime.
const movementItems = items as unknown as MovementItem[];
return (
<div>
<div className="admin-warehouse-movement-table">
<table className="admin-table">
<thead>
<tr>
<th className="admin-warehouse-col-item">Položka</th>
{mode === "issue" && (
<th className="admin-warehouse-col-batch">Šarže</th>
)}
<th className="admin-warehouse-col-qty">Množství</th>
<th className="admin-warehouse-col-price">Cena/ks</th>
<th className="admin-warehouse-col-location">Lokace</th>
<th className="admin-warehouse-col-notes">Poznámka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{movementItems.map((item, index) => (
<tr key={item.key}>
<td className="admin-warehouse-col-item">
<ItemPicker
value={item.item_id}
itemName={item.item_name}
onChange={(id) => updateItem(index, "item_id", id)}
/>
</td>
{mode === "issue" && (
<td className="admin-warehouse-col-batch">
<BatchPicker
itemId={item.item_id}
value={item.batch_id}
onChange={(batchId, unitPrice) => {
updateItem(index, "batch_id", batchId);
updateItem(index, "unit_price", unitPrice);
}}
/>
</td>
)}
<td className="admin-warehouse-col-qty">
<input
type="number"
className="admin-form-input"
value={item.quantity || ""}
onChange={(e) =>
updateItem(
index,
"quantity",
parseDecimal(e.target.value),
)
}
min="0"
step="0.001"
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Množství, řádek ${index + 1}`}
/>
</td>
<td className="admin-warehouse-col-price">
<input
type="number"
className="admin-form-input"
value={item.unit_price || ""}
onChange={(e) =>
updateItem(
index,
"unit_price",
parseDecimal(e.target.value),
)
}
min="0"
step="0.01"
disabled={mode === "issue"}
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Cena za kus, řádek ${index + 1}`}
/>
</td>
<td className="admin-warehouse-col-location">
<LocationSelect
value={item.location_id}
onChange={(id) => updateItem(index, "location_id", id)}
/>
</td>
<td className="admin-warehouse-col-notes">
<input
type="text"
className="admin-form-input"
value={item.notes ?? ""}
onChange={(e) => updateItem(index, "notes", e.target.value)}
/>
</td>
<td>
<div className="admin-table-actions">
<button
type="button"
className="admin-btn-icon danger"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={addItem}
>
+ Přidat řádek
</button>
</div>
);
}

View File

@@ -1,525 +0,0 @@
/* ============================================================================
Dashboard
============================================================================ */
/* Dashboard layout */
.dash {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.dash .admin-page-header {
margin-bottom: 0;
}
/* Quick actions */
.dash-quick-actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.625rem;
}
.dash-quick-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border: none;
border-radius: var(--border-radius-sm);
font-size: 0.8125rem;
font-weight: 600;
font-family: inherit;
text-decoration: none;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.dash-quick-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none !important;
}
.dash-quick-btn-success {
background: var(--success-soft);
color: var(--success);
}
.dash-quick-btn-info {
background: var(--info-soft);
color: var(--info);
}
.dash-quick-btn-warning {
background: var(--warning-soft);
color: var(--warning);
}
.dash-quick-btn-danger {
background: var(--danger-soft);
color: var(--danger);
}
.dash-quick-btn:hover {
transform: translateY(-1px);
filter: brightness(0.95);
}
[data-theme="light"] .dash-quick-btn-success {
background: var(--success);
color: #fff;
}
[data-theme="light"] .dash-quick-btn-info {
background: var(--info);
color: #fff;
}
[data-theme="light"] .dash-quick-btn-warning {
background: var(--warning);
color: #fff;
}
[data-theme="light"] .dash-quick-btn-danger {
background: var(--danger);
color: #fff;
}
/* Main content 3-col grid */
.dash-main-grid {
display: grid;
grid-template-columns: 1.4fr 1fr 1fr;
gap: 1rem;
}
/* Card link */
.dash-card-link {
font-size: 0.75rem;
color: var(--accent-color);
font-weight: 600;
text-decoration: none;
transition: opacity 0.15s;
}
.dash-card-link:hover {
opacity: 0.8;
}
/* Activity rows */
.dash-activity-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
transition: background 0.1s;
}
.dash-activity-row:last-child {
border-bottom: none;
}
.dash-activity-row:hover {
background: var(--bg-tertiary);
}
.dash-activity-icon {
width: 34px;
height: 34px;
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dash-activity-icon.success {
background: var(--success-soft);
color: var(--success);
}
.dash-activity-icon.info {
background: var(--info-soft);
color: var(--info);
}
.dash-activity-icon.warning {
background: var(--warning-soft);
color: var(--warning);
}
.dash-activity-icon.danger {
background: var(--danger-soft);
color: var(--danger);
}
.dash-activity-icon.accent {
background: var(--accent-soft);
color: var(--accent-color);
}
.dash-activity-icon.muted {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.dash-activity-main {
flex: 1;
min-width: 0;
}
.dash-activity-text {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dash-activity-sub {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
}
.dash-activity-time {
font-size: 0.6875rem;
color: var(--text-muted);
flex-shrink: 0;
}
/* Presence rows */
.dash-presence-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid var(--border-color);
}
.dash-presence-row:last-child {
border-bottom: none;
}
.dash-presence-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6875rem;
font-weight: 700;
flex-shrink: 0;
text-transform: uppercase;
}
.dash-presence-avatar.dash-status-in {
background: var(--success-soft);
color: var(--success);
}
.dash-presence-avatar.dash-status-away {
background: var(--warning-soft);
color: var(--warning);
}
.dash-presence-avatar.dash-status-out {
background: var(--bg-tertiary);
color: var(--text-muted);
}
.dash-presence-avatar.dash-status-leave {
background: var(--info-soft);
color: var(--info);
}
.dash-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dash-status-dot.dash-status-in {
background: var(--success);
}
.dash-status-dot.dash-status-away {
background: var(--warning);
}
.dash-status-dot.dash-status-out {
background: var(--text-muted);
}
.dash-status-dot.dash-status-leave {
background: var(--info);
}
.dash-presence-label.dash-status-in {
color: var(--success);
}
.dash-presence-label.dash-status-away {
color: var(--warning);
}
.dash-presence-label.dash-status-out {
color: var(--text-muted);
}
.dash-presence-label.dash-status-leave {
color: var(--info);
}
.dash-presence-name {
flex: 1;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
}
.dash-presence-end {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.dash-presence-label {
font-size: 0.75rem;
font-weight: 500;
}
.dash-presence-time {
font-size: 0.6875rem;
color: var(--text-muted);
}
/* Right column */
.dash-right-col {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Project rows */
.dash-project-row {
display: block;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border-color);
text-decoration: none;
transition: background 0.1s;
}
.dash-project-row:last-child {
border-bottom: none;
}
.dash-project-row:hover {
background: var(--bg-tertiary);
}
.dash-project-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
}
.dash-project-customer {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
}
/* Stat mini rows */
.dash-stat-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid var(--border-color);
font-size: 0.8125rem;
color: var(--text-secondary);
}
.dash-stat-row:last-child {
border-bottom: none;
}
/* Empty row */
.dash-empty-row {
padding: 1.5rem;
text-align: center;
color: var(--text-muted);
font-size: 0.8125rem;
}
/* Bottom stacked layout (profile + sessions) */
.dash-bottom {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.dash .admin-card {
margin-bottom: 0;
}
/* Profile grid inside account card */
.dash-profile-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1.5rem;
}
.dash-profile-item {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.dash-profile-label {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
}
.dash-profile-value {
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 500;
}
/* ============================================================================
Responsive
============================================================================ */
@media (max-width: 1024px) {
.dash-main-grid {
grid-template-columns: 1fr 1fr;
}
.dash-right-col {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 768px) {
.dash-quick-actions {
grid-template-columns: repeat(2, 1fr);
}
.dash-main-grid {
grid-template-columns: 1fr;
}
.dash-right-col {
grid-template-columns: 1fr;
}
.dash-bottom {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.dash-quick-actions {
grid-template-columns: 1fr 1fr;
}
.dash-profile-grid {
grid-template-columns: 1fr;
}
}
/* ============================================================================
Sessions / Devices
============================================================================ */
.dash-sessions-list {
display: flex;
flex-direction: column;
}
.dash-session-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
transition: var(--transition);
}
.dash-session-item:last-child {
border-bottom: none;
}
.dash-session-item:hover {
background: var(--bg-tertiary);
}
.dash-session-item-current {
background: var(--row-current);
}
.dash-session-item-current:hover {
background: var(--row-current-hover);
}
.dash-session-icon {
width: 40px;
height: 40px;
border-radius: var(--border-radius-sm);
background: var(--bg-tertiary);
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.dash-session-item-current .dash-session-icon {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.dash-session-info {
flex: 1;
min-width: 0;
}
.dash-session-device {
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
}
.dash-session-meta {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 0.25rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.dash-session-meta-separator {
color: var(--border-color);
}
.dash-session-actions {
flex-shrink: 0;
}
@media (max-width: 640px) {
.dash-session-item {
padding: 1rem;
gap: 0.75rem;
}
.dash-session-icon {
width: 36px;
height: 36px;
}
.dash-session-device {
font-size: 0.875rem;
}
.dash-session-meta {
font-size: 0.75rem;
}
}

View File

@@ -1,211 +0,0 @@
/* ============================================================================
React DatePicker Overrides
============================================================================ */
.react-datepicker-wrapper {
width: 100%;
}
.react-datepicker-popper {
z-index: 100 !important;
}
/* Prevent flash at top-left before popper calculates position */
#datepicker-portal .react-datepicker-popper {
opacity: 0;
animation: dp-fade-in 0.01s forwards 0.02s;
}
@keyframes dp-fade-in {
to {
opacity: 1;
}
}
.react-datepicker {
font-family: inherit !important;
background-color: var(--bg-secondary) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--border-radius-sm) !important;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25) !important;
color: var(--text-primary) !important;
font-size: 0.875rem !important;
}
.react-datepicker__triangle {
display: none !important;
}
/* Header */
.react-datepicker__header {
background-color: var(--bg-tertiary) !important;
border-bottom: 1px solid var(--border-color) !important;
padding-top: 0.75rem !important;
}
.react-datepicker__current-month,
.react-datepicker-time__header {
color: var(--text-primary) !important;
font-weight: 600 !important;
}
.react-datepicker__day-name {
color: var(--text-secondary) !important;
}
/* Days */
.react-datepicker__day {
color: var(--text-primary) !important;
border-radius: 6px !important;
transition:
background 0.15s,
color 0.15s !important;
}
.react-datepicker__day:hover {
background-color: var(--accent-light) !important;
color: var(--text-primary) !important;
}
.react-datepicker__day--selected,
.react-datepicker__day--keyboard-selected {
background-color: var(--accent-color) !important;
color: #fff !important;
}
.react-datepicker__day--today {
font-weight: 700 !important;
}
.react-datepicker__day--outside-month {
color: var(--text-muted) !important;
opacity: 0.5;
}
.react-datepicker__day--disabled {
color: var(--text-muted) !important;
opacity: 0.3 !important;
}
/* Navigation arrows */
.react-datepicker__navigation {
top: 0.75rem !important;
}
.react-datepicker__navigation-icon::before {
border-color: var(--text-secondary) !important;
}
.react-datepicker__navigation:hover *::before {
border-color: var(--accent-color) !important;
}
/* Year dropdown */
.react-datepicker__year-dropdown,
.react-datepicker__month-dropdown,
.react-datepicker__year-read-view,
.react-datepicker__month-read-view {
color: var(--text-primary) !important;
}
/* Time picker */
.react-datepicker__time-container {
border-left: 1px solid var(--border-color) !important;
}
.react-datepicker__time-container .react-datepicker__time {
background-color: var(--bg-secondary) !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box {
width: 100% !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box
ul.react-datepicker__time-list
li.react-datepicker__time-list-item {
color: var(--text-primary) !important;
transition: background 0.15s !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box
ul.react-datepicker__time-list
li.react-datepicker__time-list-item:hover {
background-color: var(--accent-light) !important;
color: var(--text-primary) !important;
}
.react-datepicker__time-container
.react-datepicker__time
.react-datepicker__time-box
ul.react-datepicker__time-list
li.react-datepicker__time-list-item--selected {
background-color: var(--accent-color) !important;
color: #fff !important;
font-weight: 600 !important;
}
/* Month picker */
.react-datepicker__monthPicker {
background-color: var(--bg-secondary) !important;
}
.react-datepicker-year-header {
background-color: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
border-bottom: 1px solid var(--border-color) !important;
}
.react-datepicker__month-wrapper {
background-color: var(--bg-secondary) !important;
}
.react-datepicker__month-text {
color: var(--text-primary) !important;
padding: 0.5rem !important;
border-radius: 6px !important;
transition: background 0.15s !important;
background-color: transparent !important;
}
.react-datepicker__month-text:hover {
background-color: var(--accent-light) !important;
color: var(--text-primary) !important;
}
.react-datepicker__month-text--keyboard-selected,
.react-datepicker__month-text--selected {
background-color: var(--accent-color) !important;
color: #fff !important;
}
.react-datepicker__month-text--today {
font-weight: 700 !important;
}
/* Input */
.react-datepicker__input-container input {
cursor: pointer;
}
.react-datepicker__close-icon::after {
background-color: var(--accent-color) !important;
}
/* Mobile safety net — keep the popper inside the viewport on small phones.
(Native date input is used on touch; this guards the rare desktop-style popper.) */
@media (max-width: 480px) {
.react-datepicker-popper {
max-width: calc(100vw - 24px);
}
.react-datepicker {
font-size: 0.8rem !important;
}
}

View File

@@ -1,199 +0,0 @@
/* ============================================================================
File Manager
============================================================================ */
.fm-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.fm-full-path {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-tertiary);
user-select: all;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.fm-toolbar-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.fm-breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
font-size: 12px;
min-height: 28px;
}
.fm-breadcrumb-segment {
display: inline-flex;
align-items: center;
}
.fm-breadcrumb-sep {
color: var(--text-tertiary);
margin: 0 4px;
user-select: none;
}
.fm-breadcrumb-btn {
background: none;
border: none;
padding: 2px 6px;
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-mono);
font-size: 12px;
transition: all 0.15s ease;
}
.fm-breadcrumb-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.fm-breadcrumb-btn.active {
color: var(--text-primary);
font-weight: 600;
}
.fm-new-folder {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.fm-new-folder .admin-form-input {
max-width: 250px;
}
.fm-content {
position: relative;
border-radius: var(--border-radius-sm);
transition: border-color 0.2s ease;
}
.fm-content.fm-drag-over {
border: 2px dashed var(--accent-color);
background: var(--accent-light);
}
.fm-dropzone-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: color-mix(in srgb, var(--bg-primary) 90%, transparent);
border-radius: var(--border-radius-sm);
z-index: 5;
color: var(--accent-color);
font-size: 13px;
font-weight: 500;
pointer-events: none;
}
.fm-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2.5rem 1rem;
color: var(--text-tertiary);
font-size: 13px;
}
.fm-folder-link {
background: none;
border: none;
padding: 0;
color: var(--accent-color);
font-weight: 500;
font-size: inherit;
font-family: inherit;
cursor: pointer;
}
.fm-folder-link:hover {
text-decoration: underline;
}
.fm-item-count {
font-size: 10px;
color: var(--text-tertiary);
font-weight: 400;
}
.fm-file-name {
color: var(--text-primary);
}
.fm-meta {
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
}
.fm-actions {
display: inline-flex;
gap: 2px;
justify-content: flex-end;
}
.fm-name-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.fm-symlink-badge {
display: inline-flex;
align-items: center;
color: var(--text-tertiary);
cursor: help;
}
/* ============================================================================
File Manager — mobile
============================================================================ */
@media (max-width: 640px) {
/* Toolbar wraps so actions drop below the path instead of overflowing */
.fm-toolbar {
flex-wrap: wrap;
}
/* New-folder input goes full width (override the 250px desktop cap) */
.fm-new-folder .admin-form-input {
max-width: none;
width: 100%;
}
/* Breadcrumb scrolls horizontally instead of overflowing the container */
.fm-breadcrumb {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.fm-breadcrumb-segment {
flex-shrink: 0;
}
}

View File

@@ -1,510 +0,0 @@
/* ============================================================================
Forms
============================================================================ */
.admin-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.admin-form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.admin-form-input {
width: 100%;
padding: 9px 12px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
transition:
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-sizing: border-box;
min-height: 36px;
}
.admin-form-input:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--accent-light);
}
.admin-form-input::placeholder {
color: var(--text-muted);
}
.admin-form-input[type="date"],
.admin-form-input[type="time"],
.admin-form-input[type="month"],
.admin-form-input[type="number"] {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
text-align: left;
height: 36px;
max-width: 100%;
}
.admin-form-input[type="number"]::-webkit-inner-spin-button,
.admin-form-input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.admin-form-input[type="date"]::-webkit-date-and-time-value,
.admin-form-input[type="time"]::-webkit-date-and-time-value,
.admin-form-input[type="month"]::-webkit-date-and-time-value {
text-align: left;
margin: 0;
}
.admin-form-input[type="date"]::-webkit-datetime-edit,
.admin-form-input[type="time"]::-webkit-datetime-edit,
.admin-form-input[type="month"]::-webkit-datetime-edit {
padding: 0;
}
.admin-form-input[type="date"]::-webkit-calendar-picker-indicator,
.admin-form-input[type="time"]::-webkit-calendar-picker-indicator,
.admin-form-input[type="month"]::-webkit-calendar-picker-indicator {
filter: var(--calendar-icon-filter, none);
cursor: pointer;
}
/* Select */
.admin-form-select {
width: 100%;
padding: 9px 12px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
transition:
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
min-height: 36px;
box-sizing: border-box;
cursor: pointer;
appearance: none;
background-image: var(--select-arrow);
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 32px;
}
.admin-form-select:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--accent-light);
}
.admin-form-select option {
background: var(--bg-secondary);
color: var(--text-primary);
}
/* Textarea */
.admin-form-textarea {
width: 100%;
padding: 9px 12px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
transition:
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
resize: vertical;
box-sizing: border-box;
min-height: 80px;
}
.admin-form-textarea:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--accent-light);
}
/* Checkbox */
.admin-form-checkbox {
display: inline-flex;
align-items: flex-start;
gap: 0;
cursor: pointer;
user-select: none;
}
.admin-form-checkbox input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.admin-form-checkbox input + span::before {
content: "";
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
margin-right: 8px;
background: var(--input-bg);
border: 1px solid var(--border-color);
border-radius: 4px;
vertical-align: middle;
transition:
border-color var(--transition),
box-shadow var(--transition),
background var(--transition);
flex-shrink: 0;
}
.admin-form-checkbox input:checked + span::before {
background: var(--accent-color);
border-color: var(--accent-color);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
background-size: 12px;
background-position: center;
background-repeat: no-repeat;
}
.admin-form-checkbox input:focus + span::before {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--accent-light);
}
.admin-form-checkbox:hover
input:not(:checked):not(:disabled):not(:indeterminate)
+ span::before {
border-color: var(--border-color-hover);
background: var(--bg-secondary);
}
.admin-form-checkbox input:indeterminate + span::before {
background: var(--accent-color);
border-color: var(--accent-color);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round'%3E%3Cline x1='6' y1='12' x2='18' y2='12'%3E%3C/line%3E%3C/svg%3E");
background-size: 12px;
background-position: center;
background-repeat: no-repeat;
}
.admin-form-checkbox input:disabled + span::before {
opacity: 0.5;
cursor: not-allowed;
}
.admin-form-checkbox:has(input:disabled) {
cursor: not-allowed;
opacity: 0.7;
}
.admin-form-checkbox span {
display: flex;
align-items: center;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
/* Reorderable List */
.admin-reorder-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-reorder-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
background: var(--bg-tertiary);
border-radius: var(--border-radius-sm);
}
.admin-reorder-arrows {
display: flex;
gap: 2px;
}
.admin-reorder-label {
font-size: 13px;
color: var(--text-primary);
}
.admin-reorder-label.accent {
color: var(--accent-color);
}
.admin-reorder-arrows .admin-btn-icon {
width: 22px;
height: 22px;
color: var(--text-muted);
}
.admin-reorder-arrows .admin-btn-icon:hover:not(:disabled) {
background: var(--bg-primary);
color: var(--text-primary);
}
.admin-reorder-arrows .admin-btn-icon:disabled {
opacity: 0.25;
}
/* Form Rows (Grid Layouts) */
.admin-form-row {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, 1fr);
}
.admin-form-row-3 {
grid-template-columns: repeat(3, 1fr);
}
.admin-form-row-4 {
grid-template-columns: repeat(4, 1fr);
}
.admin-form-row-5 {
grid-template-columns: 1.2fr 1fr 1fr 1fr 1fr;
}
@media (max-width: 768px) {
.admin-form-row-4 {
grid-template-columns: repeat(2, 1fr);
}
.admin-form-row-5 {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 640px) {
.admin-form-row,
.admin-form-row-3 {
grid-template-columns: 1fr;
}
.admin-form-row-5 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.admin-form-row,
.admin-form-row-3,
.admin-form-row-4,
.admin-form-row-5 {
grid-template-columns: 1fr;
}
}
/* Form Utilities */
.admin-form-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
/* Required field indicator */
.admin-form-label.required::after {
content: " *";
color: var(--danger);
font-weight: 600;
}
/* Inline field errors */
.admin-form-group.has-error .admin-form-input,
.admin-form-group.has-error .admin-form-select,
.admin-form-group.has-error .admin-form-textarea {
border-color: var(--danger);
box-shadow: 0 0 0 3px var(--danger-light);
}
.admin-form-group.has-error .admin-form-label {
color: var(--danger);
}
.admin-form-error {
font-size: 0.75rem;
color: var(--danger);
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.admin-form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
/* Touch targets - min 44px on mobile */
@media (max-width: 768px) {
.admin-form-input,
.admin-form-select,
.admin-form-textarea {
min-height: 44px;
font-size: 16px; /* prevent auto-zoom on iOS */
}
.admin-form-checkbox {
min-height: 44px;
padding: 8px 0;
}
.admin-form-checkbox input + span::before {
width: 20px;
height: 20px;
}
.admin-form-label {
font-size: 13px;
}
}
/* ============================================================================
Customer Selector
============================================================================ */
.admin-customer-select {
position: relative;
}
.admin-customer-selected {
display: flex;
align-items: center;
gap: 0.5rem;
height: 36px;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--input-bg);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.admin-customer-selected span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-customer-selected .admin-btn-icon {
flex-shrink: 0;
width: 22px;
height: 22px;
margin-right: -4px;
}
.admin-customer-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 100;
max-height: 260px;
overflow-y: auto;
overscroll-behavior: contain;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-top: none;
border-radius: 0 0 var(--border-radius-sm) var(--border-radius-sm);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 4px 0;
}
.admin-customer-dropdown::-webkit-scrollbar {
width: 5px;
}
.admin-customer-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.admin-customer-dropdown::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 99px;
}
.admin-customer-dropdown::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.admin-customer-dropdown-item {
padding: 8px 12px;
cursor: pointer;
transition: background var(--transition);
border-radius: 4px;
margin: 0 4px;
}
.admin-customer-dropdown-item:hover {
background: var(--bg-secondary);
}
.admin-customer-dropdown-item div:first-child {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.3;
}
.admin-customer-dropdown-item div:last-child {
font-size: 11.5px;
color: var(--text-tertiary);
margin-top: 1px;
}
.admin-customer-dropdown-empty {
padding: 0.75rem;
text-align: center;
color: var(--text-tertiary);
font-size: 0.8125rem;
}
@media (max-width: 640px) {
.admin-customer-dropdown {
max-width: calc(100vw - 24px);
overflow-y: auto;
}
.admin-customer-dropdown-item {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 44px;
}
.admin-customer-selected .admin-btn-icon {
min-width: 44px;
min-height: 44px;
margin-right: -10px;
}
}

View File

@@ -1,150 +0,0 @@
/* ============================================================================
Invoice Status Badges
============================================================================ */
.admin-badge-invoice-issued {
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
}
.admin-badge-invoice-paid {
background: color-mix(in srgb, var(--success) 15%, transparent);
color: var(--success);
}
.admin-badge-invoice-overdue {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
/* ============================================================================
Invoice Month Navigation
============================================================================ */
.invoice-month-nav {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
margin-bottom: 0.875rem;
}
.invoice-month-nav span {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
min-width: 120px;
text-align: center;
}
.invoice-month-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition:
border-color 0.15s,
color 0.15s;
}
.invoice-month-btn:hover:not(:disabled) {
border-color: var(--accent-color);
color: var(--accent-color);
}
.invoice-month-btn:disabled {
opacity: 0.3;
cursor: default;
}
/* ============================================================================
Received Invoices - Upload Modal
============================================================================ */
.received-upload-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.received-upload-card {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
overflow: hidden;
}
.received-upload-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.received-upload-file-info {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
color: var(--text-secondary);
}
.received-upload-file-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.received-upload-file-size {
font-size: 0.75rem;
color: var(--text-tertiary);
white-space: nowrap;
}
.received-upload-card-fields {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.received-upload-row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
@media (max-width: 640px) {
/* Month-nav row may wrap so it never forces horizontal page overflow */
.invoice-month-nav {
flex-wrap: wrap;
}
/* 44px touch target for the prev/next month buttons */
.invoice-month-btn {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
}
.received-upload-row {
flex-direction: column;
}
.received-upload-file-name {
max-width: 200px;
}
}

View File

@@ -1,582 +0,0 @@
/* ============================================================================
Layout
============================================================================ */
.admin-layout {
display: flex;
min-height: 100vh;
min-height: 100dvh;
background: var(--bg-primary);
}
@media (min-width: 1024px) {
.admin-layout {
align-items: flex-start;
}
}
/* ============================================================================
Sidebar
============================================================================ */
/* -- Theme variables for sidebar -- */
[data-theme="dark"] .admin-sidebar {
--sb-bg: #141414;
--sb-border: #2a2a2a;
--sb-text: #a0a0a0;
--sb-text-hover: #ddd;
--sb-hover-bg: #1f1f1f;
--sb-active-bg: #ffffff;
--sb-active-text: #141414;
--sb-label: #444;
--sb-muted: #555;
--sb-scrollbar: #333;
}
[data-theme="light"] .admin-sidebar {
--sb-bg: #ffffff;
--sb-border: #e8e6e1;
--sb-text: #7c7c84;
--sb-text-hover: #1a1a1a;
--sb-hover-bg: #f5f4f2;
--sb-active-bg: #141414;
--sb-active-text: #ffffff;
--sb-label: #a0a0a0;
--sb-muted: #a0a0a0;
--sb-scrollbar: #ddd;
}
.admin-sidebar {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100vh;
height: 100dvh;
z-index: 50;
background: var(--sb-bg);
border-right: 1px solid var(--sb-border);
display: flex;
flex-direction: column;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
transform: translateX(-100%);
visibility: hidden;
transition:
transform 0.3s ease,
visibility 0.3s ease;
overflow: hidden;
overscroll-behavior: none;
}
.admin-sidebar.open {
transform: translateX(0);
visibility: visible;
touch-action: none;
}
@media (min-width: 1024px) {
.admin-sidebar {
right: auto;
width: 220px;
height: 100%;
transform: none;
visibility: visible;
padding: 0;
}
}
[data-theme="light"] .admin-sidebar {
box-shadow:
1px 0 0 0 var(--sb-border),
4px 0 16px rgba(0, 0, 0, 0.04);
}
/* Sidebar Overlay (mobile) */
.admin-sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 49;
backdrop-filter: blur(2px);
}
.admin-sidebar-overlay.open {
display: block;
}
@media (min-width: 1024px) {
.admin-sidebar-overlay {
display: none !important;
}
}
/* Sidebar Header */
.admin-sidebar-header {
padding: 0 18px;
height: 73px;
border-bottom: 1px solid var(--sb-border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.admin-sidebar-logo {
height: 28px;
width: auto;
}
.admin-sidebar-close {
display: block;
padding: 0.5rem;
background: transparent;
border: none;
color: var(--sb-text);
cursor: pointer;
border-radius: 6px;
}
.admin-sidebar-close:hover {
background: var(--sb-hover-bg);
color: var(--sb-text-hover);
}
@media (min-width: 1024px) {
.admin-sidebar-close {
display: none;
}
}
/* Sidebar Navigation */
.admin-sidebar-nav {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 4px 0;
}
.admin-sidebar-nav::-webkit-scrollbar {
width: 5px;
}
.admin-sidebar-nav::-webkit-scrollbar-track {
background: transparent;
}
.admin-sidebar-nav::-webkit-scrollbar-thumb {
background: var(--sb-scrollbar);
border-radius: 99px;
}
/* Nav Section */
.admin-nav-section {
padding: 14px 10px 6px;
}
.admin-nav-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--sb-label);
padding: 0 8px;
margin-bottom: 4px;
}
/* Nav Item */
.admin-nav-item {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
border-radius: 7px;
color: var(--sb-text);
cursor: pointer;
transition: all 0.15s ease;
font-size: 13px;
font-weight: 450;
margin-bottom: 1px;
text-decoration: none;
user-select: none;
}
.admin-nav-item:hover {
background: var(--sb-hover-bg);
color: var(--sb-text-hover);
}
.admin-nav-item.active {
background: var(--sb-active-bg);
color: var(--sb-active-text);
font-weight: 600;
}
.admin-nav-item.active svg {
color: var(--accent-color);
}
.admin-nav-item svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
@media (max-width: 1023px) {
.admin-nav-item {
padding: 10px 12px;
font-size: 15px;
gap: 10px;
}
.admin-nav-item svg {
width: 18px;
height: 18px;
}
}
/* Sidebar Footer */
.admin-sidebar-footer {
margin-top: auto;
border-top: 1px solid var(--sb-border);
padding: 14px 10px;
flex-shrink: 0;
}
.admin-user-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 8px;
margin-bottom: 4px;
}
.admin-user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent-color);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
flex-shrink: 0;
}
.admin-user-details {
flex: 1;
min-width: 0;
}
.admin-user-name {
color: var(--sb-text-hover);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-user-role {
color: var(--sb-muted);
font-size: 11px;
}
.admin-logout-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
background: transparent;
border: none;
color: var(--sb-text);
cursor: pointer;
border-radius: 7px;
font-size: 12px;
font-family: inherit;
transition: all 0.15s ease;
}
.admin-logout-btn:hover {
background: var(--sb-hover-bg);
color: var(--sb-text-hover);
}
.admin-logout-btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
@media (max-width: 480px) {
.admin-sidebar-footer {
padding: 10px 8px;
}
.admin-user-chip {
padding: 6px;
}
.admin-logout-btn {
padding: 8px;
font-size: 13px;
}
}
/* ============================================================================
Main Content Area
============================================================================ */
.admin-main {
flex: 1;
min-width: 0;
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
@media (min-width: 1024px) {
.admin-main {
margin-left: 220px;
}
}
/* Header */
.admin-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 30;
height: calc(73px + env(safe-area-inset-top, 0px));
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
padding-top: env(safe-area-inset-top, 0px);
}
@media (min-width: 1024px) {
.admin-header {
left: 220px;
padding: 0 1.5rem;
}
}
.admin-menu-btn {
display: block;
padding: 0.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--border-radius-sm);
}
.admin-menu-btn:hover {
background: var(--bg-tertiary);
}
@media (min-width: 1024px) {
.admin-menu-btn {
display: none;
}
}
.admin-header-theme-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
border-radius: 50%;
transition: var(--transition);
position: relative;
overflow: hidden;
}
.admin-header-theme-btn:hover {
background: var(--bg-tertiary);
border-color: var(--border-color-hover);
transform: scale(1.05);
}
.admin-theme-icon {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
transition: all 0.3s ease;
opacity: 0;
transform: rotate(180deg) scale(0.5);
}
.admin-theme-icon.visible {
opacity: 1;
transform: rotate(0) scale(1);
}
/* Content */
.admin-content {
flex: 1;
padding: 1rem;
padding-top: calc(73px + 1rem + env(safe-area-inset-top, 0px));
}
@media (min-width: 1024px) {
.admin-content {
padding: 28px 32px;
padding-top: calc(73px + 28px);
}
}
/* ============================================================================
Page Headers
============================================================================ */
.admin-page-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
@media (min-width: 640px) {
.admin-page-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.admin-page-title {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-heading);
margin-bottom: 0.25rem;
}
.admin-page-subtitle {
color: var(--text-secondary);
font-size: 13px;
}
.admin-page-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
@media (max-width: 640px) {
.admin-page-actions {
width: 100%;
}
.admin-page-actions .admin-btn {
flex: 1;
}
}
/* ============================================================================
Grid System
============================================================================ */
.admin-grid {
display: grid;
gap: 1rem;
}
.admin-grid > .admin-card {
margin-bottom: 0;
}
.admin-grid-3 {
grid-template-columns: 1fr;
}
@media (min-width: 640px) {
.admin-grid-3 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.admin-grid-3 {
grid-template-columns: repeat(3, 1fr);
}
}
.admin-grid-4 {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 768px) {
.admin-grid-4 {
grid-template-columns: repeat(4, 1fr);
}
}
/* Page header on mobile */
@media (max-width: 480px) {
.admin-page-title {
font-size: 18px;
}
.admin-page-subtitle {
font-size: 12px;
}
.admin-content {
padding: 12px !important;
padding-top: calc(73px + 12px + env(safe-area-inset-top, 0px)) !important;
}
}
/* Grid - single column on small mobile */
@media (max-width: 480px) {
.admin-grid-4 {
grid-template-columns: 1fr;
}
}
/* ============================================================================
Settings Grid
============================================================================ */
.admin-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.admin-settings-grid > .admin-card {
margin-bottom: 0;
}
@media (max-width: 900px) {
.admin-settings-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,143 +0,0 @@
/* ============================================================================
Login Page
============================================================================ */
.admin-login {
min-height: 100vh;
min-height: 100dvh;
max-height: 100vh;
max-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: var(--bg-primary);
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.admin-login .bg-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
}
.admin-login .bg-orb-1 {
width: 400px;
height: 400px;
background: var(--orb-color-1);
top: 10%;
left: 20%;
animation: float 20s ease-in-out infinite;
}
.admin-login .bg-orb-2 {
width: 320px;
height: 320px;
background: var(--orb-color-2);
bottom: 20%;
right: 15%;
animation: float 25s ease-in-out infinite reverse;
}
.admin-login-theme-btn {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
background: var(--glass-bg);
backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
color: var(--text-secondary);
cursor: pointer;
border-radius: 50%;
overflow: hidden;
transition: var(--transition);
z-index: 10;
}
.admin-login-theme-btn:hover {
background: var(--glass-bg-solid);
color: var(--text-primary);
border-color: var(--border-color-hover);
transform: scale(1.05);
}
.admin-login-card {
width: 100%;
max-width: 420px;
max-height: calc(100vh - 2rem);
max-height: calc(100dvh - 2rem);
padding: 2rem;
background: var(--glass-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--border-radius-lg);
box-shadow: var(--glass-shadow);
position: relative;
z-index: 1;
overflow-y: auto;
}
@media (min-width: 640px) {
.admin-login-card {
padding: 2.5rem;
}
}
.admin-login-header {
text-align: center;
margin-bottom: 2rem;
}
.admin-login-logo {
height: 48px;
width: auto;
margin-bottom: 1rem;
}
.admin-login-2fa-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--accent-light);
color: var(--accent-color);
margin-bottom: 1rem;
}
.admin-login-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-heading);
margin-bottom: 0.5rem;
}
.admin-login-subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
}
.admin-back-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 0.875rem;
transition: var(--transition);
margin-top: 1.5rem;
display: inline-block;
}
.admin-back-link:hover {
color: var(--text-primary);
text-decoration: underline;
}

View File

@@ -1,641 +0,0 @@
/* ============================================
Offers Module
============================================ */
/* Items table */
.offers-items-table {
overflow-x: auto;
overflow-y: hidden;
margin-bottom: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
}
.offers-items-table .admin-table {
min-width: 700px;
margin: 0;
}
.offers-items-table .admin-table thead th {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
padding: 8px 10px;
white-space: nowrap;
}
.offers-items-table .admin-table td {
vertical-align: top;
padding: 8px;
}
.offers-items-table .admin-table tbody tr {
transition: background var(--transition);
}
.offers-items-table .admin-table tbody tr:hover {
background: var(--table-row-hover);
}
.offers-items-table .admin-table td .admin-form-input {
display: block;
padding: 6px 8px;
font-size: 13px;
min-height: 32px;
}
/* Language badges */
.offers-lang-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.25rem;
padding: 0 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
background: color-mix(in srgb, var(--info) 15%, transparent);
color: var(--info);
margin-right: 0.375rem;
vertical-align: middle;
}
.offers-lang-badge-cz {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: var(--danger);
}
/* RichEditor (Quill) */
.admin-rich-editor {
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: visible;
}
.admin-rich-editor .quill {
display: flex;
flex-direction: column;
}
/* Toolbar */
.admin-rich-editor .ql-toolbar.ql-snow {
background: var(--bg-secondary);
border: none;
border-bottom: 1px solid var(--border-color);
border-radius: 0.5rem 0.5rem 0 0;
padding: 0.5rem;
flex-wrap: wrap;
gap: 2px;
}
.admin-rich-editor .ql-toolbar .ql-formats {
margin-right: 8px;
}
/* Toolbar buttons */
.admin-rich-editor .ql-snow .ql-stroke {
stroke: var(--text-secondary);
}
.admin-rich-editor .ql-snow .ql-fill {
fill: var(--text-secondary);
}
.admin-rich-editor .ql-snow .ql-picker-label {
color: var(--text-secondary);
border-color: var(--border-color);
}
.admin-rich-editor .ql-snow button:hover .ql-stroke,
.admin-rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
stroke: var(--text-primary);
}
.admin-rich-editor .ql-snow button:hover .ql-fill,
.admin-rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
fill: var(--text-primary);
}
.admin-rich-editor .ql-snow button:hover,
.admin-rich-editor .ql-snow .ql-picker-label:hover {
color: var(--text-primary);
}
/* Active state */
.admin-rich-editor .ql-snow button.ql-active {
color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
border-radius: 4px;
}
.admin-rich-editor .ql-snow button.ql-active .ql-stroke {
stroke: var(--accent-color);
}
.admin-rich-editor .ql-snow button.ql-active .ql-fill,
.admin-rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill {
fill: var(--accent-color);
}
.admin-rich-editor .ql-snow .ql-picker-item.ql-selected {
color: var(--accent-color);
}
.admin-rich-editor .ql-snow .ql-picker-label.ql-active {
color: var(--accent-color);
}
.admin-rich-editor .ql-snow .ql-picker-label.ql-active .ql-stroke {
stroke: var(--accent-color);
}
/* Dropdowns (font, size, color, align) */
.admin-rich-editor .ql-snow .ql-picker-options {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
z-index: 1000;
padding: 0.25rem;
}
.admin-rich-editor .ql-snow .ql-picker-item {
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.admin-rich-editor .ql-snow .ql-picker-item:hover {
color: var(--text-primary);
background: var(--bg-secondary);
}
/* Font picker */
.admin-rich-editor .ql-snow .ql-font .ql-picker-options {
min-width: 11rem;
max-height: 200px;
overflow-y: auto;
}
.admin-rich-editor .ql-snow .ql-size .ql-picker-options {
max-height: 200px;
overflow-y: auto;
}
/* Font labels - vysoka specificita kvuli quill.snow.css */
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="arial"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="arial"]::before {
content: "Arial" !important;
font-family: Arial, sans-serif;
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="tahoma"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="tahoma"]::before {
content: "Tahoma" !important;
font-family: Tahoma, sans-serif;
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="verdana"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="verdana"]::before {
content: "Verdana" !important;
font-family: Verdana, sans-serif;
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="georgia"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="georgia"]::before {
content: "Georgia" !important;
font-family: Georgia, serif;
}
.ql-snow
.ql-picker.ql-font
.ql-picker-label[data-value="times-new-roman"]::before,
.ql-snow
.ql-picker.ql-font
.ql-picker-item[data-value="times-new-roman"]::before {
content: "Times New Roman" !important;
font-family: "Times New Roman", serif;
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="courier-new"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="courier-new"]::before {
content: "Courier New" !important;
font-family: "Courier New", monospace;
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="trebuchet-ms"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="trebuchet-ms"]::before {
content: "Trebuchet MS" !important;
font-family: "Trebuchet MS", sans-serif;
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="impact"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="impact"]::before {
content: "Impact" !important;
font-family: Impact, sans-serif;
}
.ql-snow
.ql-picker.ql-font
.ql-picker-label[data-value="comic-sans-ms"]::before,
.ql-snow
.ql-picker.ql-font
.ql-picker-item[data-value="comic-sans-ms"]::before {
content: "Comic Sans MS" !important;
font-family: "Comic Sans MS", cursive;
}
.ql-snow
.ql-picker.ql-font
.ql-picker-label[data-value="lucida-console"]::before,
.ql-snow
.ql-picker.ql-font
.ql-picker-item[data-value="lucida-console"]::before {
content: "Lucida Console" !important;
font-family: "Lucida Console", monospace;
}
.ql-snow
.ql-picker.ql-font
.ql-picker-label[data-value="palatino-linotype"]::before,
.ql-snow
.ql-picker.ql-font
.ql-picker-item[data-value="palatino-linotype"]::before {
content: "Palatino Linotype" !important;
font-family: "Palatino Linotype", serif;
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="garamond"]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="garamond"]::before {
content: "Garamond" !important;
font-family: Garamond, serif;
}
/* Font classes */
.ql-font-arial {
font-family: Arial, sans-serif;
}
.ql-font-tahoma {
font-family: Tahoma, sans-serif;
}
.ql-font-verdana {
font-family: Verdana, sans-serif;
}
.ql-font-georgia {
font-family: Georgia, serif;
}
.ql-font-times-new-roman {
font-family: "Times New Roman", serif;
}
.ql-font-courier-new {
font-family: "Courier New", monospace;
}
.ql-font-trebuchet-ms {
font-family: "Trebuchet MS", sans-serif;
}
.ql-font-impact {
font-family: Impact, sans-serif;
}
.ql-font-comic-sans-ms {
font-family: "Comic Sans MS", cursive;
}
.ql-font-lucida-console {
font-family: "Lucida Console", monospace;
}
.ql-font-palatino-linotype {
font-family: "Palatino Linotype", serif;
}
.ql-font-garamond {
font-family: Garamond, serif;
}
/* Size picker */
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="8px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="8px"]::before {
content: "8px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="9px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="9px"]::before {
content: "9px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="10px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="10px"]::before {
content: "10px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="11px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="11px"]::before {
content: "11px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="12px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="12px"]::before {
content: "12px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="14px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="14px"]::before {
content: "14px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before {
content: "16px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="18px"]::before {
content: "18px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="20px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="20px"]::before {
content: "20px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="24px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="24px"]::before {
content: "24px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="28px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="28px"]::before {
content: "28px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="32px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="32px"]::before {
content: "32px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="36px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="36px"]::before {
content: "36px" !important;
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="48px"]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="48px"]::before {
content: "48px" !important;
}
/* Editor area */
.admin-rich-editor .ql-container.ql-snow {
border: none;
border-radius: 0 0 0.5rem 0.5rem;
font-family: Tahoma, sans-serif;
font-size: 14px;
}
.admin-rich-editor .ql-editor {
min-height: var(--re-min-height, 120px);
padding: 0.75rem;
color: var(--text-primary);
line-height: 1.6;
font-family: Tahoma, sans-serif;
font-size: 14px;
background: var(--input-bg);
}
.admin-rich-editor .ql-editor.ql-blank::before {
color: var(--text-tertiary);
font-style: normal;
}
/* Lists inside editor */
.admin-rich-editor .ql-editor ul,
.admin-rich-editor .ql-editor ol {
padding-left: 1.5rem;
}
/* Color picker */
.admin-rich-editor
.ql-snow
.ql-color-picker
.ql-picker-options[aria-hidden="false"] {
width: 176px;
padding: 0.375rem;
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.admin-rich-editor .ql-snow .ql-color-picker .ql-picker-item {
width: 18px;
height: 18px;
border-radius: 2px;
margin: 0;
padding: 0;
flex-shrink: 0;
}
/* Tooltip (link editor) */
.admin-rich-editor .ql-snow .ql-tooltip {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
color: var(--text-primary);
}
.admin-rich-editor .ql-snow .ql-tooltip input[type="text"] {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
color: var(--text-primary);
padding: 0.25rem 0.5rem;
}
.admin-rich-editor .ql-snow .ql-tooltip a {
color: var(--accent-color);
}
/* Read-only rendered rich text (Quill HTML output) */
.admin-rich-text-view {
color: var(--text-secondary);
line-height: 1.6;
font-size: 0.875rem;
overflow-wrap: break-word;
word-break: break-word;
min-width: 0;
}
.admin-rich-text-view ul,
.admin-rich-text-view ol {
padding-left: 1.5rem;
margin: 0.25rem 0 0.75rem;
}
.admin-rich-text-view li {
margin-bottom: 0.15rem;
}
.admin-rich-text-view a {
color: var(--accent-color);
}
.admin-rich-text-view strong,
.admin-rich-text-view b {
font-weight: 600;
color: var(--text-primary);
display: inline-block;
margin-top: 0.5rem;
}
.admin-rich-text-view br + b,
.admin-rich-text-view br + strong {
margin-top: 0.75rem;
}
.admin-rich-text-view > br:first-child,
.admin-rich-text-view ul + br,
.admin-rich-text-view ol + br {
display: none;
}
/* Keep Quill picker dropdowns on-screen on narrow phones.
The font picker hard-codes min-width: 11rem and the color picker a fixed
176px width — both overflow a ~360px viewport. */
@media (max-width: 480px) {
.admin-rich-editor .ql-snow .ql-picker-options,
.admin-rich-editor .ql-snow .ql-font .ql-picker-options,
.admin-rich-editor .ql-snow .ql-size .ql-picker-options {
min-width: 0;
max-width: calc(100vw - 32px);
}
.admin-rich-editor
.ql-snow
.ql-color-picker
.ql-picker-options[aria-hidden="false"] {
width: auto;
max-width: calc(100vw - 32px);
}
}
@media (max-width: 768px) {
.offers-items-table .admin-table td .admin-form-input {
font-size: 16px;
min-height: 44px;
padding: 9px 10px;
}
/* Give the description column more room by shrinking numeric columns */
.offers-col-qty {
width: 4rem !important;
}
.offers-col-unit {
width: 4rem !important;
}
.offers-col-price {
width: 5.5rem !important;
}
.offers-col-included {
width: 3.5rem !important;
}
.offers-col-total {
width: 5.5rem !important;
}
.offers-col-del {
width: 2.5rem !important;
}
}
@media (max-width: 640px) {
.offers-items-table {
margin: 0 -1rem;
width: calc(100% + 2rem);
border-radius: 0;
border-left: none;
border-right: none;
}
.offers-items-table .admin-table {
min-width: 700px;
}
.offers-items-table .admin-table td {
padding: 6px;
font-size: 12px;
}
.offers-items-table .admin-table th {
font-size: 10px;
padding: 6px;
}
/* Further reduce numeric columns on very small screens */
.offers-col-qty {
width: 3.5rem !important;
}
.offers-col-unit {
width: 3.5rem !important;
}
.offers-col-price {
width: 5rem !important;
}
.offers-col-total {
width: 5rem !important;
}
}
/* Offer draft row in table */
.offers-draft-row {
background: var(--row-draft);
}
.offers-draft-row-label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--warning);
background: color-mix(in srgb, var(--warning) 14%, transparent);
padding: 0.2rem 0.55rem;
border-radius: 99px;
}
/* Expired offer without order */
.offers-expired-row {
background: var(--row-expired);
}
.offers-expired-row td {
color: var(--danger) !important;
}
.offers-expired-row a {
color: var(--danger) !important;
}
/* Invalidated offer */
.offers-invalidated-row {
opacity: 0.6;
}
.offers-invalidated-row td {
color: var(--text-muted) !important;
}
.offers-invalidated-row a {
color: var(--text-muted) !important;
}
/* Completed offer */
.offers-completed-row {
background: var(
--row-completed,
color-mix(in srgb, var(--success) 8%, transparent)
);
}
/* Read-only form (invalidated offer detail) */
.offers-readonly input[readonly],
.offers-readonly select:disabled {
background-color: var(--bg-secondary);
cursor: default;
}
/* Offer draft indicator */
/* Filters */
.admin-filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
}
.admin-filter-group {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.admin-filter-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.admin-filter-group .admin-tabs {
flex-wrap: wrap;
}
.admin-filter-group .admin-form-select {
min-width: 10rem;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,29 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import AdminDatePicker from "../components/AdminDatePicker";
import BulkAttendanceModal from "../components/BulkAttendanceModal";
import ShiftFormModal from "../components/ShiftFormModal";
import AttendanceShiftTable from "../components/AttendanceShiftTable";
import useModalLock from "../hooks/useModalLock";
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
import FormField from "../components/FormField";
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
import {
Button,
Card,
ConfirmDialog,
FilterBar,
LoadingState,
MonthField,
PageEnter,
Select,
StatCard,
StatusChip,
ProgressBar,
type ProgressColor,
type StatCardColor,
} from "../ui";
interface UserTotalData {
name: string;
@@ -35,23 +48,57 @@ interface UserTotalData {
worked_holiday_hours?: number;
}
function getFundBarBackground(data: UserTotalData) {
// fondUsed mirrors the Fond "used" line in the IIFE below:
// real_worked + vacation + worked_holiday + free_holiday
// (delta is fondUsed - fund; 0 means exactly at the fund).
/** Fond "used" total mirrors the Fond footer line below:
* real_worked + vacation + worked_holiday + free_holiday. */
function getFondUsed(data: UserTotalData): number {
const realWorked = data.worked_hours_raw ?? data.worked_hours;
const fondUsed =
return (
realWorked +
(data.vacation_hours ?? 0) +
(data.worked_holiday_hours ?? 0) +
(data.svatek ?? 0);
const fundVal = data.fund ?? 0;
const delta = fondUsed - fundVal;
if (delta > 0) return "linear-gradient(135deg, var(--warning), #d97706)";
if (delta >= 0) return "linear-gradient(135deg, var(--success), #059669)";
return "var(--gradient)";
(data.svatek ?? 0)
);
}
/** Maps the Fond delta (used fund) to a ProgressBar color token, mirroring
* the legacy getFundBarBackground conditional: over fund → warning, exactly
* at fund → success, under fund → primary (was the accent gradient). */
function getFundBarColor(data: UserTotalData): ProgressColor {
const delta = getFondUsed(data) - (data.fund ?? 0);
if (delta > 0) return "warning";
if (delta >= 0) return "success";
return "primary";
}
const PrintIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
);
const AddIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
export default function AttendanceAdmin() {
const alert = useAlert();
const { hasPermission } = useAuth();
@@ -111,279 +158,238 @@ export default function AttendanceAdmin() {
Object.keys(data.user_totals).length === 0;
if (isInitialLoad) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
const hasTotals = Object.keys(data.user_totals).length > 0;
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
<PageEnter>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<div>
<h1 className="admin-page-title">Správa docházky</h1>
</div>
<div className="admin-page-actions">
<Typography variant="h4">Správa docházky</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{hasData && (
<button
<Button
variant="outlined"
color="inherit"
startIcon={PrintIcon}
onClick={handlePrint}
className="admin-btn admin-btn-secondary"
title="Tisk docházky"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginRight: "0.5rem" }}
>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
Tisk
</button>
</Button>
)}
<button
onClick={openBulkModal}
className="admin-btn admin-btn-secondary"
>
<Button variant="outlined" color="inherit" onClick={openBulkModal}>
Vyplnit měsíc
</button>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</Button>
<Button startIcon={AddIcon} onClick={openCreateModal}>
Přidat záznam
</button>
</div>
</motion.div>
</Button>
</Box>
</Box>
{/* Filters */}
<motion.div
className="admin-card mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<FormField label="Měsíc">
<AdminDatePicker
mode="month"
value={month}
onChange={(val: string) => setMonth(val)}
/>
</FormField>
<FormField label="Zaměstnanec">
<select
<FilterBar>
<Box sx={{ flex: "0 0 180px" }}>
<MonthField value={month} onChange={(val) => setMonth(val)} />
</Box>
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
className="admin-form-select"
>
<option value="">Všichni</option>
{data.users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</FormField>
</div>
</div>
</motion.div>
onChange={setFilterUserId}
options={[
{ value: "", label: "Všichni zaměstnanci" },
...data.users.map((user) => ({
value: String(user.id),
label: user.name,
})),
]}
/>
</Box>
</FilterBar>
{/* User Totals */}
{Object.keys(data.user_totals).length > 0 && (
<motion.div
className="admin-grid admin-grid-3 mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.09 }}
{/* User Totals (KPI cards) */}
{hasTotals && (
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
lg: "repeat(3, 1fr)",
},
gap: 2,
mb: 3,
}}
>
{Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData;
const balance = data.leave_balances[uid];
const statColor: StatCardColor = ut.working ? "success" : "default";
return (
<div key={uid} className="admin-card" style={{ display: "flex" }}>
<div
className="admin-card-body"
style={{
display: "flex",
flexDirection: "column",
flex: 1,
<StatCard
key={uid}
label={ut.name}
value={
<Box
component="span"
sx={{
display: "inline-flex",
alignItems: "center",
gap: 1,
}}
>
<div className="flex-row gap-2 mb-2">
<span style={{ fontWeight: 600 }}>{ut.name}</span>
<span
className={`attendance-working-badge ${ut.working ? "working" : "finished"}`}
>
{ut.working ? "\u2713" : "\u2717"}
</span>
</div>
<div className="admin-stat-value">
{formatMinutes(ut.minutes)}
</div>
<div className="admin-stat-label">odpracováno</div>
<div
style={{
marginTop: "0.5rem",
marginBottom: "0.75rem",
<StatusChip
color={ut.working ? "success" : "default"}
label={ut.working ? "✓" : "✗"}
/>
</Box>
}
color={statColor}
footer={
<Box>
{/* Leave-type badges */}
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
minHeight: "2rem",
gap: 0.5,
mb: 1,
minHeight: "1.75rem",
alignItems: "center",
}}
>
{ut.vacation_hours > 0 && (
<span className="attendance-leave-badge badge-vacation">
Dov: {ut.vacation_hours}h
</span>
<StatusChip
color="info"
label={`Dov: ${ut.vacation_hours}h`}
/>
)}
{ut.sick_hours > 0 && (
<span className="attendance-leave-badge badge-sick">
Nem: {ut.sick_hours}h
</span>
<StatusChip
color="error"
label={`Nem: ${ut.sick_hours}h`}
/>
)}
{ut.svatek !== undefined && ut.svatek > 0 && (
<span className="attendance-leave-badge badge-holiday">
Sv: {Math.round(ut.svatek)}h
</span>
<StatusChip
color="warning"
label={`Sv: ${Math.round(ut.svatek)}h`}
/>
)}
{ut.unpaid_hours > 0 && (
<span className="attendance-leave-badge badge-unpaid">
Nep: {ut.unpaid_hours}h
</span>
<StatusChip
color="default"
label={`Nep: ${ut.unpaid_hours}h`}
/>
)}
</div>
</Box>
{/* Fond usage */}
{ut.fund !== null &&
(() => {
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday
// (everything the user actually got paid for this month:
// raw worked time with sub-day remainders, plus
// vacation days, plus hours worked on holidays, plus
// the 8h per unworked holiday from the fund).
const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
const fondUsed =
realWorked +
(ut.vacation_hours ?? 0) +
(ut.worked_holiday_hours ?? 0) +
(ut.svatek ?? 0);
// Fond "used" = real_worked + vacation +
// worked_holiday + free_holiday (everything the user
// actually got paid for this month).
const fondUsed = getFondUsed(ut);
const fundVal = ut.fund ?? 0;
const delta = fondUsed - fundVal;
const pct = Math.min(
100,
(fondUsed / (ut.fund || 1)) * 100,
);
return (
<div
className="kpi-card-footer"
style={{
marginTop: "auto",
paddingTop: "0.75rem",
borderTop: "1px solid var(--border-color)",
}}
>
<div
className="text-secondary"
style={{
<Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.8rem",
mb: 0.5,
}}
>
<span>
<Typography
variant="caption"
color="text.secondary"
>
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h
</span>
</Typography>
{delta > 0 && (
<span className="text-warning fw-600">
+{formatHoursDecimal(delta * 60)}h
</span>
)}
{delta <= 0 && delta < 0 && (
<span className="text-danger fw-600">
-{formatHoursDecimal(Math.abs(delta) * 60)}h
</span>
)}
</div>
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: "warning.main",
}}
>
<div
style={{
height: "100%",
width: `${Math.min(
100,
(fondUsed / (ut.fund || 1)) * 100,
)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
+{formatHoursDecimal(delta * 60)}h
</Typography>
)}
{delta < 0 && (
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: "error.main",
}}
>
-{formatHoursDecimal(Math.abs(delta) * 60)}h
</Typography>
)}
</Box>
<ProgressBar
value={pct}
color={getFundBarColor(ut)}
height={4}
/>
</div>
</div>
</Box>
);
})()}
<div
className="text-secondary"
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
>
{data.leave_balances[uid] ? (
<>
{/* Remaining vacation */}
<Box sx={{ mt: 1.5 }}>
{balance ? (
<Typography variant="caption" color="text.secondary">
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
h / {data.leave_balances[uid].vacation_total}h
</>
{balance.vacation_remaining.toFixed(1)}h /{" "}
{balance.vacation_total}h
</Typography>
) : (
<span style={{ visibility: "hidden" }}></span>
<Typography
variant="caption"
sx={{ visibility: "hidden" }}
>
</Typography>
)}
</div>
</div>
</div>
</Box>
</Box>
}
/>
);
})}
</motion.div>
</Box>
)}
{/* Records Table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<Card>
<AttendanceShiftTable
records={data.records}
onEdit={openEditModal}
onDelete={(record) => setDeleteConfirm({ show: true, record })}
/>
</div>
</motion.div>
</Card>
{/* Modals */}
<BulkAttendanceModal
@@ -435,7 +441,7 @@ export default function AttendanceAdmin() {
}
/>
<ConfirmModal
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, record: null })}
onConfirm={handleDelete}
@@ -444,6 +450,6 @@ export default function AttendanceAdmin() {
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
</PageEnter>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,28 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import { userListOptions } from "../lib/queries/users";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
import { useApiMutation } from "../lib/queries/mutations";
import { todayLocalStr } from "../utils/formatters";
import {
Button,
Card,
DateField,
Field,
LoadingState,
PageEnter,
PageHeader,
Select,
TextField,
TimeField,
} from "../ui";
const API_BASE = "/api/admin";
interface CreateForm {
@@ -102,89 +114,74 @@ export default function AttendanceCreate() {
if (!hasPermission("attendance.manage")) return <Forbidden />;
if (loading) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Přidat záznam docházky</h1>
</div>
<div className="admin-page-actions">
<Link
<PageEnter>
<PageHeader
title="Přidat záznam docházky"
actions={
<Button
variant="outlined"
color="inherit"
component={RouterLink}
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
&larr; Zpět na správu
</Link>
</div>
</motion.div>
<motion.div
className="admin-card"
style={{ maxWidth: "600px" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<form onSubmit={handleSubmit} className="admin-form">
<div className="admin-form-row">
<FormField label="Zaměstnanec" required>
<select
value={form.user_id}
onChange={(e) =>
setForm({ ...form, user_id: e.target.value })
Zpět na správu
</Button>
}
className="admin-form-select"
required
>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</FormField>
<FormField label="Datum směny" required>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val: string) => handleShiftDateChange(val)}
required
/>
</FormField>
</div>
<FormField label="Typ záznamu" required>
<select
value={form.leave_type}
onChange={(e) =>
setForm({ ...form, leave_type: e.target.value })
}
className="admin-form-select"
<Card sx={{ maxWidth: 640 }}>
<Box component="form" onSubmit={handleSubmit}>
{/* Employee + Shift date */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="unpaid">Neplacené volno</option>
</select>
</FormField>
<Field label="Zaměstnanec" required>
<Select
value={form.user_id}
onChange={(val) => setForm({ ...form, user_id: val })}
options={[
{ value: "", label: "Vyberte zaměstnance" },
...users.map((u) => ({
value: String(u.id),
label: u.name,
})),
]}
/>
</Field>
<Field label="Datum směny" required>
<DateField
value={form.shift_date}
onChange={(val) => handleShiftDateChange(val)}
/>
</Field>
</Box>
{/* Record type */}
<Field label="Typ záznamu" required>
<Select
value={form.leave_type}
onChange={(val) => setForm({ ...form, leave_type: val })}
options={[
{ value: "work", label: "Práce" },
{ value: "vacation", label: "Dovolená" },
{ value: "sick", label: "Nemoc" },
{ value: "unpaid", label: "Neplacené volno" },
]}
/>
</Field>
{/* Leave hours — shown only for non-work types */}
{!isWorkType && (
<FormField label="Počet hodin">
<input
<Field label="Počet hodin" hint="Výchozí 8 hodin pro celý den">
<TextField
type="number"
value={form.leave_hours}
onChange={(e) =>
@@ -193,132 +190,142 @@ export default function AttendanceCreate() {
leave_hours: parseFloat(e.target.value),
})
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
/>
<small className="admin-form-hint">
Výchozí 8 hodin pro celý den
</small>
</FormField>
</Field>
)}
{/* Work-type time fields */}
{isWorkType && (
<>
<div className="admin-form-row">
<FormField label="Příchod - datum">
<AdminDatePicker
mode="date"
{/* Arrival */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Příchod - datum">
<DateField
value={form.arrival_date}
onChange={(val: string) =>
setForm({ ...form, arrival_date: val })
}
onChange={(val) => setForm({ ...form, arrival_date: val })}
/>
</FormField>
<FormField label="Příchod - čas">
<AdminDatePicker
mode="time"
</Field>
<Field label="Příchod - čas">
<TimeField
value={form.arrival_time}
onChange={(val: string) =>
setForm({ ...form, arrival_time: val })
}
onChange={(val) => setForm({ ...form, arrival_time: val })}
/>
</FormField>
</div>
</Field>
</Box>
<div className="admin-form-row">
<FormField label="Začátek pauzy - datum">
<AdminDatePicker
mode="date"
{/* Break start */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Začátek pauzy - datum">
<DateField
value={form.break_start_date}
onChange={(val: string) =>
onChange={(val) =>
setForm({ ...form, break_start_date: val })
}
/>
</FormField>
<FormField label="Začátek pauzy - čas">
<AdminDatePicker
mode="time"
</Field>
<Field label="Začátek pauzy - čas">
<TimeField
value={form.break_start_time}
onChange={(val: string) =>
onChange={(val) =>
setForm({ ...form, break_start_time: val })
}
/>
</FormField>
</div>
</Field>
</Box>
<div className="admin-form-row">
<FormField label="Konec pauzy - datum">
<AdminDatePicker
mode="date"
{/* Break end */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Konec pauzy - datum">
<DateField
value={form.break_end_date}
onChange={(val: string) =>
onChange={(val) =>
setForm({ ...form, break_end_date: val })
}
/>
</FormField>
<FormField label="Konec pauzy - čas">
<AdminDatePicker
mode="time"
</Field>
<Field label="Konec pauzy - čas">
<TimeField
value={form.break_end_time}
onChange={(val: string) =>
onChange={(val) =>
setForm({ ...form, break_end_time: val })
}
/>
</FormField>
</div>
</Field>
</Box>
<div className="admin-form-row">
<FormField label="Odchod - datum">
<AdminDatePicker
mode="date"
{/* Departure */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Odchod - datum">
<DateField
value={form.departure_date}
onChange={(val: string) =>
onChange={(val) =>
setForm({ ...form, departure_date: val })
}
/>
</FormField>
<FormField label="Odchod - čas">
<AdminDatePicker
mode="time"
</Field>
<Field label="Odchod - čas">
<TimeField
value={form.departure_time}
onChange={(val: string) =>
onChange={(val) =>
setForm({ ...form, departure_time: val })
}
/>
</FormField>
</div>
</Field>
</Box>
</>
)}
<FormField label="Poznámka">
<textarea
{/* Notes */}
<Field label="Poznámka">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={3}
/>
</FormField>
</Field>
<div className="admin-form-actions">
<Link
{/* Actions */}
<Box sx={{ display: "flex", gap: 1.5, justifyContent: "flex-end" }}>
<Button
variant="outlined"
color="inherit"
component={RouterLink}
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
Zrušit
</Link>
<button
type="submit"
disabled={submitting}
className="admin-btn admin-btn-primary"
>
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</form>
</div>
</motion.div>
</div>
</Button>
</Box>
</Box>
</Card>
</PageEnter>
);
}

View File

@@ -1,10 +1,11 @@
import { useState, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import { iconBadgeSx } from "../theme";
import {
attendanceHistoryOptions,
type AttendanceRecord,
@@ -25,7 +26,20 @@ import {
normalizeDateStr,
formatHoursDecimal,
} from "../utils/attendanceHelpers";
import FormField from "../components/FormField";
import {
Button,
Card,
DataTable,
FilterBar,
MonthField,
ProgressBar,
StatusChip,
PageHeader,
PageEnter,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const MONTH_NAMES = [
"Leden",
@@ -42,6 +56,21 @@ const MONTH_NAMES = [
"Prosinec",
];
const PrintIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
);
const formatBreakRange = (record: AttendanceRecord): string => {
if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
@@ -122,9 +151,7 @@ function getBusinessDaysInMonth(year: number, month: number): number {
const renderProjectCell = (record: AttendanceRecord) => {
if (record.project_logs && record.project_logs.length > 0) {
return (
<div
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
{record.project_logs.map((log, i) => {
let h: number,
m: number,
@@ -145,31 +172,28 @@ const renderProjectCell = (record: AttendanceRecord) => {
m = mins % 60;
}
return (
<span
<StatusChip
key={log.id || i}
className="admin-badge"
style={{
fontSize: "0.7rem",
display: "inline-block",
background: isActive ? "var(--accent-light)" : undefined,
}}
>
{log.project_name || `#${log.project_id}`} ({h}:
{String(m).padStart(2, "0")}h{isActive ? " ▸" : ""})
</span>
color={isActive ? "info" : "default"}
label={`${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`}
sx={{ fontSize: "0.7rem" }}
/>
);
})}
</div>
</Box>
);
}
if (record.project_name) {
return (
<span
className="admin-badge admin-badge-wrap"
style={{ fontSize: "0.75rem" }}
>
{record.project_name}
</span>
<StatusChip
color="default"
label={record.project_name}
sx={{
fontSize: "0.75rem",
height: "auto",
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
}}
/>
);
}
return "—";
@@ -373,88 +397,156 @@ export default function AttendanceHistory() {
};
};
const fund = computed.monthlyFund;
const progressValue =
fund.fund > 0 ? Math.min(100, (fund.worked / fund.fund) * 100) : 0;
const progressColor =
computed.delta > 0
? "warning"
: computed.delta >= 0
? "success"
: "primary";
const deltaColor =
computed.delta > 0
? "warning.main"
: computed.delta < 0
? "error.main"
: "success.main";
const columns: DataColumn<AttendanceRecord>[] = [
{
key: "date",
header: "Datum",
width: "10%",
mono: true,
render: (record) => formatDate(record.shift_date),
},
{
key: "type",
header: "Typ",
width: "11%",
render: (record) => {
const leaveType = record.leave_type || "work";
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Historie docházky</h1>
<p className="admin-page-subtitle">{computed.monthName}</p>
</div>
<div className="admin-page-actions">
{records.length > 0 && (
<button
<StatusChip
color={leaveTypeChipColor(leaveType)}
label={getLeaveTypeName(leaveType)}
/>
);
},
},
{
key: "arrival",
header: "Příchod",
width: "10%",
mono: true,
render: (record) => {
const isLeave = (record.leave_type || "work") !== "work";
return isLeave ? "—" : formatDatetime(record.arrival_time);
},
},
{
key: "break",
header: "Pauza",
width: "11%",
mono: true,
render: (record) => {
const isLeave = (record.leave_type || "work") !== "work";
return isLeave ? "—" : formatBreakRange(record);
},
},
{
key: "departure",
header: "Odchod",
width: "10%",
mono: true,
render: (record) => {
const isLeave = (record.leave_type || "work") !== "work";
return isLeave ? "—" : formatDatetime(record.departure_time);
},
},
{
key: "hours",
header: "Hodiny",
width: "9%",
mono: true,
render: (record) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record);
return workMinutes > 0 ? formatMinutes(workMinutes, true) : "—";
},
},
{
key: "projects",
header: "Projekty",
width: "18%",
render: (record) => renderProjectCell(record),
},
{
key: "notes",
header: "Poznámka",
width: "11%",
render: (record) => record.notes || "",
},
];
return (
<PageEnter>
<PageHeader
title="Historie docházky"
subtitle={computed.monthName}
actions={
records.length > 0 ? (
<Button
variant="outlined"
color="inherit"
startIcon={PrintIcon}
onClick={handlePrint}
className="admin-btn admin-btn-secondary"
title="Tisk docházky"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginRight: "0.5rem" }}
>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
Tisk
</button>
)}
</div>
</motion.div>
</Button>
) : undefined
}
/>
{/* Filters */}
<motion.div
className="admin-card mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<FormField label="Měsíc">
<AdminDatePicker
mode="month"
value={month}
onChange={(val: string) => setMonth(val)}
/>
</FormField>
</div>
</div>
</motion.div>
<FilterBar>
<Box sx={{ flex: "0 0 180px" }}>
<MonthField value={month} onChange={(val) => setMonth(val)} />
</Box>
</FilterBar>
{/* Monthly Fund Card */}
<motion.div
className="admin-card mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<Card sx={{ mt: 3 }}>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
<LoadingState />
) : (
<>
{computed.monthlyFund && (
<div
style={{
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "1rem",
gap: 2,
flexWrap: "wrap",
}}
>
<div className="admin-stat-icon info">
<Box
sx={[
{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 44,
height: 44,
borderRadius: 2,
flexShrink: 0,
},
iconBadgeSx("info"),
]}
>
<svg
width="24"
height="24"
@@ -468,230 +560,102 @@ export default function AttendanceHistory() {
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div style={{ flex: 1, minWidth: "200px" }}>
<div
style={{
</Box>
<Box sx={{ flex: 1, minWidth: "200px" }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: "0.375rem",
mb: 0.75,
}}
>
<span
style={{
fontWeight: 600,
fontSize: "1rem",
color: "var(--text-primary)",
}}
<Typography sx={{ fontWeight: 600, fontSize: "1rem" }}>
Fond: {fund.worked}h / {fund.fund}h
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontSize: "0.8125rem" }}
>
Fond: {computed.monthlyFund.worked}h /{" "}
{computed.monthlyFund.fund}h
</span>
<span
className="text-secondary"
style={{ fontSize: "0.8125rem" }}
>
{computed.monthlyFund.business_days} prac. dnů
{computed.monthlyFund.holiday_count > 0 &&
` + ${computed.monthlyFund.holiday_count} svátky`}
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.worked / computed.monthlyFund.fund) * 100 : 0)}%`,
background:
computed.delta > 0
? "linear-gradient(135deg, var(--warning), #d97706)"
: computed.delta >= 0
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}}
/>
</div>
<div
style={{
{fund.business_days} prac. dnů
{fund.holiday_count > 0 && ` + ${fund.holiday_count} svátky`}
</Typography>
</Box>
<ProgressBar value={progressValue} color={progressColor} />
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
marginTop: "0.5rem",
gap: 0.5,
mt: 1,
minHeight: "1.5rem",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
}}
>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
{computed.totalMinutes > 0 && (
<span
className="attendance-leave-badge"
<StatusChip
color="info"
label={`Práce: ${formatHoursDecimal(computed.totalMinutes)}h`}
title="Odpracováno (reálně)"
>
Práce: {formatHoursDecimal(computed.totalMinutes)}h
</span>
/>
)}
{computed.vacationHours > 0 && (
<span className="attendance-leave-badge badge-vacation">
Dov: {computed.vacationHours}h
</span>
<StatusChip
color="success"
label={`Dov: ${computed.vacationHours}h`}
/>
)}
{computed.sickHours > 0 && (
<span className="attendance-leave-badge badge-sick">
Nem: {computed.sickHours}h
</span>
<StatusChip
color="error"
label={`Nem: ${computed.sickHours}h`}
/>
)}
{computed.freeHolidayHours > 0 && (
<span className="attendance-leave-badge badge-holiday">
Sv: {Math.round(computed.freeHolidayHours)}h
</span>
<StatusChip
color="warning"
label={`Sv: ${Math.round(computed.freeHolidayHours)}h`}
/>
)}
{computed.unpaidHours > 0 && (
<span className="attendance-leave-badge badge-unpaid">
Nep: {computed.unpaidHours}h
</span>
<StatusChip
color="default"
label={`Nep: ${computed.unpaidHours}h`}
/>
)}
</div>
<span
style={{
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
className={
computed.delta > 0
? "text-warning fw-600"
: computed.delta < 0
? "text-danger fw-600"
: "text-success fw-600"
}
</Box>
<Typography
variant="caption"
sx={{ fontWeight: 600, color: deltaColor }}
>
{computed.delta > 0
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
: computed.delta < 0
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
: "Fond splněn"}
</span>
</div>
</div>
</div>
</Typography>
</Box>
</Box>
</Box>
)}
{!computed.monthlyFund && (
<div
className="text-muted"
style={{
fontSize: "0.875rem",
textAlign: "center",
padding: "0.5rem 0",
}}
>
Fond měsíce není k dispozici
</div>
)}
</>
)}
</div>
</motion.div>
</Card>
{/* Records Table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<Card sx={{ mt: 3 }}>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
<LoadingState />
) : (
<>
{records.length === 0 && (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
<DataTable<AttendanceRecord>
columns={columns}
rows={records}
rowKey={(record) => record.id}
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
/>
)}
{records.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record);
return (
<tr key={record.id}>
<td className="admin-mono">
{formatDate(record.shift_date)}
</td>
<td>
<span
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">
{isLeave ? "—" : formatBreakRange(record)}
</td>
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0
? formatMinutes(workMinutes, true)
: "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td
style={{
maxWidth: "150px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{record.notes || ""}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</motion.div>
</Card>
{/* Hidden Print Content */}
{records.length > 0 && (
@@ -896,6 +860,24 @@ export default function AttendanceHistory() {
</table>
</div>
)}
</div>
</PageEnter>
);
}
/** Map an attendance leave_type to a StatusChip semantic color (mirrors the
* legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue),
* sick→error(red), unpaid→default(gray). */
function leaveTypeChipColor(
type: string,
): "default" | "success" | "error" | "warning" | "info" {
switch (type) {
case "vacation":
return "info";
case "sick":
return "error";
case "unpaid":
return "default";
default:
return "info";
}
}

View File

@@ -3,17 +3,47 @@ import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { useNavigate, useParams, Link } from "react-router-dom";
import { motion } from "framer-motion";
import { useNavigate, useParams, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { styled } from "@mui/material/styles";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// Leaflet map container (was the .attendance-location-map rule). zIndex:0
// creates a stacking context so Leaflet's internal z-indexes stay contained.
const LocationMap = styled("div")(({ theme }) => ({
height: "400px",
borderRadius: "8px",
marginBottom: "1.5rem",
background: theme.vars!.palette.background.paper,
position: "relative",
zIndex: 0,
"@media (max-width:640px)": {
height: "clamp(220px, 45vh, 400px)",
},
}));
import { formatDate, formatTime } from "../utils/attendanceHelpers";
import {
attendanceLocationOptions,
type LocationRecord,
} from "../lib/queries/attendance";
import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
export default function AttendanceLocation() {
const alert = useAlert();
@@ -167,127 +197,164 @@ export default function AttendanceLocation() {
const month = shiftDateStr.substring(0, 7);
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Poloha záznamu</h1>
</div>
<div className="admin-page-actions">
<Link
<PageEnter>
{/* Header */}
<PageHeader
title="Poloha záznamu"
actions={
<Button
component={RouterLink}
to={`/attendance/admin?month=${month}`}
className="admin-btn admin-btn-secondary"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
&larr; Zpět na správu
</Link>
</div>
</motion.div>
Zpět na správu
</Button>
}
/>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">
{/* Info + map card */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
{record.user_name} {formatDate(record.shift_date)}
</h2>
</div>
<div className="admin-card-body">
{hasAnyLocation && (
<div ref={mapRef} className="attendance-location-map" />
)}
</Typography>
<div className="attendance-location-grid">
{/* Arrival */}
<div
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
{/* Leaflet map */}
{hasAnyLocation && <LocationMap ref={mapRef} />}
{/* Location detail grid */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
mt: hasAnyLocation ? 2 : 0,
}}
>
{/* Arrival */}
<Box
sx={{
p: 2,
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
opacity: hasArrivalLocation ? 1 : 0.6,
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Příchod
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
>
<h3 className="attendance-location-title">Příchod</h3>
<div className="attendance-location-time">
{record.arrival_time
? formatDatetimeLocal(record.arrival_time)
: "—"}
</div>
</Typography>
{hasArrivalLocation ? (
<>
<div className="attendance-location-address">
<Typography variant="body2" sx={{ mb: 0.5 }}>
{record.arrival_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontSize: "0.75rem",
color: "text.secondary",
mb: 1,
}}
>
GPS: {record.arrival_lat}, {record.arrival_lng}
{record.arrival_accuracy &&
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
</div>
<a
</Typography>
<Button
component="a"
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
variant="outlined"
color="inherit"
size="small"
>
Otevřít v Google Maps
</a>
</Button>
</>
) : (
<div className="attendance-location-address">
<Typography variant="body2" color="text.secondary">
<em>Poloha nebyla zaznamenána</em>
</div>
</Typography>
)}
</div>
</Box>
{/* Departure */}
{(hasDepartureLocation || record.departure_time) && (
<div
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
<Box
sx={{
p: 2,
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
opacity: hasDepartureLocation ? 1 : 0.6,
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Odchod
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
>
<h3 className="attendance-location-title">Odchod</h3>
<div className="attendance-location-time">
{record.departure_time
? formatDatetimeLocal(record.departure_time)
: "—"}
</div>
</Typography>
{hasDepartureLocation ? (
<>
<div className="attendance-location-address">
<Typography variant="body2" sx={{ mb: 0.5 }}>
{record.departure_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontSize: "0.75rem",
color: "text.secondary",
mb: 1,
}}
>
GPS: {record.departure_lat}, {record.departure_lng}
{record.departure_accuracy &&
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
</div>
<a
</Typography>
<Button
component="a"
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
variant="outlined"
color="inherit"
size="small"
>
Otevřít v Google Maps
</a>
</Button>
</>
) : (
<div className="attendance-location-address">
<Typography variant="body2" color="text.secondary">
<em>Poloha nebyla zaznamenána</em>
</div>
</Typography>
)}
</div>
</Box>
)}
</div>
</div>
</motion.div>
</div>
</Box>
</Card>
</PageEnter>
);
}

View File

@@ -1,17 +1,31 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import Forbidden from "../components/Forbidden";
import Pagination from "../components/Pagination";
import FormField from "../components/FormField";
import FormModal from "../components/FormModal";
import AdminDatePicker from "../components/AdminDatePicker";
import { czechPlural } from "../utils/formatters";
import apiFetch from "../utils/api";
import { useApiMutation } from "../lib/queries/mutations";
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
import {
Button,
Card,
DataTable,
Pagination,
Modal,
Select,
DateField,
TextField,
StatusChip,
PageHeader,
PageEnter,
FilterBar,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
@@ -30,19 +44,23 @@ const ACTION_LABELS: Record<string, string> = {
access_denied: "Přístup odepřen",
};
const ACTION_BADGE_CLASS: Record<string, string> = {
create: "admin-badge-success",
update: "admin-badge-info",
delete: "admin-badge-danger",
login: "admin-badge-secondary",
login_failed: "admin-badge-danger",
logout: "admin-badge-secondary",
view: "admin-badge-info",
activate: "admin-badge-success",
deactivate: "admin-badge-warning",
password_change: "admin-badge-info",
permission_change: "admin-badge-warning",
access_denied: "admin-badge-danger",
// Maps legacy admin-badge-* CSS class suffixes → StatusChip color
const ACTION_CHIP_COLOR: Record<
string,
"success" | "info" | "warning" | "error" | "default"
> = {
create: "success",
update: "info",
delete: "error",
login: "default",
login_failed: "error",
logout: "default",
view: "info",
activate: "success",
deactivate: "warning",
password_change: "info",
permission_change: "warning",
access_denied: "error",
};
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
@@ -53,6 +71,29 @@ const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(
([value, label]) => ({ value, label }),
);
const CLEANUP_DAYS_OPTIONS = [
{ value: "30", label: "30 dní" },
{ value: "60", label: "60 dní" },
{ value: "90", label: "90 dní" },
{ value: "180", label: "180 dní" },
{ value: "365", label: "1 rok" },
{ value: "0", label: "Vše" },
];
const TrashIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
interface AuditLogEntry {
id: number;
created_at: string;
@@ -82,11 +123,16 @@ export default function AuditLog() {
date_to: "",
});
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(50);
const [perPage] = useState(50);
const [showCleanup, setShowCleanup] = useState(false);
const [cleanupDays, setCleanupDays] = useState(90);
// cleanupDays stored as string for the kit Select; converted to number at the boundary
const [cleanupDaysStr, setCleanupDaysStr] = useState("90");
const { data: logsData, isPending } = useQuery({
const {
data: logsData,
isPending,
isFetching,
} = useQuery({
queryKey: [
"audit-log",
{
@@ -127,6 +173,10 @@ export default function AuditLog() {
},
};
},
// Keep the current rows on screen while a filter refetches, so the page
// doesn't drop to <LoadingState/> (which unmounts PageEnter and replays the
// whole entrance animation on every filter change).
placeholderData: keepPreviousData,
});
const logs: AuditLogEntry[] = logsData?.data ?? [];
@@ -154,16 +204,8 @@ export default function AuditLog() {
setPage(1);
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
const handlePerPageChange = (newPerPage: number) => {
setPage(1);
setPerPage(newPerPage);
};
const handleCleanup = async () => {
const cleanupDays = Number(cleanupDaysStr);
try {
// Backend requires this literal confirmation token when wiping everything.
await cleanupMutation.mutateAsync({
@@ -181,229 +223,156 @@ export default function AuditLog() {
};
if (isPending && logs.length === 0) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
const columns: DataColumn<AuditLogEntry>[] = [
{
key: "created_at",
header: "Čas",
width: "16%",
mono: true,
render: (log) => formatDatetime(log.created_at),
},
{
key: "username",
header: "Uživatel",
width: "14%",
bold: true,
render: (log) => log.username || "-",
},
{
key: "action",
header: "Akce",
width: "14%",
render: (log) => (
<StatusChip
label={ACTION_LABELS[log.action] || log.action}
color={ACTION_CHIP_COLOR[log.action] ?? "default"}
/>
),
},
{
key: "entity_type",
header: "Typ entity",
width: "14%",
render: (log) =>
ENTITY_TYPE_LABELS[log.entity_type || ""] || log.entity_type || "-",
},
{
key: "description",
header: "Popis",
width: "30%",
render: (log) => log.description || "-",
},
{
key: "user_ip",
header: "IP",
width: "12%",
mono: true,
render: (log) => log.user_ip || "-",
},
];
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Audit log</h1>
{pagination && (
<p className="admin-page-subtitle">
{pagination.total}{" "}
{czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}
</p>
)}
</div>
<button
className="admin-btn admin-btn-secondary admin-btn-sm"
<PageEnter>
<PageHeader
title="Audit log"
subtitle={
pagination
? `${pagination.total} ${czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}`
: undefined
}
actions={
<Button
variant="outlined"
startIcon={TrashIcon}
onClick={() => setShowCleanup(true)}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
Vyčistit
</button>
</motion.div>
</Button>
}
/>
<FormModal
{/* Cleanup Modal */}
<Modal
isOpen={showCleanup}
onClose={() => !cleanupMutation.isPending && setShowCleanup(false)}
onSubmit={handleCleanup}
title="Vyčistit audit log"
submitLabel="Smazat"
submitText="Smazat"
loading={cleanupMutation.isPending}
>
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
<div style={{ margin: "0.75rem 0", maxWidth: "200px" }}>
<select
className="admin-form-select"
value={cleanupDays}
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
>
<option value={30}>30 dní</option>
<option value={60}>60 dní</option>
<option value={90}>90 dní</option>
<option value={180}>180 dní</option>
<option value={365}>1 rok</option>
<option value={0}>Vše</option>
</select>
</div>
<p
className="admin-confirm-message"
style={{ fontSize: "12px", opacity: 0.6 }}
>
<Typography variant="body2" sx={{ mb: 1 }}>
Smazat záznamy starší než:
</Typography>
<Box sx={{ maxWidth: 200, mb: 1 }}>
<Select
value={cleanupDaysStr}
onChange={setCleanupDaysStr}
options={CLEANUP_DAYS_OPTIONS}
/>
</Box>
<Typography variant="body2" sx={{ opacity: 0.6, fontSize: "0.75rem" }}>
Tato akce je nevratná.
</p>
</FormModal>
</Typography>
</Modal>
<motion.div
className="admin-card mb-4"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row admin-form-row-5">
<FormField label="Hledat">
<input
type="text"
className="admin-form-input"
placeholder="Popis, uživatel..."
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
value={filters.search}
onChange={(e) => handleFilterChange("search", e.target.value)}
placeholder="Popis, uživatel..."
fullWidth
/>
</FormField>
<FormField label="Akce">
<select
className="admin-form-select"
</Box>
<Box sx={{ flex: "0 0 160px" }}>
<Select
value={filters.action}
onChange={(e) => handleFilterChange("action", e.target.value)}
>
<option value="">Všechny</option>
{ACTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</FormField>
<FormField label="Typ entity">
<select
className="admin-form-select"
onChange={(val) => handleFilterChange("action", val)}
options={[{ value: "", label: "Všechny akce" }, ...ACTION_OPTIONS]}
/>
</Box>
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={filters.entity_type}
onChange={(e) =>
handleFilterChange("entity_type", e.target.value)
}
>
<option value="">Všechny</option>
{ENTITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</FormField>
<FormField label="Od">
<AdminDatePicker
mode="date"
onChange={(val) => handleFilterChange("entity_type", val)}
options={[
{ value: "", label: "Všechny entity" },
...ENTITY_OPTIONS,
]}
/>
</Box>
<Box sx={{ flex: "0 0 150px" }}>
<DateField
value={filters.date_from}
onChange={(val: string) => handleFilterChange("date_from", val)}
onChange={(val) => handleFilterChange("date_from", val)}
label="Od"
/>
</FormField>
<FormField label="Do">
<AdminDatePicker
mode="date"
</Box>
<Box sx={{ flex: "0 0 150px" }}>
<DateField
value={filters.date_to}
onChange={(val: string) => handleFilterChange("date_to", val)}
onChange={(val) => handleFilterChange("date_to", val)}
label="Do"
/>
</FormField>
</div>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<div className="admin-table-responsive">
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<table className="admin-table">
<thead>
<tr>
<th>Čas</th>
<th>Uživatel</th>
<th>Akce</th>
<th>Typ entity</th>
<th>Popis</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{logs.length === 0 && (
<tr>
<td colSpan={6}>
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Žádné záznamy k zobrazení</p>
</div>
</td>
</tr>
)}
{logs.length > 0 &&
logs.map((log) => (
<tr key={log.id}>
<td className="admin-mono">
{formatDatetime(log.created_at)}
</td>
<td className="fw-500">{log.username || "-"}</td>
<td>
<span
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`}
>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td>
{ENTITY_TYPE_LABELS[log.entity_type || ""] ||
log.entity_type ||
"-"}
</td>
<td>{log.description || "-"}</td>
<td className="admin-mono">{log.user_ip || "-"}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</Box>
</FilterBar>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
<DataTable<AuditLogEntry>
columns={columns}
rows={logs}
rowKey={(log) => log.id}
empty={<EmptyState title="Žádné záznamy k zobrazení" />}
/>
<Pagination
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</div>
</motion.div>
</div>
</Card>
</PageEnter>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,27 @@
import { useState, useCallback } from "react";
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { Link as RouterLink } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import CircularProgress from "@mui/material/CircularProgress";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import useModalLock from "../hooks/useModalLock";
import apiFetch from "../utils/api";
import { dashboardOptions } from "../lib/queries/dashboard";
import { require2FAOptions } from "../lib/queries/settings";
import { getCzechDate } from "../utils/dashboardHelpers";
import { useApiMutation } from "../lib/queries/mutations";
import { Card, Button, StatusChip, PageEnter } from "../ui";
import { iconBadgeSx } from "../theme";
import DashKpiCards from "../components/dashboard/DashKpiCards";
import DashQuickActions from "../components/dashboard/DashQuickActions";
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
import DashProfile from "../components/dashboard/DashProfile";
import DashSessions from "../components/dashboard/DashSessions";
import DashTodayPlan, {
type TodayPlan,
} from "../components/dashboard/DashTodayPlan";
const API_BASE = "/api/admin";
@@ -66,6 +72,7 @@ interface DashData {
pending_orders?: number;
unpaid_invoices?: number;
pending_leave_requests?: number;
today_plan?: TodayPlan | null;
[key: string]: unknown;
}
@@ -102,9 +109,6 @@ export default function Dashboard() {
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [disableCode, setDisableCode] = useState("");
useModalLock(show2FASetup);
useModalLock(show2FADisable);
// Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = useCallback(() => {
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
@@ -221,57 +225,51 @@ export default function Dashboard() {
};
return (
<div className="dash">
<PageEnter>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">
<Box sx={{ mb: 3 }}>
<Typography variant="h4">
Vítejte zpět, {user?.fullName || user?.username}
</h1>
<p className="admin-page-subtitle">{getCzechDate()}</p>
</div>
</motion.div>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{getCzechDate()}
</Typography>
</Box>
{/* 2FA Required Banner */}
{user?.require2FA && !user?.totpEnabled && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{
border: "2px solid var(--danger)",
background: "var(--danger-light)",
<Card
sx={{
mb: 3,
border: 2,
borderColor: "error.main",
backgroundColor:
"rgba(var(--mui-palette-error-mainChannel) / 0.12)",
}}
>
<div
className="admin-card-body"
style={{
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
gap: 2,
flexWrap: "wrap",
}}
>
<div className="flex-row-gap">
<div
style={{
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={[
{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--danger-light)",
color: "var(--danger)",
flexShrink: 0,
}}
},
iconBadgeSx("error"),
]}
>
<svg
width="20"
@@ -285,35 +283,33 @@ export default function Dashboard() {
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div>
<div>
<div className="fw-600">Dvoufaktorové ověření je povinné</div>
<div
className="text-secondary"
style={{ fontSize: "0.875rem" }}
>
</Box>
<Box>
<Typography sx={{ fontWeight: 600 }}>
Dvoufaktorové ověření je povinné
</Typography>
<Typography variant="body2" color="text.secondary">
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
nemáte přístup k ostatním sekcím systému.
</div>
</div>
</div>
<button
</Typography>
</Box>
</Box>
<Button
onClick={handleStart2FASetup}
disabled={totpSubmitting}
className="admin-btn admin-btn-primary"
style={{ flexShrink: 0 }}
sx={{ flexShrink: 0 }}
>
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
</button>
</div>
</motion.div>
</Button>
</Box>
</Card>
)}
{/* Loading spinner */}
{dashLoading && (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress />
</Box>
)}
{/* KPI cards — only show if user has any admin-level permissions */}
@@ -325,6 +321,13 @@ export default function Dashboard() {
<DashKpiCards dashData={dashData ?? null} />
)}
{/* My work plan for today — paired with the clock-in below */}
{!dashLoading &&
(hasPermission("attendance.record") ||
hasPermission("attendance.manage")) && (
<DashTodayPlan plan={dashData?.today_plan ?? null} />
)}
{/* Quick actions */}
{!dashLoading && (
<DashQuickActions
@@ -336,11 +339,14 @@ export default function Dashboard() {
{/* Main content grid */}
{!dashLoading && (
<motion.div
className="dash-main-grid"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
gap: 3,
alignItems: "start",
mb: 3,
}}
>
{hasPermission("settings.audit") && (
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
@@ -351,86 +357,146 @@ export default function Dashboard() {
)}
{/* Pravy sloupec: projekty + nabidky */}
<div className="dash-right-col">
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{dashData?.projects && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Aktivní projekty</h2>
<Link
<Card sx={{ display: "flex", flexDirection: "column" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<Typography variant="h6">Aktivní projekty</Typography>
<Button
component={RouterLink}
to="/projects"
className="admin-btn admin-btn-primary admin-btn-sm"
size="small"
sx={{ flexShrink: 0 }}
>
Vše &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
</Button>
</Box>
{dashData.projects.active_projects.length === 0 && (
<div className="dash-empty-row">Žádné aktivní projekty</div>
)}
{dashData.projects.active_projects.map(
(p: {
id: number;
name: string;
customer_name: string | null;
}) => (
<Link
key={p.id}
to={`/projects/${p.id}`}
className="dash-project-row"
<Typography
variant="body2"
color="text.secondary"
sx={{ py: 1 }}
>
<div className="dash-project-name">{p.name}</div>
Žádné aktivní projekty
</Typography>
)}
{dashData.projects.active_projects.map((p) => (
<Box
key={p.id}
component={RouterLink}
to={`/projects/${p.id}`}
sx={{
display: "block",
py: 1.25,
textDecoration: "none",
color: "inherit",
borderBottom: 1,
borderColor: "divider",
"&:last-of-type": { borderBottom: 0 },
"&:hover": { color: "primary.main" },
}}
>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{p.name}
</Typography>
{p.customer_name && (
<div className="dash-project-customer">
<Typography variant="caption" color="text.secondary">
{p.customer_name}
</div>
</Typography>
)}
</Link>
),
)}
</div>
</div>
</Box>
))}
</Card>
)}
{dashData?.offers && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Nabídky</h2>
<Link
<Card sx={{ display: "flex", flexDirection: "column" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<Typography variant="h6">Nabídky</Typography>
<Button
component={RouterLink}
to="/offers"
className="admin-btn admin-btn-primary admin-btn-sm"
size="small"
sx={{ flexShrink: 0 }}
>
Zobrazit &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="dash-stat-row">
<span>Otevřené</span>
<span className="admin-badge admin-badge-info">
{dashData.offers.open_count}
</span>
</div>
<div className="dash-stat-row">
<span>Převedené na objednávku</span>
<span className="admin-badge admin-badge-success">
{dashData.offers.converted_count}
</span>
</div>
<div className="dash-stat-row">
<span>Zneplatněné</span>
<span className="admin-badge admin-badge-warning">
{dashData.offers.expired_count}
</span>
</div>
</div>
</div>
</Button>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
py: 1,
borderBottom: 1,
borderColor: "divider",
}}
>
<Typography variant="body2">Otevřené</Typography>
<StatusChip color="info" label={dashData.offers.open_count} />
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
py: 1,
borderBottom: 1,
borderColor: "divider",
}}
>
<Typography variant="body2">
Převedené na objednávku
</Typography>
<StatusChip
color="success"
label={dashData.offers.converted_count}
/>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
py: 1,
}}
>
<Typography variant="body2">Zneplatněné</Typography>
<StatusChip
color="warning"
label={dashData.offers.expired_count}
/>
</Box>
</Card>
)}
</div>
</motion.div>
</Box>
</Box>
)}
{/* Profile + Sessions */}
{!dashLoading && (
<div className="dash-bottom">
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" },
gap: 3,
alignItems: "start",
}}
>
<DashProfile
totpEnabled={totpEnabled}
totpLoading={totpLoading}
@@ -452,8 +518,8 @@ export default function Dashboard() {
setDisableCode={setDisableCode}
/>
<DashSessions />
</div>
</Box>
)}
</div>
</PageEnter>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,18 +2,34 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import { czechPlural } from "../utils/formatters";
import {
leavePendingOptions,
leaveProcessedOptions,
} from "../lib/queries/leave";
import ConfirmModal from "../components/ConfirmModal";
import Forbidden from "../components/Forbidden";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
ConfirmDialog,
Modal,
StatusChip,
Field,
TextField,
PageHeader,
PageEnter,
EmptyState,
LoadingState,
Tabs,
TabPanel,
type DataColumn,
type TabDef,
} from "../ui";
const API_BASE = "/api/admin";
@@ -23,12 +39,6 @@ const leaveTypeLabels: Record<string, string> = {
unpaid: "Neplacené volno",
};
const leaveTypeClasses: Record<string, string> = {
vacation: "badge-vacation",
sick: "badge-sick",
unpaid: "badge-unpaid",
};
const statusLabels: Record<string, string> = {
pending: "Čeká na schválení",
approved: "Schváleno",
@@ -36,11 +46,23 @@ const statusLabels: Record<string, string> = {
cancelled: "Zrušeno",
};
const statusClasses: Record<string, string> = {
pending: "badge-pending",
approved: "badge-approved",
rejected: "badge-rejected",
cancelled: "badge-cancelled",
// Leave type → StatusChip color
const leaveTypeColor: Record<
string,
"info" | "error" | "default" | "success" | "warning"
> = {
vacation: "info",
sick: "error",
unpaid: "default",
};
// Status → StatusChip color
const statusColor: Record<string, "warning" | "success" | "error" | "default"> =
{
pending: "warning",
approved: "success",
rejected: "error",
cancelled: "default",
};
interface RawLeaveRequest {
@@ -192,290 +214,231 @@ export default function LeaveApproval() {
};
if (loading) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Schvalování nepřítomnosti</h1>
<p className="admin-page-subtitle">
{pendingCount > 0
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
: "Žádné čekající žádosti"}
</p>
</div>
</motion.div>
const tabs: TabDef[] = [
{ value: "pending", label: `Ke schválení (${pendingCount})` },
{ value: "processed", label: "Vyřízené" },
];
{/* Tabs */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-tabs mb-6">
<button
className={`admin-tab ${activeTab === "pending" ? "active" : ""}`}
onClick={() => setActiveTab("pending")}
>
Ke schválení
{pendingCount > 0 && (
<span
className="admin-badge badge-pending"
style={{
marginLeft: "0.5rem",
fontSize: "0.7rem",
padding: "0.15rem 0.5rem",
}}
>
{pendingCount}
</span>
)}
</button>
<button
className={`admin-tab ${activeTab === "processed" ? "active" : ""}`}
onClick={() => setActiveTab("processed")}
>
Vyřízené
</button>
</div>
</motion.div>
const processedColumns: DataColumn<LeaveRequest>[] = [
{
key: "employee_name",
header: "Zaměstnanec",
width: "16%",
bold: true,
render: (req) => req.employee_name,
},
{
key: "leave_type",
header: "Typ",
width: "13%",
render: (req) => (
<StatusChip
label={leaveTypeLabels[req.leave_type] || req.leave_type}
color={leaveTypeColor[req.leave_type] ?? "default"}
/>
),
},
{
key: "date_from",
header: "Od",
width: "10%",
mono: true,
render: (req) => formatDate(req.date_from),
},
{
key: "date_to",
header: "Do",
width: "10%",
mono: true,
render: (req) => formatDate(req.date_to),
},
{
key: "total_days",
header: "Dny",
width: "6%",
mono: true,
render: (req) => String(req.total_days),
},
{
key: "status",
header: "Stav",
width: "13%",
render: (req) => (
<StatusChip
label={statusLabels[req.status] || req.status}
color={statusColor[req.status] ?? "default"}
/>
),
},
{
key: "reviewer_name",
header: "Schválil",
width: "12%",
render: (req) => req.reviewer_name || "—",
},
{
key: "reviewer_note",
header: "Poznámka",
width: "12%",
render: (req) =>
req.reviewer_note ? (
<Box component="span" title={req.reviewer_note}>
{req.reviewer_note.length > 40
? `${req.reviewer_note.substring(0, 40)}...`
: req.reviewer_note}
</Box>
) : (
"—"
),
},
{
key: "reviewed_at",
header: "Vyřízeno",
width: "8%",
mono: true,
render: (req) => formatDatetime(req.reviewed_at),
},
];
return (
<PageEnter>
<PageHeader
title="Schvalování nepřítomnosti"
subtitle={
pendingCount > 0
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
: "Žádné čekající žádosti"
}
/>
<Tabs
value={activeTab}
onChange={(v) => setActiveTab(v as "pending" | "processed")}
tabs={tabs}
/>
{/* Pending Tab */}
{activeTab === "pending" && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<TabPanel value="pending" current={activeTab}>
{pendingRequests.length === 0 ? (
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-empty-state">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-muted mb-4"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<p>Žádné čekající žádosti</p>
</div>
</div>
</div>
<Card>
<EmptyState title="Žádné čekající žádosti" />
</Card>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{pendingRequests.map((req) => (
<div key={req.id} className="admin-card">
<div
className="admin-card-body"
style={{ padding: "1.25rem" }}
>
<div
style={{
<Card key={req.id}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
flexWrap: "wrap",
gap: "1rem",
gap: 2,
}}
>
<div className="flex-1">
<div className="flex-row-gap mb-2">
<strong style={{ fontSize: "1rem" }}>
{req.employee_name}
</strong>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</div>
<div
className="text-secondary"
style={{
<Box sx={{ flex: 1 }}>
<Box
sx={{
display: "flex",
gap: "1.5rem",
alignItems: "center",
gap: 1.5,
mb: 1,
}}
>
<Typography
component="strong"
sx={{ fontSize: "1rem", fontWeight: 600 }}
>
{req.employee_name}
</Typography>
<StatusChip
label={
leaveTypeLabels[req.leave_type] || req.leave_type
}
color={leaveTypeColor[req.leave_type] ?? "default"}
/>
</Box>
<Box
sx={{
display: "flex",
gap: 3,
flexWrap: "wrap",
fontSize: "0.875rem",
color: "text.secondary",
}}
>
<span>
<Box component="span">
<strong>{formatDate(req.date_from)}</strong> {" "}
<strong>{formatDate(req.date_to)}</strong>
</span>
<span>
</Box>
<Box component="span">
{req.total_days}{" "}
{czechPlural(req.total_days, "den", "dny", "dnů")} (
{req.total_hours}h)
</span>
<span className="text-muted">
</Box>
<Box component="span" sx={{ color: "text.disabled" }}>
Podáno: {formatDatetime(req.created_at)}
</span>
</div>
</Box>
</Box>
{req.notes && (
<div
className="text-secondary"
style={{
marginTop: "0.5rem",
<Box
sx={{
mt: 1,
fontSize: "0.875rem",
fontStyle: "italic",
color: "text.secondary",
}}
>
{req.notes}
</div>
</Box>
)}
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
flexShrink: 0,
}}
>
<button
</Box>
<Box sx={{ display: "flex", gap: 1, flexShrink: 0 }}>
<Button
size="small"
color="success"
variant="outlined"
onClick={() =>
setApproveModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{
background: "var(--success-light)",
color: "var(--success)",
border: "none",
}}
>
Schválit
</button>
<button
</Button>
<Button
size="small"
color="error"
variant="outlined"
onClick={() =>
setRejectModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{
background: "var(--danger-light)",
color: "var(--danger)",
border: "none",
}}
>
Zamítnout
</button>
</div>
</div>
</div>
</div>
</Button>
</Box>
</Box>
</Card>
))}
</div>
)}
</motion.div>
</Box>
)}
</TabPanel>
{/* Processed Tab */}
{activeTab === "processed" && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
{processedRequests.length === 0 ? (
<div className="admin-empty-state">
<p>Zatím žádné vyřízené žádosti</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Stav</th>
<th>Schválil</th>
<th>Poznámka</th>
<th>Vyřízeno</th>
</tr>
</thead>
<tbody>
{processedRequests.map((req) => (
<tr key={req.id}>
<td>
<strong>{req.employee_name}</strong>
</td>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">
{formatDate(req.date_to)}
</td>
<td className="admin-mono">{req.total_days}</td>
<td>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td>{req.reviewer_name || "—"}</td>
<td style={{ maxWidth: "200px" }}>
{req.reviewer_note ? (
<span title={req.reviewer_note}>
{req.reviewer_note.length > 40
? `${req.reviewer_note.substring(0, 40)}...`
: req.reviewer_note}
</span>
) : (
"—"
)}
</td>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.reviewed_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
)}
<TabPanel value="processed" current={activeTab}>
<Card>
<DataTable<LeaveRequest>
columns={processedColumns}
rows={processedRequests}
rowKey={(req) => req.id}
empty={<EmptyState title="Zatím žádné vyřízené žádosti" />}
/>
</Card>
</TabPanel>
{/* Approve Confirmation */}
<ConfirmModal
<ConfirmDialog
isOpen={approveModal.open}
onClose={() => setApproveModal({ open: false, request: null })}
onConfirm={handleApprove}
@@ -486,12 +449,11 @@ export default function LeaveApproval() {
: ""
}
confirmText="Schválit"
type="info"
loading={approveMutation.isPending}
/>
{/* Reject Modal */}
<FormModal
<Modal
isOpen={rejectModal.open && !!rejectModal.request}
onClose={() => {
setRejectModal({ open: false, request: null });
@@ -499,31 +461,31 @@ export default function LeaveApproval() {
}}
onSubmit={handleReject}
title="Zamítnout žádost"
submitLabel="Zamítnout"
submitText="Zamítnout"
loading={rejectMutation.isPending}
>
{rejectModal.request && (
<>
<p className="text-secondary mb-4">
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{rejectModal.request.employee_name} {" "}
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
{formatDate(rejectModal.request.date_from)} {" "}
{formatDate(rejectModal.request.date_to)} (
{rejectModal.request.total_days} dnů)
</p>
<FormField label="Důvod zamítnutí" required>
<textarea
</Typography>
<Field label="Důvod zamítnutí" required>
<TextField
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="Uveďte důvod zamítnutí..."
className="admin-form-textarea"
multiline
rows={3}
autoFocus
/>
</FormField>
</Field>
</>
)}
</FormModal>
</div>
</Modal>
</PageEnter>
);
}

View File

@@ -2,12 +2,23 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Forbidden from "../components/Forbidden";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import ConfirmModal from "../components/ConfirmModal";
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
ConfirmDialog,
StatusChip,
PageHeader,
PageEnter,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
@@ -24,19 +35,41 @@ const statusLabels: Record<string, string> = {
cancelled: "Zrušeno",
};
const statusClasses: Record<string, string> = {
pending: "badge-pending",
approved: "badge-approved",
rejected: "badge-rejected",
cancelled: "badge-cancelled",
// Leave type → StatusChip color
const leaveTypeColor: Record<
string,
"info" | "error" | "default" | "success" | "warning"
> = {
vacation: "info",
sick: "error",
unpaid: "default",
};
const leaveTypeClasses: Record<string, string> = {
vacation: "badge-vacation",
sick: "badge-sick",
unpaid: "badge-unpaid",
// Status → StatusChip color
const statusColor: Record<string, "warning" | "success" | "error" | "default"> =
{
pending: "warning",
approved: "success",
rejected: "error",
cancelled: "default",
};
const CancelIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
export default function LeaveRequests() {
const alert = useAlert();
const { hasPermission } = useAuth();
@@ -72,167 +105,167 @@ export default function LeaveRequests() {
}
};
function renderNoteCell(req: LeaveRequest) {
if (isPending) {
return <LoadingState />;
}
const columns: DataColumn<LeaveRequest>[] = [
{
key: "leave_type",
header: "Typ",
width: "14%",
render: (req) => (
<StatusChip
label={leaveTypeLabels[req.leave_type] || req.leave_type}
color={leaveTypeColor[req.leave_type] ?? "default"}
/>
),
},
{
key: "date_from",
header: "Od",
width: "11%",
mono: true,
render: (req) => formatDate(req.date_from),
},
{
key: "date_to",
header: "Do",
width: "11%",
mono: true,
render: (req) => formatDate(req.date_to),
},
{
key: "total_days",
header: "Dny",
width: "7%",
mono: true,
render: (req) => String(req.total_days),
},
{
key: "total_hours",
header: "Hodiny",
width: "8%",
mono: true,
render: (req) => `${req.total_hours}h`,
},
{
key: "status",
header: "Stav",
width: "16%",
render: (req) => (
<StatusChip
label={statusLabels[req.status] || req.status}
color={statusColor[req.status] ?? "default"}
/>
),
},
{
key: "notes",
header: "Poznámka",
width: "20%",
render: (req) => {
const truncate = (text: string) =>
text.length > 40 ? `${text.substring(0, 40)}...` : text;
if (req.status === "rejected" && req.reviewer_note) {
return (
<span
style={{ color: "var(--danger)", fontSize: "0.875rem" }}
<Box
component="span"
title={req.reviewer_note}
sx={{
color: "error.main",
fontSize: "0.875rem",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{truncate(req.reviewer_note)}
</span>
</Box>
);
}
if (req.notes) {
return (
<span
className="text-secondary"
style={{ fontSize: "0.875rem" }}
<Box
component="span"
title={req.notes}
sx={{
color: "text.secondary",
fontSize: "0.875rem",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{truncate(req.notes)}
</span>
</Box>
);
}
return <span className="text-muted"></span>;
}
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
<Box component="span" sx={{ color: "text.disabled" }}>
</Box>
);
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Moje žádosti</h1>
<p className="admin-page-subtitle">Přehled žádostí o nepřítomnost</p>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{requests.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<p>Zatím nemáte žádné žádosti</p>
<p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
Novou žádost můžete podat na stránce Docházka
</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Hodiny</th>
<th>Stav</th>
<th>Poznámka</th>
<th>Podáno</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{requests.map((req) => (
<tr key={req.id}>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">{formatDate(req.date_to)}</td>
<td className="admin-mono">{req.total_days}</td>
<td className="admin-mono">{req.total_hours}h</td>
<td>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td style={{ maxWidth: "200px" }}>
{renderNoteCell(req)}
</td>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.created_at)}
</td>
<td>
<div className="admin-table-actions">
{req.status === "pending" && (
<button
onClick={() =>
setCancelModal({ open: true, id: req.id })
}
className="admin-btn admin-btn-secondary admin-btn-sm"
},
},
{
key: "created_at",
header: "Podáno",
width: "13%",
mono: true,
render: (req) => formatDatetime(req.created_at),
},
{
key: "actions",
header: "Akce",
width: "8%",
align: "right",
render: (req) =>
req.status === "pending" ? (
<Button
size="small"
variant="outlined"
startIcon={CancelIcon}
onClick={() => setCancelModal({ open: true, id: req.id })}
>
Zrušit
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
</Button>
) : null,
},
];
<ConfirmModal
return (
<PageEnter>
<PageHeader
title="Moje žádosti"
subtitle="Přehled žádostí o nepřítomnost"
/>
<Card>
<DataTable<LeaveRequest>
columns={columns}
rows={requests}
rowKey={(req) => req.id}
empty={
<EmptyState
title="Zatím nemáte žádné žádosti"
description="Novou žádost můžete podat na stránce Docházka"
/>
}
/>
</Card>
<ConfirmDialog
isOpen={cancelModal.open}
onClose={() => setCancelModal({ open: false, id: null })}
onConfirm={handleCancel}
title="Zrušit žádost"
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
confirmText="Zrušit žádost"
type="warning"
loading={cancelMutation.isPending}
/>
</div>
</PageEnter>
);
}

View File

@@ -1,6 +1,12 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Navigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import CircularProgress from "@mui/material/CircularProgress";
import { keyframes } from "@mui/system";
import type { Theme } from "@mui/material/styles";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import { useTheme } from "../../context/ThemeContext";
@@ -8,7 +14,13 @@ import {
shouldShowSessionExpiredAlert,
shouldShowLogoutAlert,
} from "../utils/api";
import FormField from "../components/FormField";
import { TextField, Button, CheckboxField, Field } from "../ui";
// Drift animation for the decorative background orbs (was @keyframes float).
const float = keyframes({
"0%, 100%": { transform: "translate(0, 0)" },
"50%": { transform: "translate(30px, -30px)" },
});
export default function Login() {
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth();
@@ -132,11 +144,17 @@ export default function Login() {
if (authLoading) {
return (
<div className="admin-login">
<div className="admin-loading">
<div className="admin-spinner" />
</div>
</div>
<Box
sx={{
minHeight: ["100vh", "100dvh"],
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "background.default",
}}
>
<CircularProgress />
</Box>
);
}
@@ -144,32 +162,120 @@ export default function Login() {
return <Navigate to="/" replace />;
}
// Shared glass card sx (replaces .admin-login-card)
const cardSx = (theme: Theme) => ({
width: "100%",
maxWidth: 420,
maxHeight: "calc(100dvh - 2rem)",
p: { xs: 4, sm: 5 },
bgcolor: "background.paper",
backdropFilter: "blur(20px)",
border: "1px solid",
borderColor: "divider",
borderRadius: "16px",
boxShadow: "0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.04)",
...theme.applyStyles("dark", {
boxShadow: "0 1px 3px rgba(0,0,0,0.2), 0 4px 16px rgba(0,0,0,0.15)",
}),
position: "relative" as const,
zIndex: 1,
overflowY: "auto" as const,
});
// Framer entrance + shake choreography for the card panels (unchanged)
const cardMotion = {
initial: { opacity: 0, y: 30 },
animate: shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -20 },
transition: shake
? { x: { duration: 0.5, ease: "easeOut" as const } }
: { duration: 0.3 },
};
return (
<motion.div
className="admin-login"
<Box
component={motion.div}
initial={{ opacity: 0, scale: 0.98 }}
animate={
animatingOut
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
: { scale: 1, opacity: 1, filter: "none" }
}
transition={
animatingOut
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
}
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
sx={{
minHeight: ["100vh", "100dvh"],
maxHeight: ["100vh", "100dvh"],
display: "flex",
alignItems: "center",
justifyContent: "center",
p: 2,
bgcolor: "background.default",
position: "relative",
overflow: "hidden",
boxSizing: "border-box",
}}
>
<div className="bg-orb bg-orb-1" />
<div className="bg-orb bg-orb-2" />
{/* Animated background orbs (replaces .bg-orb) */}
<Box
aria-hidden
sx={{
position: "absolute",
borderRadius: "50%",
filter: "blur(80px)",
pointerEvents: "none",
width: 400,
height: 400,
background: "rgba(var(--mui-palette-primary-mainChannel) / 0.16)",
top: "10%",
left: "20%",
animation: `${float} 20s ease-in-out infinite`,
}}
/>
<Box
aria-hidden
sx={{
position: "absolute",
borderRadius: "50%",
filter: "blur(80px)",
pointerEvents: "none",
width: 320,
height: 320,
background: "rgba(120, 119, 198, 0.13)",
bottom: "20%",
right: "15%",
animation: `${float} 25s ease-in-out infinite reverse`,
}}
/>
<button
{/* Theme toggle (replaces .admin-login-theme-btn) */}
<IconButton
onClick={toggleTheme}
className="admin-login-theme-btn"
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
sx={{
position: "absolute",
top: "1rem",
right: "1rem",
width: 44,
height: 44,
bgcolor: "background.paper",
backdropFilter: "blur(10px)",
border: "1px solid",
borderColor: "divider",
color: "text.secondary",
overflow: "hidden",
zIndex: 10,
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
"&:hover": {
bgcolor: "background.paper",
color: "text.primary",
borderColor: "rgba(var(--mui-palette-text-primaryChannel) / 0.15)",
transform: "scale(1.05)",
},
}}
>
<span
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
>
{theme === "light" ? (
<svg
width="20"
height="20"
@@ -181,10 +287,7 @@ export default function Login() {
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
</span>
<span
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
>
) : (
<svg
width="20"
height="20"
@@ -195,122 +298,112 @@ export default function Login() {
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</span>
</button>
)}
</IconButton>
<AnimatePresence mode="wait">
{!show2FA ? (
<motion.div
key="login"
className="admin-login-card"
initial={{ opacity: 0, y: 30 }}
animate={
shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
}
exit={{ opacity: 0, y: -20 }}
transition={
shake
? { x: { duration: 0.5, ease: "easeOut" } }
: { duration: 0.3 }
}
>
<div className="admin-login-header">
<img
<Box key="login" component={motion.div} {...cardMotion} sx={cardSx}>
<Box sx={{ textAlign: "center", mb: 4 }}>
<Box
component="img"
src={
theme === "dark"
? "/api/admin/company-settings/logo?variant=dark"
: "/api/admin/company-settings/logo?variant=light"
}
alt="Logo"
className="admin-login-logo"
onError={(e) => {
(e.target as HTMLImageElement).src =
theme === "dark"
? "/images/logo-dark.png"
: "/images/logo-light.png";
}}
sx={{ height: 48, width: "auto", mb: 2 }}
/>
<h1 className="admin-login-title">Interní systém</h1>
<p className="admin-login-subtitle">Přihlaste se ke svému účtu</p>
</div>
<Typography
variant="h1"
sx={{
fontSize: "1.5rem",
fontWeight: 700,
color: "text.primary",
mb: 1,
}}
>
Interní systém
</Typography>
<Typography sx={{ color: "text.secondary", fontSize: "0.95rem" }}>
Přihlaste se ke svému účtu
</Typography>
</Box>
<form onSubmit={handleSubmit} className="admin-form">
<FormField label="Uživatelské jméno nebo e-mail">
<input
<Box component="form" onSubmit={handleSubmit}>
<Field label="Uživatelské jméno nebo e-mail">
<TextField
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
className="admin-form-input"
placeholder="Zadejte uživatelské jméno"
/>
</FormField>
</Field>
<FormField label="Heslo">
<input
<Field label="Heslo">
<TextField
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className="admin-form-input"
placeholder="Zadejte heslo"
/>
</FormField>
</Field>
<label className="admin-form-checkbox">
<input
type="checkbox"
<Box sx={{ mb: 2 }}>
<CheckboxField
label="Zapamatovat si mě"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
onChange={setRemember}
/>
<span>Zapamatovat si </span>
</label>
</Box>
<button
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: "100%" }}
>
<Button type="submit" disabled={loading} fullWidth>
{loading ? (
<>
<div
className="admin-spinner"
style={{ width: 20, height: 20, borderWidth: 2 }}
<CircularProgress
size={20}
thickness={5}
color="inherit"
sx={{ mr: 1 }}
/>
Přihlašování...
</>
) : (
"Přihlásit se"
)}
</button>
</form>
</motion.div>
</Button>
</Box>
</Box>
) : (
<motion.div
key="2fa"
className="admin-login-card"
initial={{ opacity: 0, y: 30 }}
animate={
shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
}
exit={{ opacity: 0, y: -20 }}
transition={
shake
? { x: { duration: 0.5, ease: "easeOut" } }
: { duration: 0.3 }
}
<Box key="2fa" component={motion.div} {...cardMotion} sx={cardSx}>
<Box sx={{ textAlign: "center", mb: 4 }}>
<Box
sx={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 56,
height: 56,
borderRadius: "50%",
background:
"rgba(var(--mui-palette-primary-mainChannel) / 0.1)",
color: "primary.main",
mb: 2,
}}
>
<div className="admin-login-header">
<div className="admin-login-2fa-icon">
<svg
width="32"
height="32"
@@ -322,26 +415,31 @@ export default function Login() {
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h1 className="admin-login-title">Dvoufaktorové ověření</h1>
<p className="admin-login-subtitle">
</Box>
<Typography
variant="h1"
sx={{
fontSize: "1.5rem",
fontWeight: 700,
color: "text.primary",
mb: 1,
}}
>
Dvoufaktorové ověření
</Typography>
<Typography sx={{ color: "text.secondary", fontSize: "0.95rem" }}>
{useBackupCode
? "Zadejte jeden ze záložních kódů"
: "Zadejte 6místný kód z autentizační aplikace"}
</p>
</div>
</Typography>
</Box>
<form onSubmit={handle2FASubmit} className="admin-form">
<FormField
label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}
>
<input
ref={totpInputRef}
<Box component="form" onSubmit={handle2FASubmit}>
<Field label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}>
<TextField
inputRef={totpInputRef}
id="totp-code"
type="text"
inputMode={useBackupCode ? "text" : "numeric"}
pattern={useBackupCode ? undefined : "[0-9]*"}
maxLength={useBackupCode ? 8 : 6}
value={totpCode}
onChange={(e) => {
const val = useBackupCode
@@ -351,80 +449,93 @@ export default function Login() {
}}
required
autoComplete="one-time-code"
className="admin-form-input"
placeholder={useBackupCode ? "XXXXXXXX" : "000000"}
style={
useBackupCode
slotProps={{
htmlInput: {
inputMode: useBackupCode ? "text" : "numeric",
pattern: useBackupCode ? undefined : "[0-9]*",
maxLength: useBackupCode ? 8 : 6,
style: useBackupCode
? {}
: {
textAlign: "center",
fontSize: "1.5rem",
letterSpacing: "0.5rem",
fontFamily: "monospace",
}
}
},
},
}}
/>
</FormField>
</Field>
<button
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: "100%" }}
>
<Button type="submit" disabled={loading} fullWidth>
{loading ? (
<>
<div
className="admin-spinner"
style={{ width: 20, height: 20, borderWidth: 2 }}
<CircularProgress
size={20}
thickness={5}
color="inherit"
sx={{ mr: 1 }}
/>
Ověřování...
</>
) : (
"Ověřit"
)}
</button>
</form>
</Button>
</Box>
<div
style={{
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "0.5rem",
gap: 1,
mt: 1,
}}
>
<button
<Button
variant="text"
onClick={() => {
setUseBackupCode(!useBackupCode);
setTotpCode("");
}}
className="admin-back-link"
style={{
border: "none",
background: "none",
cursor: "pointer",
sx={{
color: "text.secondary",
fontSize: "0.875rem",
fontWeight: 400,
textTransform: "none",
"&:hover": {
color: "text.primary",
textDecoration: "underline",
background: "transparent",
},
}}
>
{useBackupCode
? "Použít autentizační aplikaci"
: "Použít záložní kód"}
</button>
<button
</Button>
<Button
variant="text"
onClick={handleBack}
className="admin-back-link"
style={{
border: "none",
background: "none",
cursor: "pointer",
sx={{
color: "text.secondary",
fontSize: "0.875rem",
fontWeight: 400,
textTransform: "none",
"&:hover": {
color: "text.primary",
textDecoration: "underline",
background: "transparent",
},
}}
>
&larr; Zpět na přihlášení
</button>
</div>
</motion.div>
</Button>
</Box>
</Box>
)}
</AnimatePresence>
</motion.div>
</Box>
);
}

View File

@@ -1,53 +1,28 @@
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Button } from "../ui";
export default function NotFound() {
return (
<motion.div
className="admin-empty-state"
style={{ minHeight: "60vh", justifyContent: "center" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div
className="admin-empty-icon"
style={{ width: 80, height: 80, marginBottom: "1.5rem" }}
>
<svg
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M16 16s-1.5-2-4-2-4 2-4 2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</div>
<h2
style={{
fontSize: "1.5rem",
fontWeight: 600,
marginBottom: "0.5rem",
color: "var(--text-primary)",
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "60vh",
gap: 2,
textAlign: "center",
}}
>
<Typography variant="h1" sx={{ fontSize: "6rem", fontWeight: 700 }}>
404
</h2>
<p>Stránka nebyla nalezena.</p>
<Link
to="/"
className="admin-btn admin-btn-primary"
style={{ marginTop: "0.5rem" }}
>
</Typography>
<Typography variant="body1">Stránka nebyla nalezena.</Typography>
<Button component={RouterLink} to="/" sx={{ mt: 0.5 }}>
Zpět na Dashboard
</Link>
</motion.div>
</Button>
</Box>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,10 @@
import { useState, useCallback, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import {
offerCustomersOptions,
@@ -13,6 +12,23 @@ import {
type CustomField,
} from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
Modal,
ConfirmDialog,
Field,
TextField,
CheckboxField,
StatusChip,
PageHeader,
PageEnter,
FilterBar,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
@@ -42,6 +58,100 @@ interface CustomerForm {
vat_id: string;
}
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const SmallPlusIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
const RemoveIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
const UpIcon = (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 15l-6-6-6 6" />
</svg>
);
const DownIcon = (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
);
export default function OffersCustomers() {
const alert = useAlert();
const { hasPermission } = useAuth();
@@ -261,230 +371,195 @@ export default function OffersCustomers() {
const fullFieldOrder = getFullFieldOrder();
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
const columns: DataColumn<Customer>[] = [
{
key: "name",
header: "Název",
width: "26%",
render: (c) => (
<Box>
<Box sx={{ fontWeight: 500, color: "text.primary" }}>{c.name}</Box>
{c.street && (
<Box sx={{ fontSize: "11px", color: "text.disabled" }}>
{c.street}
</Box>
)}
</Box>
),
},
{
key: "city",
header: "Město",
width: "18%",
render: (c) => c.city || "—",
},
{
key: "company_id",
header: "IČO",
width: "13%",
mono: true,
render: (c) => c.company_id || "—",
},
{
key: "vat_id",
header: "DIČ",
width: "13%",
mono: true,
render: (c) => c.vat_id || "—",
},
{
key: "quotation_count",
header: "Nabídky",
width: "10%",
render: (c) => (
<StatusChip label={String(c.quotation_count || 0)} color="info" />
),
},
{
key: "actions",
header: "Akce",
width: "10%",
align: "right",
render: (c) => {
const cannotDelete = c.quotation_count > 0;
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Zákazníci</h1>
<p className="admin-page-subtitle">Správa zákazníků pro nabídky</p>
</div>
{hasPermission("customers.create") && (
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat zákazníka
</button>
)}
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="admin-form-input"
placeholder="Hledat zákazníky..."
/>
</div>
{filteredCustomers.length === 0 ? (
<div className="admin-empty-state">
<p>
{search
? "Žádní zákazníci odpovídající hledání."
: "Zatím nejsou žádní zákazníci."}
</p>
{!search && hasPermission("customers.create") && (
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
Přidat prvního zákazníka
</button>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Město</th>
<th>IČO</th>
<th>DIČ</th>
<th>Nabídky</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{filteredCustomers.map((customer) => (
<tr key={customer.id}>
<td>
<div
style={{
fontWeight: 500,
color: "var(--text-primary)",
}}
>
{customer.name}
</div>
{customer.street && (
<div
className="text-tertiary"
style={{ fontSize: "11px" }}
>
{customer.street}
</div>
)}
</td>
<td>{customer.city || "—"}</td>
<td>{customer.company_id || "—"}</td>
<td>{customer.vat_id || "—"}</td>
<td>
<span className="admin-badge admin-badge-info">
{customer.quotation_count || 0}
</span>
</td>
<td>
<div className="admin-table-actions">
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
{hasPermission("customers.edit") && (
<button
onClick={() => openEditModal(customer)}
className="admin-btn-icon"
title="Upravit"
<IconButton
size="small"
onClick={() => openEditModal(c)}
aria-label="Upravit"
title="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{EditIcon}
</IconButton>
)}
{hasPermission("customers.delete") && (
<button
onClick={() =>
setDeleteConfirm({ show: true, customer })
}
className="admin-btn-icon danger"
// Disabled buttons don't fire title/tooltip, so wrap in a titled
// span when delete is blocked (customer still has quotations).
<Box
component="span"
title={
customer.quotation_count > 0
? "Nelze smazat zákazníka s nabídkami"
: "Smazat"
cannotDelete ? "Nelze smazat zákazníka s nabídkami" : "Smazat"
}
>
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, customer: c })}
aria-label={
customer.quotation_count > 0
cannotDelete
? "Nelze smazat zákazníka s nabídkami"
: "Smazat"
}
disabled={customer.quotation_count > 0}
disabled={cannotDelete}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
{DeleteIcon}
</IconButton>
</Box>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
</Box>
);
},
},
];
return (
<PageEnter>
<PageHeader
title="Zákazníci"
subtitle="Správa zákazníků pro nabídky"
actions={
hasPermission("customers.create") ? (
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat zákazníka
</Button>
) : undefined
}
/>
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Hledat zákazníky..."
fullWidth
/>
</Box>
</FilterBar>
<Card>
<DataTable<Customer>
columns={columns}
rows={filteredCustomers}
rowKey={(c) => c.id}
empty={
<EmptyState
title={
search
? "Žádní zákazníci odpovídající hledání."
: "Zatím nejsou žádní zákazníci."
}
action={
!search && hasPermission("customers.create") ? (
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat prvního zákazníka
</Button>
) : undefined
}
/>
}
/>
</Card>
{/* Create/Edit Modal */}
<FormModal
<Modal
isOpen={showModal}
onClose={closeModal}
onSubmit={handleSubmit}
title={editingCustomer ? "Upravit zákazníka" : "Nový zákazník"}
submitLabel={editingCustomer ? "Uložit změny" : "Vytvořit zákazníka"}
submitText={editingCustomer ? "Uložit změny" : "Vytvořit zákazníka"}
loading={saving}
size="lg"
maxWidth="md"
>
<div className="admin-form">
<FormField label="Název" required>
<input
type="text"
<Field label="Název" required>
<TextField
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
className="admin-form-input"
placeholder="Název firmy / jméno"
/>
</FormField>
<FormField label="Ulice">
<input
type="text"
</Field>
<Field label="Ulice">
<TextField
value={form.street}
onChange={(e) =>
setForm((prev) => ({ ...prev, street: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Město">
<input
type="text"
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Město">
<TextField
value={form.city}
onChange={(e) =>
setForm((prev) => ({ ...prev, city: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
<FormField label="PSČ">
<input
type="text"
</Field>
<Field label="PSČ">
<TextField
value={form.postal_code}
onChange={(e) =>
setForm((prev) => ({
@@ -492,24 +567,26 @@ export default function OffersCustomers() {
postal_code: e.target.value,
}))
}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Země">
<input
type="text"
</Field>
</Box>
<Field label="Země">
<TextField
value={form.country}
onChange={(e) =>
setForm((prev) => ({ ...prev, country: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="IČO">
<input
type="text"
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="IČO">
<TextField
value={form.company_id}
onChange={(e) =>
setForm((prev) => ({
@@ -517,41 +594,43 @@ export default function OffersCustomers() {
company_id: e.target.value,
}))
}
className="admin-form-input"
/>
</FormField>
<FormField label="DIČ">
<input
type="text"
</Field>
<Field label="DIČ">
<TextField
value={form.vat_id}
onChange={(e) =>
setForm((prev) => ({ ...prev, vat_id: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
</div>
</Field>
</Box>
{/* Dynamic custom fields */}
<div style={{ marginTop: 4 }}>
<label
className="admin-form-label"
style={{ display: "block", marginBottom: 4 }}
<Box sx={{ mt: 0.5 }}>
<Typography
variant="body2"
sx={{
display: "block",
mb: 0.5,
fontWeight: 600,
color: "text.secondary",
}}
>
Vlastní pole
</label>
</Typography>
{customFields.map((field, idx) => (
<div key={field._key} style={{ marginBottom: 8 }}>
<div
className="admin-form-row"
style={{ marginBottom: 0, alignItems: "flex-end" }}
<Box key={field._key} sx={{ mb: 1 }}>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
alignItems: "flex-start",
}}
>
<FormField
label={idx === 0 ? "Název" : " "}
style={{ flex: 1 }}
>
<input
type="text"
<TextField
label={idx === 0 ? "Název" : undefined}
value={field.name}
onChange={(e) => {
const updated = [...customFields];
@@ -561,23 +640,17 @@ export default function OffersCustomers() {
};
setCustomFields(updated);
}}
className="admin-form-input"
placeholder="Např. Kontakt"
/>
</FormField>
<FormField
label={idx === 0 ? "Hodnota" : " "}
style={{ flex: 1 }}
>
<div
style={{
<Box
sx={{
display: "flex",
gap: 4,
alignItems: "center",
gap: 0.5,
alignItems: idx === 0 ? "flex-end" : "center",
}}
>
<input
type="text"
<TextField
label={idx === 0 ? "Hodnota" : undefined}
value={field.value}
onChange={(e) => {
const updated = [...customFields];
@@ -587,11 +660,11 @@ export default function OffersCustomers() {
};
setCustomFields(updated);
}}
className="admin-form-input"
style={{ flex: 1 }}
sx={{ flex: 1 }}
/>
<button
type="button"
<IconButton
size="small"
color="error"
onClick={() => {
const key = `custom_${idx}`;
setFieldOrder((prev) => {
@@ -605,50 +678,40 @@ export default function OffersCustomers() {
return k;
});
});
setCustomFields(
customFields.filter((_, i) => i !== idx),
);
setCustomFields(customFields.filter((_, i) => i !== idx));
}}
className="admin-btn-icon danger"
title="Odebrat pole"
aria-label="Odebrat pole"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</FormField>
</div>
<label className="admin-form-checkbox" style={{ marginTop: 4 }}>
<input
type="checkbox"
{RemoveIcon}
</IconButton>
</Box>
</Box>
<Box sx={{ mt: 0.5 }}>
<CheckboxField
label={
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
Zobrazit název v PDF
</Typography>
}
checked={field.showLabel !== false}
onChange={(e) => {
onChange={(checked) => {
const updated = [...customFields];
updated[idx] = {
...updated[idx],
showLabel: e.target.checked,
showLabel: checked,
};
setCustomFields(updated);
}}
/>
<span style={{ fontSize: "0.8rem" }}>
Zobrazit název v PDF
</span>
</label>
</div>
</Box>
</Box>
))}
<button
type="button"
<Button
variant="outlined"
color="inherit"
size="small"
startIcon={SmallPlusIcon}
onClick={() =>
setCustomFields([
...customFields,
@@ -660,90 +723,83 @@ export default function OffersCustomers() {
},
])
}
className="admin-btn admin-btn-secondary"
style={{ marginTop: 4, fontSize: "0.85rem" }}
sx={{ mt: 0.5 }}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat pole
</button>
</div>
</Button>
</Box>
{/* Field order for PDF */}
<div style={{ marginTop: 16 }}>
<label className="admin-form-label">Pořadí polí v PDF</label>
<small
className="admin-form-hint"
style={{ display: "block", marginBottom: 8 }}
<Box sx={{ mt: 2 }}>
<Typography
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Pořadí polí v PDF
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 1 }}
>
Určuje pořadí řádků v adresním bloku zákazníka na PDF nabídce.
</small>
<div className="admin-reorder-list">
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
{fullFieldOrder.map((key, index) => (
<div key={key} className="admin-reorder-item">
<div className="admin-reorder-arrows">
<button
type="button"
<Box
key={key}
sx={{
display: "flex",
alignItems: "center",
gap: 1,
px: 1,
py: 0.5,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Box sx={{ display: "flex", flexDirection: "column" }}>
<IconButton
size="small"
onClick={() => moveField(index, -1)}
disabled={index === 0}
className="admin-btn-icon"
title="Nahoru"
aria-label="Nahoru"
sx={{ p: 0.25 }}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
<button
type="button"
{UpIcon}
</IconButton>
<IconButton
size="small"
onClick={() => moveField(index, 1)}
disabled={index === fullFieldOrder.length - 1}
className="admin-btn-icon"
title="Dolů"
aria-label="Dolů"
sx={{ p: 0.25 }}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
</div>
<span
className={`admin-reorder-label${key.startsWith("custom_") ? " accent" : ""}`}
{DownIcon}
</IconButton>
</Box>
<Typography
variant="body2"
sx={{
color: key.startsWith("custom_")
? "primary.main"
: "text.primary",
fontWeight: key.startsWith("custom_") ? 600 : 400,
}}
>
{getFieldDisplayName(key)}
</span>
</div>
</Typography>
</Box>
))}
</div>
</div>
</div>
</FormModal>
</Box>
</Box>
</Modal>
{/* Delete Confirm Modal */}
<ConfirmModal
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, customer: null })}
onConfirm={handleDelete}
@@ -751,9 +807,9 @@ export default function OffersCustomers() {
message={`Opravdu chcete smazat zákazníka "${deleteConfirm.customer?.name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
confirmVariant="danger"
loading={deleting}
/>
</div>
</PageEnter>
);
}

View File

@@ -1,11 +1,10 @@
import { useState, useRef, type ReactNode } from "react";
import { useState, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import RichEditor from "../components/RichEditor";
import {
@@ -16,8 +15,25 @@ import {
type ScopeSection,
} from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
import {
Button,
Card,
DataTable,
Modal,
ConfirmDialog,
Field,
TextField,
StatusChip,
PageHeader,
PageEnter,
EmptyState,
LoadingState,
Tabs,
TabPanel,
type DataColumn,
type TabDef,
} from "../ui";
const API_BASE = "/api/admin";
@@ -33,45 +49,118 @@ interface ScopeForm {
sections: ScopeSection[];
}
const PlusIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
const UpIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 15l-6-6-6 6" />
</svg>
);
const DownIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
);
const RemoveIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
export default function OffersTemplates() {
const { hasPermission } = useAuth();
const [activeTab, setActiveTab] = useState<"items" | "scopes">("items");
if (!hasPermission("settings.templates")) return <Forbidden />;
const tabs: TabDef[] = [
{ value: "items", label: "Šablony položek" },
{ value: "scopes", label: "Šablony rozsahu" },
];
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Šablony</h1>
<p className="admin-page-subtitle">
Šablony položek a rozsahu projektu
</p>
</div>
</motion.div>
<PageEnter>
<PageHeader
title="Šablony"
subtitle="Šablony položek a rozsahu projektu"
/>
<div className="admin-tabs mb-4">
<button
className={`admin-tab ${activeTab === "items" ? "active" : ""}`}
onClick={() => setActiveTab("items")}
>
Šablony položek
</button>
<button
className={`admin-tab ${activeTab === "scopes" ? "active" : ""}`}
onClick={() => setActiveTab("scopes")}
>
Šablony rozsahu
</button>
</div>
<Tabs
value={activeTab}
onChange={(v) => setActiveTab(v as "items" | "scopes")}
tabs={tabs}
/>
{activeTab === "items" ? <ItemTemplatesTab /> : <ScopeTemplatesTab />}
</div>
<TabPanel value="items" current={activeTab}>
<ItemTemplatesTab />
</TabPanel>
<TabPanel value="scopes" current={activeTab}>
<ScopeTemplatesTab />
</TabPanel>
</PageEnter>
);
}
@@ -169,155 +258,135 @@ function ItemTemplatesTab() {
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">
Šablony položek ({templates.length})
</h3>
<button
onClick={openCreate}
className="admin-btn admin-btn-primary admin-btn-sm"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat
</button>
</div>
<div className="admin-card-body">
{templates.length === 0 ? (
<div className="admin-empty-state">
<p>Zatím žádné šablony položek.</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Popis</th>
<th>Cena</th>
<th>Kategorie</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t.id}>
<td className="fw-500">{t.name}</td>
<td style={{ color: "var(--text-secondary)" }}>
const columns: DataColumn<ItemTemplate>[] = [
{
key: "name",
header: "Název",
width: "26%",
bold: true,
render: (t) => t.name,
},
{
key: "description",
header: "Popis",
width: "30%",
render: (t) => (
<Box component="span" sx={{ color: "text.secondary" }}>
{t.description || "—"}
</td>
<td>{Number(t.default_price).toFixed(2)}</td>
<td style={{ color: "var(--text-secondary)" }}>
</Box>
),
},
{
key: "default_price",
header: "Cena",
width: "14%",
mono: true,
render: (t) => Number(t.default_price).toFixed(2),
},
{
key: "category",
header: "Kategorie",
width: "18%",
render: (t) => (
<Box component="span" sx={{ color: "text.secondary" }}>
{t.category || "—"}
</td>
<td>
<div className="admin-table-actions">
<button
</Box>
),
},
{
key: "actions",
header: "Akce",
width: "12%",
align: "right",
render: (t) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => openEdit(t)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
title="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() =>
setDeleteConfirm({ show: true, template: t })
}
className="admin-btn-icon danger"
title="Smazat"
{EditIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, template: t })}
aria-label="Smazat"
title="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
{DeleteIcon}
</IconButton>
</Box>
),
},
];
return (
<Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Šablony položek ({templates.length})
</Typography>
<Button size="small" startIcon={PlusIcon} onClick={openCreate}>
Přidat
</Button>
</Box>
<Card>
<DataTable<ItemTemplate>
columns={columns}
rows={templates}
rowKey={(t) => t.id}
empty={<EmptyState title="Zatím žádné šablony položek." />}
/>
</Card>
{/* Item Template Modal */}
<FormModal
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingTemplate ? "Upravit šablonu" : "Nová šablona položky"}
submitLabel={editingTemplate ? "Uložit" : "Vytvořit"}
submitText={editingTemplate ? "Uložit" : "Vytvořit"}
loading={saving}
maxWidth="md"
>
<div className="admin-form">
<FormField label="Název" required>
<input
type="text"
<Field label="Název" required>
<TextField
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
className="admin-form-input"
/>
</FormField>
<FormField label="Popis">
<textarea
</Field>
<Field label="Popis">
<TextField
value={form.description}
onChange={(e) =>
setForm((p) => ({
...p,
description: e.target.value,
}))
setForm((p) => ({ ...p, description: e.target.value }))
}
className="admin-form-input"
multiline
rows={2}
/>
</FormField>
<div className="admin-form-row">
<FormField label="Výchozí cena">
<input
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Výchozí cena">
<TextField
type="number"
value={form.default_price}
onChange={(e) =>
@@ -326,26 +395,21 @@ function ItemTemplatesTab() {
default_price: parseFloat(e.target.value),
}))
}
className="admin-form-input"
step="0.01"
inputMode="decimal"
slotProps={{ htmlInput: { step: "0.01", inputMode: "decimal" } }}
/>
</FormField>
<FormField label="Kategorie">
<input
type="text"
</Field>
<Field label="Kategorie">
<TextField
value={form.category}
onChange={(e) =>
setForm((p) => ({ ...p, category: e.target.value }))
}
className="admin-form-input"
/>
</FormField>
</div>
</div>
</FormModal>
</Field>
</Box>
</Modal>
<ConfirmModal
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, template: null })}
onConfirm={handleDelete}
@@ -353,10 +417,10 @@ function ItemTemplatesTab() {
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
confirmVariant="danger"
loading={deleting}
/>
</>
</Box>
);
}
@@ -533,268 +597,256 @@ function ScopeTemplatesTab() {
}
};
return (
<>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">
Šablony rozsahu ({templates.length})
</h3>
<button
onClick={openCreate}
className="admin-btn admin-btn-primary admin-btn-sm"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat
</button>
</div>
<div className="admin-card-body">
{templates.length === 0 ? (
<div className="admin-empty-state">
<p>Zatím žádné šablony rozsahu.</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t.id}>
<td className="fw-500">{t.name}</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEdit(t)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() =>
setDeleteConfirm({ show: true, template: t })
if (isPending) {
return <LoadingState />;
}
className="admin-btn-icon danger"
title="Smazat"
const columns: DataColumn<ScopeTemplate>[] = [
{
key: "name",
header: "Název",
width: "80%",
bold: true,
render: (t) => t.name,
},
{
key: "actions",
header: "Akce",
width: "20%",
align: "right",
render: (t) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => openEdit(t)}
aria-label="Upravit"
title="Upravit"
>
{EditIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, template: t })}
aria-label="Smazat"
title="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
{DeleteIcon}
</IconButton>
</Box>
),
},
];
return (
<Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
Šablony rozsahu ({templates.length})
</Typography>
<Button size="small" startIcon={PlusIcon} onClick={openCreate}>
Přidat
</Button>
</Box>
<Card>
<DataTable<ScopeTemplate>
columns={columns}
rows={templates}
rowKey={(t) => t.id}
empty={<EmptyState title="Zatím žádné šablony rozsahu." />}
/>
</Card>
{/* Scope Template Modal (large) */}
<FormModal
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={
editingTemplate ? "Upravit šablonu rozsahu" : "Nová šablona rozsahu"
}
submitLabel={editingTemplate ? "Uložit" : "Vytvořit"}
size="lg"
submitText={editingTemplate ? "Uložit" : "Vytvořit"}
maxWidth="lg"
loading={saving}
>
<div className="admin-form">
<FormField label="Název šablony" required>
<input
type="text"
<Field label="Název šablony" required>
<TextField
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
className="admin-form-input"
/>
</FormField>
</Field>
<div className="admin-form-group">
<label className="admin-form-label mb-2">Sekce</label>
<div className="admin-scope-list">
<Box>
<Typography
component="label"
variant="body2"
sx={{
display: "block",
mb: 1,
fontWeight: 600,
color: "text.secondary",
}}
>
Sekce
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{form.sections.map((section, index) => (
<div key={section._key} className="admin-scope-section">
<div className="admin-scope-section-header">
<span className="admin-scope-number">{index + 1}.</span>
<span className="admin-scope-title">
{section.title ||
section.title_cz ||
`Sekce ${index + 1}`}
</span>
<div className="admin-scope-actions">
<button
type="button"
<Box
key={section._key}
sx={{
border: 1,
borderColor: "divider",
borderRadius: 2,
p: 2,
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mb: 1.5,
}}
>
<Typography
variant="body2"
sx={{ fontWeight: 700, color: "text.secondary" }}
>
{index + 1}.
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, flex: 1 }}
noWrap
>
{section.title || section.title_cz || `Sekce ${index + 1}`}
</Typography>
<IconButton
size="small"
onClick={() => moveSection(index, -1)}
disabled={index === 0}
className="admin-btn-icon"
title="Posunout nahoru"
aria-label="Posunout nahoru"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
<button
type="button"
{UpIcon}
</IconButton>
<IconButton
size="small"
onClick={() => moveSection(index, 1)}
disabled={index === form.sections.length - 1}
className="admin-btn-icon"
title="Posunout dolů"
aria-label="Posunout dolů"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{DownIcon}
</IconButton>
{form.sections.length > 1 && (
<button
type="button"
<IconButton
size="small"
color="error"
onClick={() => removeSection(index)}
className="admin-btn-icon danger"
title="Odebrat"
aria-label="Odebrat"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{RemoveIcon}
</IconButton>
)}
</div>
</div>
<div className="admin-form">
<div className="admin-form-row">
<FormField
label={
<>
<span className="offers-lang-badge">EN</span> Název
sekce
</>
}
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<input
type="text"
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
mb: 0.5,
}}
>
<StatusChip label="EN" color="info" />
<Typography
component="label"
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Název sekce
</Typography>
</Box>
<TextField
value={section.title}
onChange={(e) =>
updateSection(index, "title", e.target.value)
}
className="admin-form-input"
placeholder="Název sekce (anglicky)"
/>
</FormField>
<FormField
label={
<>
<span className="offers-lang-badge offers-lang-badge-cz">
CZ
</span>{" "}
Název sekce
</>
}
</Box>
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
mb: 0.5,
}}
>
<input
type="text"
<StatusChip label="CZ" color="success" />
<Typography
component="label"
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Název sekce
</Typography>
</Box>
<TextField
value={section.title_cz}
onChange={(e) =>
updateSection(index, "title_cz", e.target.value)
}
className="admin-form-input"
placeholder="Název sekce (česky)"
/>
</FormField>
</div>
<FormField label="Obsah">
</Box>
</Box>
<Field label="Obsah">
<RichEditor
value={section.content}
onChange={(val) => updateSection(index, "content", val)}
placeholder="Obsah sekce..."
minHeight="150px"
/>
</FormField>
</div>
</div>
</Field>
</Box>
))}
</div>
<div style={{ marginTop: "0.75rem" }}>
<button
type="button"
</Box>
<Box sx={{ mt: 1.5 }}>
<Button
variant="outlined"
color="inherit"
size="small"
startIcon={PlusIcon}
onClick={addSection}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
+ Přidat sekci
</button>
</div>
</div>
</div>
</FormModal>
Přidat sekci
</Button>
</Box>
</Box>
</Modal>
<ConfirmModal
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, template: null })}
onConfirm={handleDelete}
@@ -802,9 +854,9 @@ function ScopeTemplatesTab() {
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
confirmVariant="danger"
loading={deleting}
/>
</>
</Box>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,15 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link, useNavigate } from "react-router-dom";
import apiFetch from "../utils/api";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
orderListOptions,
@@ -20,7 +17,26 @@ import {
} from "../lib/queries/orders";
import { offerCustomersOptions } from "../lib/queries/offers";
import { useApiMutation } from "../lib/queries/mutations";
import Pagination from "../components/Pagination";
import {
Button,
Card,
DataTable,
Pagination,
Modal,
ConfirmDialog,
Field,
TextField,
Select,
StatusChip,
CheckboxField,
FileUpload,
PageHeader,
PageEnter,
FilterBar,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
@@ -31,11 +47,14 @@ const STATUS_LABELS: Record<string, string> = {
stornovana: "Stornována",
};
const STATUS_CLASSES: Record<string, string> = {
prijata: "admin-badge-order-prijata",
v_realizaci: "admin-badge-order-realizace",
dokoncena: "admin-badge-order-dokoncena",
stornovana: "admin-badge-order-stornovana",
const STATUS_COLORS: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
prijata: "info",
v_realizaci: "warning",
dokoncena: "success",
stornovana: "error",
};
interface Order {
@@ -51,12 +70,94 @@ interface Order {
invoice_id?: number;
}
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const ViewIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
);
const InvoiceViewIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<text
x="12"
y="16.5"
textAnchor="middle"
fill="currentColor"
stroke="none"
fontSize="9"
fontWeight="700"
>
F
</text>
</svg>
);
const InvoiceCreateIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
export default function Orders() {
const alert = useAlert();
const { hasPermission } = useAuth();
const { sort, order, handleSort, activeSort } = useTableSort("order_number");
const { sort, order, handleSort } = useTableSort("order_number");
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const [page, setPage] = useState(1);
const [deleteConfirm, setDeleteConfirm] = useState<{
@@ -204,7 +305,9 @@ export default function Orders() {
pagination,
isPending,
isFetching,
} = usePaginatedQuery<Order>(orderListOptions({ search, sort, order, page }));
} = usePaginatedQuery<Order>(
orderListOptions({ search: debouncedSearch, sort, order, page }),
);
if (!hasPermission("orders.view")) return <Forbidden />;
@@ -221,281 +324,195 @@ export default function Orders() {
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Objednávky</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? orders.length}{" "}
{czechPlural(
pagination?.total ?? orders.length,
const total = pagination?.total ?? orders.length;
const subtitle = `${total} ${czechPlural(
total,
"objednávka",
"objednávky",
"objednávek",
)}
</p>
</div>
{hasPermission("orders.create") && (
<div className="admin-page-actions">
<button
onClick={openCreate}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Vytvořit objednávku
</button>
</div>
)}
</motion.div>
)}`;
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
const columns: DataColumn<Order>[] = [
{
key: "order_number",
header: "Číslo",
width: "14%",
sortKey: "order_number",
mono: true,
render: (o) => (
<Box
component={RouterLink}
to={`/orders/${o.id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
{o.order_number}
</Box>
),
},
{
key: "quotation",
header: "Nabídka",
width: "14%",
render: (o) => (
<Box
component={RouterLink}
to={`/offers/${o.quotation_id}`}
sx={{
color: "text.secondary",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{o.quotation_number}
</Box>
),
},
{
key: "customer",
header: "Zákazník",
width: "20%",
render: (o) => o.customer_name || "—",
},
{
key: "status",
header: "Stav",
width: "13%",
sortKey: "status",
render: (o) => (
<StatusChip
label={STATUS_LABELS[o.status] || o.status}
color={STATUS_COLORS[o.status] || "default"}
/>
),
},
{
key: "created_at",
header: "Datum",
width: "12%",
sortKey: "created_at",
mono: true,
render: (o) => formatDate(o.created_at),
},
{
key: "total",
header: "Celkem",
width: "13%",
align: "right",
mono: true,
bold: true,
render: (o) => formatCurrency(o.total, o.currency),
},
{
key: "actions",
header: "Akce",
width: "14%",
align: "right",
render: (o) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => navigate(`/orders/${o.id}`)}
aria-label="Detail"
title="Detail"
>
{ViewIcon}
</IconButton>
{o.invoice_id ? (
<IconButton
size="small"
color="primary"
onClick={() => navigate(`/invoices/${o.invoice_id}`)}
aria-label="Zobrazit fakturu"
title="Zobrazit fakturu"
>
{InvoiceViewIcon}
</IconButton>
) : (
hasPermission("invoices.create") && (
<IconButton
size="small"
onClick={() => navigate(`/invoices/new?fromOrder=${o.id}`)}
aria-label="Vytvořit fakturu"
title="Vytvořit fakturu"
>
{InvoiceCreateIcon}
</IconButton>
)
)}
{hasPermission("orders.delete") && (
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, order: o })}
aria-label="Smazat"
title="Smazat"
>
{DeleteIcon}
</IconButton>
)}
</Box>
),
},
];
return (
<PageEnter>
<PageHeader
title="Objednávky"
subtitle={subtitle}
actions={
hasPermission("orders.create") ? (
<Button startIcon={PlusIcon} onClick={openCreate}>
Vytvořit objednávku
</Button>
) : undefined
}
/>
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="admin-form-input"
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
fullWidth
/>
</div>
</Box>
</FilterBar>
{orders.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
</div>
<p>Zatím nejsou žádné objednávky.</p>
<p className="text-tertiary" style={{ fontSize: "0.875rem" }}>
Objednávky se vytvářejí z nabídek.
</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("order_number")}
>
Číslo{" "}
<SortIcon
column="order_number"
sort={activeSort}
order={order}
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
<DataTable<Order>
columns={columns}
rows={orders}
rowKey={(o) => o.id}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
<EmptyState
title="Zatím nejsou žádné objednávky."
description="Objednávky se vytvářejí z nabídek."
/>
</th>
<th>Nabídka</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("created_at")}
>
Datum{" "}
<SortIcon
column="created_at"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{(orders as Order[]).map((o) => (
<tr key={o.id}>
<td className="admin-mono">
<Link to={`/orders/${o.id}`} className="link-accent">
{o.order_number}
</Link>
</td>
<td>
<Link
to={`/offers/${o.quotation_id}`}
className="text-secondary"
style={{ textDecoration: "none" }}
>
{o.quotation_number}
</Link>
</td>
<td>{o.customer_name || "—"}</td>
<td>
<span
className={`admin-badge ${STATUS_CLASSES[o.status] || ""}`}
>
{STATUS_LABELS[o.status] || o.status}
</span>
</td>
<td className="admin-mono">{formatDate(o.created_at)}</td>
<td className="admin-mono text-right fw-500">
{formatCurrency(o.total, o.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/orders/${o.id}`}
className="admin-btn-icon"
title="Detail"
aria-label="Detail"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</Link>
{o.invoice_id ? (
<Link
to={`/invoices/${o.invoice_id}`}
className="admin-btn-icon accent"
title="Zobrazit fakturu"
aria-label="Zobrazit fakturu"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<text
x="12"
y="16.5"
textAnchor="middle"
fill="currentColor"
stroke="none"
fontSize="9"
fontWeight="700"
>
F
</text>
</svg>
</Link>
) : (
hasPermission("invoices.create") && (
<Link
to={`/invoices/new?fromOrder=${o.id}`}
className="admin-btn-icon"
title="Vytvořit fakturu"
aria-label="Vytvořit fakturu"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
</Link>
)
)}
{hasPermission("orders.delete") && (
<button
onClick={() =>
setDeleteConfirm({ show: true, order: o })
}
className="admin-btn-icon danger"
title="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
<ConfirmModal
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => {
setDeleteConfirm({ show: false, order: null });
@@ -504,58 +521,55 @@ export default function Orders() {
onConfirm={handleDelete}
title="Smazat objednávku"
message={
<>
Opravdu chcete smazat objednávku &quot;
{deleteConfirm.order?.order_number}&quot;? Bude smazán i přidružený
projekt. Tato akce je nevratná.
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory projektu na disku</span>
</label>
</>
deleteConfirm.order
? `Opravdu chcete smazat objednávku${deleteConfirm.order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`
: ""
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
confirmVariant="danger"
loading={deleteMutation.isPending}
>
<CheckboxField
label="Smazat i soubory projektu na disku"
checked={deleteFiles}
onChange={setDeleteFiles}
/>
</ConfirmDialog>
<FormModal
<Modal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSubmit={handleCreate}
title="Vytvořit objednávku bez nabídky"
submitLabel="Vytvořit"
subtitle={`Číslo objednávky: ${
nextNumberQuery.data?.number ??
nextNumberQuery.data?.next_number ??
"…"
} (přiděleno automaticky)`}
submitText="Vytvořit"
loading={creating}
>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Zákazník">
<select
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Zákazník">
<Select
value={createForm.customer_id}
onChange={(e) =>
setCreateForm({ ...createForm, customer_id: e.target.value })
onChange={(value) =>
setCreateForm({ ...createForm, customer_id: value })
}
className="admin-form-input"
>
<option value=""> bez zákazníka </option>
{(customersQuery.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</FormField>
<FormField label="Číslo objednávky zákazníka">
<input
type="text"
options={[
{ value: "", label: "— bez zákazníka —" },
...(customersQuery.data ?? []).map((c) => ({
value: String(c.id),
label: c.name,
})),
]}
/>
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Číslo objednávky zákazníka">
<TextField
value={createForm.customer_order_number}
onChange={(e) =>
setCreateForm({
@@ -563,83 +577,86 @@ export default function Orders() {
customer_order_number: e.target.value,
})
}
className="admin-form-input"
/>
</FormField>
</div>
</Field>
</Box>
</Box>
<div className="admin-form-row">
<FormField label="Měna">
<select
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Měna">
<Select
value={createForm.currency}
onChange={(e) =>
setCreateForm({ ...createForm, currency: e.target.value })
onChange={(value) =>
setCreateForm({ ...createForm, currency: value })
}
className="admin-form-input"
>
<option value="CZK">CZK</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</FormField>
<FormField label="Sazba DPH (%)">
<input
options={[
{ value: "CZK", label: "CZK" },
{ value: "EUR", label: "EUR" },
{ value: "USD", label: "USD" },
{ value: "GBP", label: "GBP" },
]}
/>
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Sazba DPH (%)">
<TextField
type="number"
inputMode="numeric"
value={createForm.vat_rate}
onChange={(e) =>
setCreateForm({ ...createForm, vat_rate: e.target.value })
}
className="admin-form-input"
min="0"
slotProps={{ htmlInput: { min: 0 } }}
/>
</FormField>
</div>
</Field>
</Box>
</Box>
<div className="admin-form-row">
<FormField label="Cena za jednotku (bez DPH)">
<input
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Cena za jednotku (bez DPH)">
<TextField
type="number"
inputMode="decimal"
value={createForm.price}
onChange={(e) =>
setCreateForm({ ...createForm, price: e.target.value })
}
className="admin-form-input"
min="0"
step="0.01"
placeholder="0"
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
/>
</FormField>
<FormField label="Množství">
<input
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Množství">
<TextField
type="number"
inputMode="decimal"
value={createForm.quantity}
onChange={(e) =>
setCreateForm({ ...createForm, quantity: e.target.value })
}
className="admin-form-input"
min="0"
step="1"
slotProps={{ htmlInput: { min: 0, step: 1 } }}
/>
</FormField>
</div>
</Field>
</Box>
</Box>
<FormField label="Předmět (scope)">
<input
type="text"
<Field label="Předmět (scope)">
<TextField
value={createForm.scope_title}
onChange={(e) =>
setCreateForm({ ...createForm, scope_title: e.target.value })
}
className="admin-form-input"
/>
</FormField>
</Field>
<FormField label="Popis">
<textarea
<Field label="Popis">
<TextField
multiline
minRows={3}
value={createForm.scope_description}
onChange={(e) =>
setCreateForm({
@@ -647,109 +664,41 @@ export default function Orders() {
scope_description: e.target.value,
})
}
className="admin-form-input"
rows={3}
/>
</FormField>
</Field>
<FormField label="Poznámka">
<textarea
<Field label="Poznámka">
<TextField
multiline
minRows={2}
value={createForm.notes}
onChange={(e) =>
setCreateForm({ ...createForm, notes: e.target.value })
}
className="admin-form-input"
rows={2}
/>
</FormField>
</Field>
<FormField label="Příloha (PO zákazníka, PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<span className="text-md">
{orderAttachment.name}{" "}
<span className="text-tertiary">
({(orderAttachment.size / 1024).toFixed(0)} KB)
</span>
</span>
<button
type="button"
onClick={() => setOrderAttachment(null)}
className="admin-btn-icon"
title="Odebrat"
style={{ marginLeft: "auto" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
) : (
<label
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
>
Vybrat soubor
<input
type="file"
<Field label="Příloha (PO zákazníka, PDF)">
<FileUpload
files={orderAttachment ? [orderAttachment] : []}
onFilesChange={(files) => setOrderAttachment(files[0] ?? null)}
accept="application/pdf"
onChange={(e) =>
setOrderAttachment(e.target.files?.[0] || null)
}
style={{ display: "none" }}
multiple={false}
/>
</label>
)}
</FormField>
</Field>
<label className="admin-form-checkbox">
<input
type="checkbox"
<CheckboxField
label="Účtovat DPH"
checked={createForm.apply_vat}
onChange={(e) =>
setCreateForm({ ...createForm, apply_vat: e.target.checked })
}
onChange={(v) => setCreateForm({ ...createForm, apply_vat: v })}
/>
<span>Účtovat DPH</span>
</label>
<label className="admin-form-checkbox">
<input
type="checkbox"
<CheckboxField
label="Vytvořit propojený projekt"
checked={createForm.create_project}
onChange={(e) =>
setCreateForm({
...createForm,
create_project: e.target.checked,
})
}
onChange={(v) => setCreateForm({ ...createForm, create_project: v })}
/>
<span>Vytvořit propojený projekt</span>
</label>
<p className="admin-form-hint">
Číslo objednávky:{" "}
<strong>
{nextNumberQuery.data?.number ??
nextNumberQuery.data?.next_number ??
"…"}
</strong>{" "}
(přiděleno automaticky)
</p>
</div>
</FormModal>
</div>
</Modal>
</PageEnter>
);
}

View File

@@ -1,6 +1,8 @@
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { motion, AnimatePresence } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import {
@@ -13,6 +15,7 @@ import PlanGrid from "../components/PlanGrid";
import PlanCellModal from "../components/PlanCellModal";
import PlanCategoriesModal from "../components/PlanCategoriesModal";
import Forbidden from "../components/Forbidden";
import { Button, Alert, LoadingState, PageEnter } from "../ui";
import { projectListOptions, type Project } from "../lib/queries/projects";
import { planCategoriesOptions, type PlanCategory } from "../lib/queries/plan";
// plan.css is imported once globally in AdminApp.tsx — no per-page re-import.
@@ -542,127 +545,109 @@ export default function PlanWork() {
}
return (
<div className="plan-work-page">
<motion.h1
className="admin-page-title"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<PageEnter sx={{ position: "relative" }}>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
Plán prací
</motion.h1>
</Typography>
{!canEdit && (
<motion.div
className="plan-banner"
role="status"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<span className="plan-banner-icon" aria-hidden>
i
</span>
<span>
<Box role="status" sx={{ mb: 2 }}>
<Alert severity="info">
<strong>Režim náhledu</strong> můžete pouze prohlížet plán. Úpravy
jsou dostupné oprávněným uživatelům.
</span>
</motion.div>
</Alert>
</Box>
)}
<motion.div
className="plan-toolbar"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
<Box
sx={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: 1.5,
mb: 2,
}}
>
{/* Nav buttons grouped so they stay on one row when the toolbar
stacks on mobile (see .plan-toolbar-nav in plan.css). On desktop
the wrapper is an inline-flex with the same 12px gap, so the
layout is visually identical. */}
<div className="plan-toolbar-nav">
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={goToToday}
>
wraps on mobile. */}
<Box sx={{ display: "inline-flex", gap: 1 }}>
<Button variant="outlined" color="inherit" onClick={goToToday}>
Dnes
</button>
<button
type="button"
className="admin-btn admin-btn-secondary"
</Button>
<Button
variant="outlined"
color="inherit"
onClick={goPrev}
aria-label="Předchozí"
>
</button>
<button
type="button"
className="admin-btn admin-btn-secondary"
</Button>
<Button
variant="outlined"
color="inherit"
onClick={goNext}
aria-label="Další"
>
</button>
</div>
<span className="plan-range-label-slot">
</Button>
</Box>
<Box
sx={{
flex: 1,
minWidth: 220,
display: "flex",
justifyContent: "center",
}}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
className="plan-range-label"
<motion.div
key={`${isoDate(range.from)}-${view}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<Typography
variant="h6"
sx={{ fontWeight: 600, whiteSpace: "nowrap" }}
>
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
</motion.span>
</Typography>
</motion.div>
</AnimatePresence>
</span>
<div
className="plan-view-toggle"
</Box>
<Box
role="group"
aria-label="Měřítko zobrazení"
sx={{ display: "inline-flex", gap: 1 }}
>
<button
type="button"
className={
view === "week"
? "admin-btn admin-btn-primary"
: "admin-btn admin-btn-secondary"
}
<Button
variant={view === "week" ? "contained" : "outlined"}
color={view === "week" ? "primary" : "inherit"}
onClick={() => setViewWithAnim("week")}
>
Týden
</button>
<button
type="button"
className={
view === "month"
? "admin-btn admin-btn-primary"
: "admin-btn admin-btn-secondary"
}
</Button>
<Button
variant={view === "month" ? "contained" : "outlined"}
color={view === "month" ? "primary" : "inherit"}
onClick={() => setViewWithAnim("month")}
>
Měsíc
</button>
</div>
</Button>
</Box>
{canEdit && (
<button
type="button"
className="admin-btn admin-btn-secondary plan-toolbar-cat"
<Button
variant="outlined"
color="inherit"
onClick={() => setCatModalOpen(true)}
>
Správa kategorií
</button>
</Button>
)}
</motion.div>
</Box>
{gridLoading && (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{gridLoading && <LoadingState />}
{/*
Grid entrance: same pattern as the h1 / banner / toolbar above
@@ -681,11 +666,6 @@ export default function PlanWork() {
patch the cache in place and pulse a single cell via pulseKey.
*/}
{grid ? (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, ease: "easeOut", delay: 0.18 }}
>
<PlanGrid
data={grid}
projects={projects}
@@ -694,7 +674,6 @@ export default function PlanWork() {
pulseKey={lastMutated}
onCellClick={openCell}
/>
</motion.div>
) : null}
<PlanCellModal
@@ -718,6 +697,6 @@ export default function PlanWork() {
onClose={() => setCatModalOpen(false)}
categories={categories}
/>
</div>
</PageEnter>
);
}

View File

@@ -3,8 +3,11 @@ import { useQuery } from "@tanstack/react-query";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem";
import {
projectDetailOptions,
@@ -13,11 +16,21 @@ import {
} from "../lib/queries/projects";
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
import Forbidden from "../components/Forbidden";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker";
import ProjectFileManager from "../components/ProjectFileManager";
import apiFetch from "../utils/api";
import {
Button,
Card,
Field,
TextField,
Select,
DateField,
StatusChip,
CheckboxField,
ConfirmDialog,
LoadingState,
PageEnter,
headerActionsSx,
} from "../ui";
const API_BASE = "/api/admin";
@@ -27,6 +40,15 @@ const STATUS_LABELS: Record<string, string> = {
zruseny: "Zrušený",
};
const STATUS_COLORS: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
aktivni: "success",
dokonceny: "info",
zruseny: "default",
};
function formatNoteDate(dateStr: string) {
if (!dateStr) return "";
const d = new Date(dateStr);
@@ -51,6 +73,36 @@ interface ProjectForm {
responsible_user_id: string;
}
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
const TrashIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
);
export default function ProjectDetail() {
const { id } = useParams();
const alert = useAlert();
@@ -226,424 +278,362 @@ export default function ProjectDetail() {
}
};
if (isPending) {
return <LoadingState />;
}
if (!project) return null;
const canEdit = hasPermission("projects.edit");
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<div>
<PageEnter>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
<Box>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<Link
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Button
component={RouterLink}
to="/projects"
className="admin-btn-icon"
title="Zpět"
aria-label="Zpět"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">
Zpět
</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Typography variant="h4">
Projekt {project.project_number}
</h1>
</div>
</div>
</Typography>
<StatusChip
label={STATUS_LABELS[project.status] || project.status}
color={STATUS_COLORS[project.status] || "default"}
/>
</Box>
</Box>
{canEdit && (
<div className="admin-page-actions">
<button
onClick={handleSave}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{saving ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : (
"Uložit"
)}
</button>
<Box sx={headerActionsSx}>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Ukládání..." : "Uložit"}
</Button>
{!project.order_id && (
<button
<Button
variant="outlined"
color="error"
onClick={() => setDeleteConfirm(true)}
className="admin-btn admin-btn-primary"
>
Smazat
</button>
</Button>
)}
</div>
</Box>
)}
</motion.div>
</Box>
</Box>
{/* Form */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Číslo projektu">
<input
type="text"
value={project.project_number}
className="admin-form-input"
readOnly
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
{/* Basic info form */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
/>
</FormField>
<FormField label="Název">
<input
type="text"
>
<Field label="Číslo projektu">
<TextField value={project.project_number} fullWidth disabled />
</Field>
<Field label="Název">
<TextField
value={form.name}
onChange={(e) => updateForm("name", e.target.value)}
className="admin-form-input"
placeholder="Název projektu"
fullWidth
disabled={!canEdit}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Zákazník">
<input
type="text"
</Field>
<Field label="Zákazník">
<TextField
value={project.customer_name || "—"}
className="admin-form-input"
readOnly
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
fullWidth
disabled
/>
</FormField>
<FormField label="Zodpovědná osoba">
<select
</Field>
<Field label="Zodpovědná osoba">
<Select
value={form.responsible_user_id}
onChange={(e) =>
updateForm("responsible_user_id", e.target.value)
}
className="admin-form-select"
onChange={(v) => updateForm("responsible_user_id", v)}
disabled={!canEdit}
>
<option value=""> Nevybráno </option>
<MenuItem value=""> Nevybráno </MenuItem>
{users.map((u) => (
<option key={u.id} value={u.id}>
<MenuItem key={u.id} value={String(u.id)}>
{u.name}
</option>
</MenuItem>
))}
</select>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3">
<FormField label="Stav">
<select
</Select>
</Field>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Field label="Stav">
<Select
value={form.status}
onChange={(e) => updateForm("status", e.target.value)}
className="admin-form-select"
onChange={(v) => updateForm("status", v)}
disabled={!canEdit}
>
<option value="aktivni">Aktivní</option>
<option value="dokonceny">Dokončený</option>
<option value="zruseny">Zrušený</option>
</select>
</FormField>
<FormField label="Datum zahájení">
<AdminDatePicker
mode="date"
<MenuItem value="aktivni">Aktivní</MenuItem>
<MenuItem value="dokonceny">Dokončený</MenuItem>
<MenuItem value="zruseny">Zrušený</MenuItem>
</Select>
</Field>
<Field label="Datum zahájení">
<DateField
value={form.start_date}
onChange={(val: string) => updateForm("start_date", val)}
onChange={(val) => updateForm("start_date", val)}
disabled={!canEdit}
/>
</FormField>
<FormField label="Datum ukončení">
<AdminDatePicker
mode="date"
</Field>
<Field label="Datum ukončení">
<DateField
value={form.end_date}
onChange={(val: string) => updateForm("end_date", val)}
onChange={(val) => updateForm("end_date", val)}
disabled={!canEdit}
/>
</FormField>
</div>
</div>
</div>
</motion.div>
</Field>
</Box>
</Card>
{/* Notes */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Poznámky</h3>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Poznámky
</Typography>
{/* Add note */}
<div className="mb-4">
<textarea
<Box sx={{ mb: 3 }}>
<TextField
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
className="admin-form-input"
rows={2}
multiline
minRows={2}
placeholder="Napište poznámku..."
style={{ resize: "vertical", width: "100%" }}
fullWidth
onKeyDown={(e) => {
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
handleAddNote();
}
}}
/>
<div className="mt-2">
<button
<Box sx={{ mt: 1 }}>
<Button
variant="outlined"
color="inherit"
onClick={handleAddNote}
className="admin-btn admin-btn-secondary admin-btn-sm"
disabled={addingNote || !newNote.trim()}
>
{addingNote ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
"Přidat poznámku"
)}
</button>
</div>
</div>
{addingNote ? "Přidávání..." : "Přidat poznámku"}
</Button>
</Box>
</Box>
{/* Legacy notes (read-only) */}
{project.notes && (
<div
style={{
padding: "0.75rem",
background: "var(--bg-secondary)",
borderRadius: "0.5rem",
marginBottom: "0.5rem",
fontSize: "0.85rem",
color: "var(--text-secondary)",
<Box
sx={{
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
mb: 1,
}}
>
<div
style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)",
marginBottom: "0.25rem",
}}
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 0.5 }}
>
Starší poznámka (před zavedením systému)
</div>
<div style={{ whiteSpace: "pre-wrap" }}>{project.notes}</div>
</div>
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ whiteSpace: "pre-wrap" }}
>
{project.notes}
</Typography>
</Box>
)}
{/* Notes list */}
{notes.length === 0 && !project.notes && (
<div
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
textAlign: "center",
padding: "1rem 0",
}}
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: "center", py: 2 }}
>
Zatím žádné poznámky
</div>
</Typography>
)}
{(notes.length > 0 || project.notes) && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.5rem",
}}
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{notes.map((note) => (
<div
<Box
key={note.id}
style={{
padding: "0.75rem",
background: "var(--bg-secondary)",
borderRadius: "0.5rem",
sx={{
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
position: "relative",
}}
>
<div
style={{
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "0.5rem",
gap: 1,
}}
>
<div className="flex-1">
<div
style={{
<Box sx={{ flex: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "0.25rem",
gap: 1,
mb: 0.5,
}}
>
<span style={{ fontWeight: 600, fontSize: "0.85rem" }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{note.user_name}
</span>
<span
style={{
color: "var(--text-tertiary)",
fontSize: "0.75rem",
}}
>
</Typography>
<Typography variant="caption" color="text.secondary">
{formatNoteDate(note.created_at)}
</span>
</div>
<div
style={{
whiteSpace: "pre-wrap",
fontSize: "0.875rem",
lineHeight: 1.5,
}}
</Typography>
</Box>
<Typography
variant="body2"
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
>
{note.content}
</div>
</div>
</Typography>
</Box>
{isAdmin && (
<button
<IconButton
size="small"
color="error"
onClick={() => handleDeleteNote(note.id)}
className="admin-btn-icon"
title="Smazat poznámku"
aria-label="Smazat poznámku"
disabled={deletingNoteId === note.id}
style={{
flexShrink: 0,
opacity: deletingNoteId === note.id ? 0.5 : 1,
}}
sx={{ flexShrink: 0 }}
>
{deletingNoteId === note.id ? (
<div
className="admin-spinner"
style={{ width: 14, height: 14, borderWidth: 2 }}
/>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
{TrashIcon}
</IconButton>
)}
</button>
)}
</div>
</div>
</Box>
</Box>
))}
</div>
</Box>
)}
</div>
</motion.div>
</Card>
{/* Project File Manager */}
<motion.div
style={{ marginBottom: "1rem" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<Box sx={{ mb: 2 }}>
<ProjectFileManager
projectId={project.id}
projectNumber={project.project_number}
hasPermission={hasPermission}
hasNasFolder={project.has_nas_folder ?? false}
/>
</motion.div>
</Box>
{/* Links */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Propojení
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Propojení</h3>
<div className="admin-form-row">
<FormField label="Objednávka">
<div>
<Box>
<Typography variant="caption" color="text.secondary">
Objednávka
</Typography>
<Typography variant="body2">
{project.order_id ? (
<Link
<Box
component={RouterLink}
to={`/orders/${project.order_id}`}
className="link-accent"
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{project.order_number}
{project.order_status && (
<span
className="text-tertiary"
style={{ fontWeight: 400, marginLeft: "0.5rem" }}
<Box
component="span"
sx={{ color: "text.secondary", ml: 1 }}
>
(
{STATUS_LABELS[project.order_status] ||
project.order_status}
)
</span>
</Box>
)}
</Link>
</Box>
) : (
"—"
)}
</div>
</FormField>
<FormField label="Nabídka">
<div>
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Nabídka
</Typography>
<Typography variant="body2">
{project.quotation_id ? (
<Link
<Box
component={RouterLink}
to={`/offers/${project.quotation_id}`}
className="link-accent"
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{project.quotation_number}
</Link>
</Box>
) : (
"—"
)}
</div>
</FormField>
</div>
</div>
</motion.div>
</Typography>
</Box>
</Box>
</Card>
<ConfirmModal
<ConfirmDialog
isOpen={deleteConfirm}
onClose={() => {
setDeleteConfirm(false);
@@ -651,30 +641,19 @@ export default function ProjectDetail() {
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={
<>
Opravdu chcete smazat projekt &quot;{project.project_number} {" "}
{project.name}&quot;? Tato akce je nevratná.
{project.has_nas_folder && (
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
)}
</>
}
message={`Opravdu chcete smazat projekt „${project.project_number} ${project.name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
confirmVariant="danger"
loading={deleting}
>
{project.has_nas_folder && (
<CheckboxField
label="Smazat i soubory na disku"
checked={deleteFiles}
onChange={setDeleteFiles}
/>
</div>
)}
</ConfirmDialog>
</PageEnter>
);
}

View File

@@ -1,17 +1,13 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker";
import { formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import { formatDate } from "../utils/formatters";
import useTableSort from "../hooks/useTableSort";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
@@ -21,7 +17,24 @@ import {
import { offerCustomersOptions } from "../lib/queries/offers";
import { userListOptions } from "../lib/queries/users";
import { useApiMutation } from "../lib/queries/mutations";
import Pagination from "../components/Pagination";
import {
Button,
Card,
DataTable,
Pagination,
Modal,
ConfirmDialog,
Field,
FilterBar,
TextField,
Select,
DateField,
StatusChip,
CheckboxField,
LoadingState,
PageEnter,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
@@ -31,10 +44,13 @@ const STATUS_LABELS: Record<string, string> = {
zruseny: "Zrušený",
};
const STATUS_CLASSES: Record<string, string> = {
aktivni: "admin-badge-project-aktivni",
dokonceny: "admin-badge-project-dokonceny",
zruseny: "admin-badge-project-zruseny",
const STATUS_COLORS: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
aktivni: "success",
dokonceny: "info",
zruseny: "default",
};
interface Project {
@@ -50,12 +66,56 @@ interface Project {
order_number?: string;
}
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
export default function Projects() {
const alert = useAlert();
const navigate = useNavigate();
const { hasPermission } = useAuth();
const { sort, order, handleSort, activeSort } =
useTableSort("project_number");
const { sort, order, handleSort } = useTableSort("project_number");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
@@ -176,267 +236,190 @@ export default function Projects() {
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
const columns: DataColumn<Project>[] = [
{
key: "project_number",
header: "Číslo",
width: "12%",
sortKey: "project_number",
mono: true,
render: (p) => (
<Box
component={RouterLink}
to={`/projects/${p.id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
<div>
<h1 className="admin-page-title">Projekty</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? projects.length}{" "}
{czechPlural(
pagination?.total ?? projects.length,
"projekt",
"projekty",
"projektů",
{p.project_number}
</Box>
),
},
{
key: "name",
header: "Název",
width: "16%",
sortKey: "name",
bold: true,
render: (p) => p.name || "—",
},
{
key: "customer",
header: "Zákazník",
width: "13%",
render: (p) => p.customer_name || "—",
},
{
key: "responsible",
header: "Zodpovědná osoba",
width: "14%",
render: (p) => p.responsible_user_name || "—",
},
{
key: "status",
header: "Stav",
width: "10%",
sortKey: "status",
render: (p) => (
<StatusChip
label={STATUS_LABELS[p.status] || p.status}
color={STATUS_COLORS[p.status] || "default"}
/>
),
},
{
key: "order",
header: "Objednávka",
width: "12%",
render: (p) =>
p.order_id ? (
<Box
component={RouterLink}
to={`/orders/${p.order_id}`}
sx={{
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
}}
>
{p.order_number}
</Box>
) : (
"—"
),
},
{
key: "start_date",
header: "Začátek",
width: "8%",
mono: true,
render: (p) => formatDate(p.start_date),
},
{
key: "end_date",
header: "Konec",
width: "8%",
mono: true,
render: (p) => formatDate(p.end_date),
},
{
key: "actions",
header: "Akce",
width: "7%",
align: "right",
render: (p) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => navigate(`/projects/${p.id}`)}
aria-label="Upravit"
title="Upravit"
>
{EditIcon}
</IconButton>
{hasPermission("projects.delete") && !p.order_id && (
<IconButton
size="small"
color="error"
onClick={() => setDeleteTarget(p)}
aria-label="Smazat"
title="Smazat projekt"
>
{DeleteIcon}
</IconButton>
)}
</p>
</div>
{hasPermission("projects.create") && (
<div className="admin-page-actions">
<button
onClick={openCreate}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat projekt
</button>
</div>
)}
</motion.div>
</Box>
),
},
];
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
return (
<PageEnter>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 3,
flexWrap: "wrap",
gap: 2,
}}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
<Typography variant="h4">Projekty</Typography>
{hasPermission("projects.create") && (
<Button startIcon={PlusIcon} onClick={openCreate}>
Přidat projekt
</Button>
)}
</Box>
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="admin-form-input"
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
fullWidth
/>
</div>
</Box>
</FilterBar>
{projects.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</div>
<p>Zatím nejsou žádné projekty.</p>
<p
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
}}
>
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
<DataTable<Project>
columns={columns}
rows={projects}
rowKey={(p) => p.id}
sortBy={sort}
sortDir={order}
onSort={handleSort}
empty={
<Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom>
Zatím nejsou žádné projekty.
</Typography>
<Typography variant="body2" color="text.secondary">
Projekty vznikají z objednávek nebo je můžete vytvořit ručně
tlačítkem Přidat projekt".
</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("project_number")}
>
Číslo{" "}
<SortIcon
column="project_number"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("name")}
>
Název{" "}
<SortIcon column="name" sort={activeSort} order={order} />
</th>
<th>Zákazník</th>
<th>Zodpovědná osoba</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("start_date")}
>
Začátek{" "}
<SortIcon
column="start_date"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("end_date")}
>
Konec{" "}
<SortIcon
column="end_date"
sort={activeSort}
order={order}
/>
</th>
<th>Typ</th>
<th>Objednávka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{(projects as Project[]).map((p) => (
<tr key={p.id}>
<td className="admin-mono">
<Link to={`/projects/${p.id}`} className="link-accent">
{p.project_number}
</Link>
</td>
<td className="fw-500">{p.name || ""}</td>
<td>{p.customer_name || ""}</td>
<td>{p.responsible_user_name || ""}</td>
<td>
<span
className={`admin-badge ${STATUS_CLASSES[p.status] || ""}`}
>
{STATUS_LABELS[p.status] || p.status}
</span>
</td>
<td className="admin-mono">{formatDate(p.start_date)}</td>
<td className="admin-mono">{formatDate(p.end_date)}</td>
<td>
<span className="admin-badge">
{p.order_id ? "Z objednávky" : "Samostatný"}
</span>
</td>
<td>
{p.order_id ? (
<Link
to={`/orders/${p.order_id}`}
className="text-secondary"
style={{ textDecoration: "none" }}
>
{p.order_number}
</Link>
) : (
""
)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/projects/${p.id}`}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</Link>
{!p.order_id && hasPermission("projects.delete") && (
<button
onClick={() => setDeleteTarget(p)}
className="admin-btn-icon danger"
title="Smazat projekt"
disabled={
deleteMutation.isPending &&
deleteTarget?.id === p.id
</Typography>
</Box>
}
>
{deleteMutation.isPending &&
deleteTarget?.id === p.id ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
)}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
<ConfirmModal
<ConfirmDialog
isOpen={!!deleteTarget}
onClose={() => {
setDeleteTarget(null);
@@ -445,133 +428,119 @@ export default function Projects() {
onConfirm={handleDelete}
title="Smazat projekt"
message={
<>
Opravdu chcete smazat projekt {deleteTarget?.project_number}?
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
</>
deleteTarget
? `Opravdu chcete smazat projekt ${deleteTarget.project_number}?`
: ""
}
confirmText="Smazat"
type="danger"
confirmVariant="danger"
loading={deleteMutation.isPending}
>
<CheckboxField
label="Smazat i soubory na disku"
checked={deleteFiles}
onChange={setDeleteFiles}
/>
</ConfirmDialog>
<FormModal
<Modal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSubmit={handleCreate}
title="Přidat projekt"
submitLabel="Vytvořit"
subtitle={`Číslo projektu: ${
nextNumberQuery.data?.number ??
nextNumberQuery.data?.next_number ??
""
} (přiděleno automaticky)`}
submitText="Vytvořit"
loading={creating}
>
<div className="admin-form">
<FormField label="Název" error={createErrors.name} required>
<input
type="text"
<Field label="Název" required error={createErrors.name}>
<TextField
value={createForm.name}
placeholder="Název projektu"
error={!!createErrors.name}
onChange={(e) => {
setCreateForm({ ...createForm, name: e.target.value });
setCreateErrors((p) => ({ ...p, name: "" }));
}}
className="admin-form-input"
placeholder="Název projektu"
aria-invalid={!!createErrors.name}
/>
</FormField>
</Field>
<div className="admin-form-row">
<FormField label="Zákazník">
<select
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Zákazník">
<Select
value={createForm.customer_id}
onChange={(e) =>
setCreateForm({ ...createForm, customer_id: e.target.value })
onChange={(value) =>
setCreateForm({ ...createForm, customer_id: value })
}
className="admin-form-input"
>
<option value="">— bez zákazníka —</option>
{(customersQuery.data ?? []).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</FormField>
<FormField label="Zodpovědná osoba">
<select
options={[
{ value: "", label: " bez zákazníka " },
...(customersQuery.data ?? []).map((c) => ({
value: String(c.id),
label: c.name,
})),
]}
/>
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Zodpovědná osoba">
<Select
value={createForm.responsible_user_id}
onChange={(e) =>
setCreateForm({
...createForm,
responsible_user_id: e.target.value,
})
onChange={(value) =>
setCreateForm({ ...createForm, responsible_user_id: value })
}
className="admin-form-input"
>
<option value="">— nepřiřazeno —</option>
{(usersQuery.data ?? [])
options={[
{ value: "", label: " nepřiřazeno " },
...(usersQuery.data ?? [])
.filter((u) => u.is_active)
.map((u) => (
<option key={u.id} value={u.id}>
{u.first_name} {u.last_name}
</option>
))}
</select>
</FormField>
</div>
.map((u) => ({
value: String(u.id),
label: `${u.first_name} ${u.last_name}`,
})),
]}
/>
</Field>
</Box>
</Box>
<div className="admin-form-row">
<FormField label="Začátek">
<AdminDatePicker
mode="date"
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Začátek">
<DateField
value={createForm.start_date}
onChange={(val) =>
setCreateForm({ ...createForm, start_date: val })
}
/>
</FormField>
<FormField label="Konec">
<AdminDatePicker
mode="date"
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Konec">
<DateField
value={createForm.end_date}
onChange={(val) =>
setCreateForm({ ...createForm, end_date: val })
}
/>
</FormField>
</div>
</Field>
</Box>
</Box>
<FormField label="Poznámka">
<textarea
<Field label="Poznámka">
<TextField
multiline
minRows={3}
value={createForm.notes}
onChange={(e) =>
setCreateForm({ ...createForm, notes: e.target.value })
}
className="admin-form-input"
rows={3}
/>
</FormField>
<p className="admin-form-hint">
Číslo projektu:{" "}
<strong>
{nextNumberQuery.data?.number ??
nextNumberQuery.data?.next_number ??
""}
</strong>{" "}
(přiděleno automaticky)
</p>
</div>
</FormModal>
</div>
</Field>
</Modal>
</PageEnter>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,11 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm, todayLocalStr } from "../utils/formatters";
@@ -17,9 +14,25 @@ import {
tripListOptions,
tripVehiclesOptions,
type BackendTrip,
type TripVehicle,
} from "../lib/queries/trips";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
Modal,
ConfirmDialog,
Field,
TextField,
Select,
DateField,
StatusChip,
LoadingState,
StatCard,
PageEnter,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
interface TripForm {
@@ -33,6 +46,114 @@ interface TripForm {
notes: string;
}
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
const TripsIcon = (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
);
const TotalIcon = (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
);
const BusinessIcon = (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
);
const PrivateIcon = (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
export default function Trips() {
const alert = useAlert();
const { hasPermission } = useAuth();
@@ -197,11 +318,7 @@ export default function Trips() {
};
if (tripsLoading) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
const totals = trips.reduce(
@@ -216,440 +333,341 @@ export default function Trips() {
{ total: 0, business: 0, private: 0, count: 0 },
);
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
const recentTrips = trips.slice(0, 10);
const columns: DataColumn<BackendTrip>[] = [
{
key: "trip_date",
header: "Datum",
width: "12%",
mono: true,
render: (trip) => formatDate(trip.trip_date),
},
{
key: "vehicle",
header: "Vozidlo",
width: "12%",
render: (trip) => (
<StatusChip label={trip.vehicles?.spz ?? ""} color="default" />
),
},
{
key: "driver",
header: "Řidič",
width: "16%",
render: (trip) =>
trip.users ? `${trip.users.first_name} ${trip.users.last_name}` : "",
},
{
key: "route",
header: "Trasa",
width: "22%",
render: (trip) => (
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</Box>
),
},
{
key: "distance",
header: "Vzdálenost",
width: "12%",
mono: true,
bold: true,
render: (trip) =>
`${formatKm(trip.distance ?? trip.end_km - trip.start_km)} km`,
},
{
key: "type",
header: "Typ",
width: "12%",
render: (trip) => (
<StatusChip
label={trip.is_business ? "Služební" : "Soukromá"}
color={trip.is_business ? "success" : "warning"}
/>
),
},
{
key: "actions",
header: "Akce",
width: "10%",
align: "right",
render: (trip) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => openEditModal(trip)}
aria-label="Upravit"
title="Upravit"
>
<div>
<h1 className="admin-page-title">Kniha jízd</h1>
<p className="admin-page-subtitle">
{EditIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, tripId: trip.id })}
aria-label="Smazat"
title="Smazat"
>
{DeleteIcon}
</IconButton>
</Box>
),
},
];
return (
<PageEnter>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<Box>
<Typography variant="h4">Kniha jízd</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{new Date().toLocaleDateString("cs-CZ", {
month: "long",
year: "numeric",
})}
</p>
</div>
<div className="admin-page-actions">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</Typography>
</Box>
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat jízdu
</button>
</div>
</motion.div>
</Button>
</Box>
{/* Stats Cards */}
<motion.div
className="admin-grid admin-grid-4"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "1fr 1fr",
lg: "repeat(4, 1fr)",
},
gap: 2,
}}
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{totals.count}</span>
<span className="admin-stat-label">Počet jízd</span>
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">
{formatKm(totals.total)} km
</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">
{formatKm(totals.business)} km
</span>
<span className="admin-stat-label">Služební</span>
</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-icon warning">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">
{formatKm(totals.private)} km
</span>
<span className="admin-stat-label">Soukromé</span>
</div>
</div>
</motion.div>
<StatCard
label="Počet jízd"
value={totals.count}
icon={TripsIcon}
color="info"
/>
<StatCard
label="Celkem naježděno"
value={`${formatKm(totals.total)} km`}
icon={TotalIcon}
color="default"
/>
<StatCard
label="Služební"
value={`${formatKm(totals.business)} km`}
icon={BusinessIcon}
color="success"
/>
<StatCard
label="Soukromé"
value={`${formatKm(totals.private)} km`}
icon={PrivateIcon}
color="warning"
/>
</Box>
{/* Recent Trips */}
<motion.div
className="admin-card mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
<Card sx={{ mt: 3 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 2,
}}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Poslední jízdy</h2>
<Link
<Typography variant="h6">Poslední jízdy</Typography>
<Button
component={RouterLink}
to="/trips/history"
className="admin-btn admin-btn-secondary admin-btn-sm"
variant="outlined"
color="inherit"
size="small"
>
Zobrazit historii
</Link>
</div>
<div className="admin-card-body">
{trips.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Zatím nemáte žádné záznamy jízd.</p>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
</Button>
</Box>
<DataTable<BackendTrip>
columns={columns}
rows={recentTrips}
rowKey={(trip) => trip.id}
empty={
<Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom>
Zatím nemáte žádné záznamy jízd.
</Typography>
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat první jízdu
</button>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Řidič</th>
<th>Trasa</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{trips.slice(0, 10).map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>
<span className="admin-badge">
{trip.vehicles?.spz ?? ""}
</span>
</td>
<td>
{trip.users
? `${trip.users.first_name} ${trip.users.last_name}`
: ""}
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<strong>
{formatKm(
trip.distance ?? trip.end_km - trip.start_km,
)}{" "}
km
</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(trip)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() =>
setDeleteConfirm({ show: true, tripId: trip.id })
</Button>
</Box>
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
/>
</Card>
{/* Add/Edit Modal */}
<FormModal
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
loading={submitMutation.isPending}
size="lg"
maxWidth="md"
>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Vozidlo" error={errors.vehicle_id} required>
<select
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Vozidlo" required error={errors.vehicle_id}>
<Select
value={form.vehicle_id}
onChange={(e) => {
handleVehicleChange(e.target.value);
onChange={(value) => {
handleVehicleChange(value);
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
}}
className="admin-form-select"
>
<option value="">Vyberte vozidlo</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
error={!!errors.vehicle_id}
options={[
{ value: "", label: "Vyberte vozidlo" },
...vehicles.map((v) => ({
value: String(v.id),
label: `${v.spz} - ${v.name}`,
})),
]}
/>
</Field>
<FormField label="Datum jízdy" error={errors.trip_date} required>
<AdminDatePicker
mode="date"
<Field label="Datum jízdy" required error={errors.trip_date}>
<DateField
value={form.trip_date}
onChange={(val: string) => {
onChange={(val) => {
setForm({ ...form, trip_date: val });
setErrors((prev) => ({ ...prev, trip_date: "" }));
}}
/>
</FormField>
</div>
</Field>
</Box>
<div className="admin-form-row admin-form-row-3">
<FormField
label="Počáteční stav km"
error={errors.start_km}
required
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<input
<Field label="Počáteční stav km" required error={errors.start_km}>
<TextField
type="number"
inputMode="numeric"
value={form.start_km}
error={!!errors.start_km}
onChange={(e) => {
setForm({ ...form, start_km: e.target.value });
setErrors((prev) => ({ ...prev, start_km: "" }));
}}
className="admin-form-input"
min="0"
slotProps={{ htmlInput: { min: 0 } }}
/>
</FormField>
</Field>
<FormField label="Konečný stav km" error={errors.end_km} required>
<input
<Field label="Konečný stav km" required error={errors.end_km}>
<TextField
type="number"
inputMode="numeric"
value={form.end_km}
error={!!errors.end_km}
onChange={(e) => {
setForm({ ...form, end_km: e.target.value });
setErrors((prev) => ({ ...prev, end_km: "" }));
}}
className="admin-form-input"
min="0"
slotProps={{ htmlInput: { min: 0 } }}
/>
</FormField>
</Field>
<FormField label="Vzdálenost">
<input
<Field label="Vzdálenost">
<TextField
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
slotProps={{ htmlInput: { readOnly: true } }}
/>
</FormField>
</div>
</Field>
</Box>
<div className="admin-form-row">
<FormField label="Místo odjezdu" error={errors.route_from} required>
<input
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Místo odjezdu" required error={errors.route_from}>
<TextField
type="text"
value={form.route_from}
error={!!errors.route_from}
onChange={(e) => {
setForm({ ...form, route_from: e.target.value });
setErrors((prev) => ({ ...prev, route_from: "" }));
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
</FormField>
</Field>
<FormField label="Místo příjezdu" error={errors.route_to} required>
<input
<Field label="Místo příjezdu" required error={errors.route_to}>
<TextField
type="text"
value={form.route_to}
error={!!errors.route_to}
onChange={(e) => {
setForm({ ...form, route_to: e.target.value });
setErrors((prev) => ({ ...prev, route_to: "" }));
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
</FormField>
</div>
</Field>
</Box>
<FormField label="Typ jízdy">
<select
value={form.is_business}
onChange={(e) =>
setForm({
...form,
is_business: parseInt(e.target.value),
})
<Field label="Typ jízdy">
<Select
value={String(form.is_business)}
onChange={(value) =>
setForm({ ...form, is_business: parseInt(value) })
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
options={[
{ value: "1", label: "Služební" },
{ value: "0", label: "Soukromá" },
]}
/>
</Field>
<FormField label="Poznámky">
<textarea
<Field label="Poznámky">
<TextField
multiline
minRows={2}
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
/>
</FormField>
</div>
</FormModal>
</Field>
</Modal>
<ConfirmModal
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, tripId: null })}
onConfirm={() => handleDelete(deleteConfirm.tripId!)}
@@ -657,9 +675,9 @@ export default function Trips() {
message="Opravdu chcete smazat tento záznam?"
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
</div>
</PageEnter>
);
}

View File

@@ -1,30 +1,42 @@
import { useState, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import {
tripListOptions,
tripVehiclesOptions,
tripUsersOptions,
type BackendTrip,
type TripVehicle,
type TripUser,
} from "../lib/queries/trips";
import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
import { companySettingsOptions } from "../lib/queries/settings";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
Modal,
ConfirmDialog,
Field,
TextField,
Select,
DateField,
MonthField,
StatusChip,
FilterBar,
LoadingState,
EmptyState,
StatCard,
PageEnter,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
interface Trip {
@@ -71,15 +83,109 @@ function mapTrip(bt: BackendTrip): Trip {
};
}
const PrintIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
);
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
const TripsIcon = (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
);
const TotalIcon = (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
);
const BusinessIcon = (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
);
export default function TripsAdmin() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [filterMonth, setFilterMonth] = useState(() =>
String(new Date().getMonth() + 1),
);
const [filterYear, setFilterYear] = useState(() =>
String(new Date().getFullYear()),
);
const [filterPeriod, setFilterPeriod] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [filterVehicleId, setFilterVehicleId] = useState("");
const [filterUserId, setFilterUserId] = useState("");
const printRef = useRef<HTMLDivElement>(null);
@@ -113,8 +219,8 @@ export default function TripsAdmin() {
const { data: tripsData, isPending } = useQuery(
tripListOptions({
month: Number(filterMonth) || undefined,
year: Number(filterYear) || undefined,
month: Number(filterPeriod.slice(5, 7)) || undefined,
year: Number(filterPeriod.slice(0, 4)) || undefined,
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
userId: filterUserId ? Number(filterUserId) : undefined,
perPage: 100,
@@ -212,10 +318,10 @@ export default function TripsAdmin() {
};
const getPeriodName = () =>
new Date(Number(filterYear), Number(filterMonth) - 1).toLocaleString(
"cs-CZ",
{ month: "long", year: "numeric" },
);
new Date(
Number(filterPeriod.slice(0, 4)),
Number(filterPeriod.slice(5, 7)) - 1,
).toLocaleString("cs-CZ", { month: "long", year: "numeric" });
const getSelectedVehicleName = () => {
if (!filterVehicleId) return null;
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
@@ -331,463 +437,352 @@ export default function TripsAdmin() {
.reduce((sum, t) => sum + t.distance, 0),
};
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
const columns: DataColumn<Trip>[] = [
{
key: "trip_date",
header: "Datum",
width: "11%",
mono: true,
render: (trip) => formatDate(trip.trip_date),
},
{
key: "driver",
header: "Řidič",
width: "16%",
render: (trip) => trip.driver_name,
},
{
key: "vehicle",
header: "Vozidlo",
width: "11%",
mono: true,
render: (trip) => <StatusChip label={trip.spz} color="default" />,
},
{
key: "route",
header: "Trasa",
width: "20%",
render: (trip) => (
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</Box>
),
},
{
key: "km",
header: "Stav km",
width: "14%",
mono: true,
render: (trip) => (
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</Box>
),
},
{
key: "distance",
header: "Vzdálenost",
width: "12%",
mono: true,
bold: true,
render: (trip) => `${formatKm(trip.distance)} km`,
},
{
key: "type",
header: "Typ",
width: "10%",
render: (trip) => (
<StatusChip
label={trip.is_business ? "Služební" : "Soukromá"}
color={trip.is_business ? "success" : "warning"}
/>
),
},
{
key: "actions",
header: "Akce",
width: "10%",
align: "right",
render: (trip) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => openEditModal(trip)}
aria-label="Upravit"
title="Upravit"
>
<div>
<h1 className="admin-page-title">Správa knihy jízd</h1>
</div>
<div className="admin-page-actions">
{EditIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, trip })}
aria-label="Smazat"
title="Smazat"
>
{DeleteIcon}
</IconButton>
</Box>
),
},
];
return (
<PageEnter>
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<Typography variant="h4">Správa knihy jízd</Typography>
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{trips.length > 0 && (
<button
<Button
variant="outlined"
color="inherit"
startIcon={PrintIcon}
onClick={handlePrint}
className="admin-btn admin-btn-secondary"
title="Tisk knihy jízd"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginRight: "0.5rem" }}
>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
Tisk
</button>
</Button>
)}
<Link to="/vehicles" className="admin-btn admin-btn-secondary">
<Button
component={RouterLink}
to="/vehicles"
variant="outlined"
color="inherit"
>
Vozidla
</Link>
</div>
</motion.div>
</Button>
</Box>
</Box>
{/* Filters */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row admin-form-row-4">
<FormField label="Měsíc" style={{ marginBottom: 0 }}>
<select
value={filterMonth}
onChange={(e) => setFilterMonth(e.target.value)}
className="admin-form-select"
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>
{new Date(2000, i).toLocaleString("cs-CZ", {
month: "long",
})}
</option>
))}
</select>
</FormField>
<FormField label="Rok" style={{ marginBottom: 0 }}>
<select
value={filterYear}
onChange={(e) => setFilterYear(e.target.value)}
className="admin-form-select"
>
{Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i;
return (
<option key={y} value={y}>
{y}
</option>
);
})}
</select>
</FormField>
<FormField label="Vozidlo" style={{ marginBottom: 0 }}>
<select
<FilterBar>
<Box sx={{ flex: "0 0 180px" }}>
<MonthField value={filterPeriod} onChange={setFilterPeriod} />
</Box>
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={filterVehicleId}
onChange={(e) => setFilterVehicleId(e.target.value)}
className="admin-form-select"
>
<option value="">Všechna vozidla</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<FormField label="Řidič" style={{ marginBottom: 0 }}>
<select
onChange={setFilterVehicleId}
options={[
{ value: "", label: "Všechna vozidla" },
...vehicles.map((v) => ({
value: String(v.id),
label: `${v.spz} - ${v.name}`,
})),
]}
/>
</Box>
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
className="admin-form-select"
>
<option value="">Všichni řidiči</option>
{tripUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</FormField>
</div>
</div>
</motion.div>
onChange={setFilterUserId}
options={[
{ value: "", label: "Všichni řidiči" },
...tripUsers.map((u) => ({
value: String(u.id),
label: u.name,
})),
]}
/>
</Box>
</FilterBar>
<motion.div
className="admin-grid admin-grid-3 mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
{/* Stats Cards */}
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(3, 1fr)",
},
gap: 2,
}}
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{totals.count}</span>
<span className="admin-stat-label">Počet jízd</span>
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">
{formatKm(totals.total)} km
</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">
{formatKm(totals.business)} km
</span>
<span className="admin-stat-label">Služební km</span>
</div>
</div>
</motion.div>
<StatCard
label="Počet jízd"
value={totals.count}
icon={TripsIcon}
color="info"
/>
<StatCard
label="Celkem naježděno"
value={`${formatKm(totals.total)} km`}
icon={TotalIcon}
color="default"
/>
<StatCard
label="Služební km"
value={`${formatKm(totals.business)} km`}
icon={BusinessIcon}
color="success"
/>
</Box>
{/* Trips Table */}
<motion.div
className="admin-card mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<Card sx={{ mt: 3 }}>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
<LoadingState />
) : (
<>
{trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
)}
{trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Řidič</th>
<th>Vozidlo</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>{trip.driver_name}</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span style={{ whiteSpace: "nowrap" }}>
{formatKm(trip.start_km)} -{" "}
{formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(trip)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() =>
setDeleteConfirm({ show: true, trip })
<DataTable<Trip>
columns={columns}
rows={trips}
rowKey={(trip) => trip.id}
empty={
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
/>
)}
</>
)}
</div>
</motion.div>
</Card>
{/* Edit Modal */}
<FormModal
<Modal
isOpen={showEditModal && !!editingTrip}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
title="Upravit jízdu"
subtitle={editingTrip?.driver_name}
loading={editMutation.isPending}
size="lg"
maxWidth="lg"
>
{editingTrip && (
<div className="admin-form">
<p
className="text-secondary"
style={{ marginTop: "0", marginBottom: "0.75rem" }}
<>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
{editingTrip.driver_name}
</p>
<div className="admin-form-row">
<FormField label="Vozidlo">
<select
<Field label="Vozidlo">
<Select
value={editForm.vehicle_id}
onChange={(e) =>
setEditForm({
...editForm,
vehicle_id: e.target.value,
})
onChange={(value) =>
setEditForm({ ...editForm, vehicle_id: value })
}
className="admin-form-select"
>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
options={vehicles.map((v) => ({
value: String(v.id),
label: `${v.spz} - ${v.name}`,
}))}
/>
</Field>
<FormField label="Datum jízdy">
<AdminDatePicker
mode="date"
<Field label="Datum jízdy">
<DateField
value={editForm.trip_date}
onChange={(val: string) =>
onChange={(val) =>
setEditForm({ ...editForm, trip_date: val })
}
/>
</FormField>
</div>
</Field>
</Box>
<div className="admin-form-row">
<FormField label="Počáteční stav km">
<input
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Field label="Počáteční stav km">
<TextField
type="number"
inputMode="numeric"
value={editForm.start_km}
onChange={(e) =>
setEditForm({ ...editForm, start_km: e.target.value })
}
className="admin-form-input"
min="0"
slotProps={{ htmlInput: { min: 0 } }}
/>
</FormField>
</Field>
<FormField label="Konečný stav km">
<input
<Field label="Konečný stav km">
<TextField
type="number"
inputMode="numeric"
value={editForm.end_km}
onChange={(e) =>
setEditForm({ ...editForm, end_km: e.target.value })
}
className="admin-form-input"
min="0"
slotProps={{ htmlInput: { min: 0 } }}
/>
</FormField>
</Field>
<FormField label="Vzdálenost">
<input
<Field label="Vzdálenost">
<TextField
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
slotProps={{ htmlInput: { readOnly: true } }}
/>
</FormField>
</div>
</Field>
</Box>
<div className="admin-form-row">
<FormField label="Místo odjezdu">
<input
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Místo odjezdu">
<TextField
type="text"
value={editForm.route_from}
onChange={(e) =>
setEditForm({
...editForm,
route_from: e.target.value,
})
setEditForm({ ...editForm, route_from: e.target.value })
}
className="admin-form-input"
/>
</FormField>
</Field>
<FormField label="Místo příjezdu">
<input
<Field label="Místo příjezdu">
<TextField
type="text"
value={editForm.route_to}
onChange={(e) =>
setEditForm({ ...editForm, route_to: e.target.value })
}
className="admin-form-input"
/>
</FormField>
</div>
</Field>
</Box>
<FormField label="Typ jízdy">
<select
value={editForm.is_business}
onChange={(e) =>
setEditForm({
...editForm,
is_business: parseInt(e.target.value),
})
<Field label="Typ jízdy">
<Select
value={String(editForm.is_business)}
onChange={(value) =>
setEditForm({ ...editForm, is_business: parseInt(value) })
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
options={[
{ value: "1", label: "Služební" },
{ value: "0", label: "Soukromá" },
]}
/>
</Field>
<FormField label="Poznámky">
<textarea
<Field label="Poznámky">
<TextField
multiline
minRows={2}
value={editForm.notes}
onChange={(e) =>
setEditForm({ ...editForm, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
/>
</FormField>
</div>
</Field>
</>
)}
</FormModal>
</Modal>
{/* Delete Confirmation */}
<ConfirmModal
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, trip: null })}
onConfirm={handleDelete}
@@ -915,6 +910,6 @@ export default function TripsAdmin() {
</table>
</div>
)}
</div>
</PageEnter>
);
}

View File

@@ -1,19 +1,25 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import Box from "@mui/material/Box";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import FormField from "../components/FormField";
import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips";
import {
tripHistoryOptions,
tripVehiclesOptions,
type BackendTrip,
type TripVehicle,
} from "../lib/queries/trips";
Card,
DataTable,
Select,
MonthField,
StatusChip,
PageHeader,
PageEnter,
FilterBar,
EmptyState,
LoadingState,
StatCard,
type DataColumn,
} from "../ui";
interface Trip {
id: number;
@@ -29,8 +35,56 @@ interface Trip {
notes?: string;
}
const TripsIcon = (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
);
const TotalIcon = (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
);
const BusinessIcon = (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
);
export default function TripsHistory() {
const alert = useAlert();
const { user, hasPermission } = useAuth();
const [month, setMonth] = useState(() => {
const now = new Date();
@@ -48,7 +102,7 @@ export default function TripsHistory() {
userId: user?.id,
}),
);
const trips = (tripsData ?? []).map((t) => ({
const trips: Trip[] = (tripsData ?? []).map((t) => ({
id: t.id,
trip_date: t.trip_date,
spz: t.vehicles?.spz ?? "",
@@ -81,222 +135,156 @@ export default function TripsHistory() {
return date.toLocaleDateString("cs-CZ", { month: "long", year: "numeric" });
};
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
const columns: DataColumn<Trip>[] = [
{
key: "trip_date",
header: "Datum",
width: "11%",
mono: true,
render: (trip) => formatDate(trip.trip_date),
},
{
key: "vehicle",
header: "Vozidlo",
width: "10%",
render: (trip) => <StatusChip label={trip.spz} color="default" />,
},
{
key: "driver",
header: "Řidič",
width: "14%",
render: (trip) => (
<Box component="span" sx={{ color: "text.secondary" }}>
{trip.driver_name}
</Box>
),
},
{
key: "route",
header: "Trasa",
width: "18%",
render: (trip) => (
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</Box>
),
},
{
key: "km",
header: "Stav km",
width: "13%",
mono: true,
render: (trip) => (
<Box
component="span"
sx={{ whiteSpace: "nowrap", color: "text.secondary" }}
>
<div>
<h1 className="admin-page-title">Historie jízd</h1>
<p className="admin-page-subtitle">{getMonthName(month)}</p>
</div>
</motion.div>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</Box>
),
},
{
key: "distance",
header: "Vzdálenost",
width: "10%",
mono: true,
bold: true,
render: (trip) => `${formatKm(trip.distance)} km`,
},
{
key: "type",
header: "Typ",
width: "10%",
render: (trip) => (
<StatusChip
label={trip.is_business ? "Služební" : "Soukromá"}
color={trip.is_business ? "success" : "warning"}
/>
),
},
{
key: "notes",
header: "Poznámka",
width: "14%",
render: (trip) => (
<Box component="span" sx={{ color: "text.secondary" }}>
{trip.notes || "—"}
</Box>
),
},
];
return (
<PageEnter>
<PageHeader title="Historie jízd" subtitle={getMonthName(month)} />
{/* Filters */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<FormField label="Měsíc">
<AdminDatePicker
mode="month"
value={month}
onChange={(val: string) => setMonth(val)}
/>
</FormField>
<FormField label="Vozidlo">
<select
<FilterBar>
<Box sx={{ flex: "0 0 180px" }}>
<MonthField value={month} onChange={(val) => setMonth(val)} />
</Box>
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={vehicleId}
onChange={(e) => setVehicleId(e.target.value)}
className="admin-form-select"
>
<option value="">Všechna vozidla</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
</div>
</div>
</motion.div>
onChange={(value) => setVehicleId(value)}
options={[
{ value: "", label: "Všechna vozidla" },
...vehicles.map((v) => ({
value: String(v.id),
label: `${v.spz} - ${v.name}`,
})),
]}
/>
</Box>
</FilterBar>
<motion.div
className="admin-grid admin-grid-3 mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "1fr 1fr",
lg: "repeat(3, 1fr)",
},
gap: 2,
mt: 3,
}}
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{totals.count}</span>
<span className="admin-stat-label">Počet jízd</span>
</div>
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">
{formatKm(totals.total)} km
</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
<circle cx="18.5" cy="18" r="2" />
<path d="M8 18h8" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">
{formatKm(totals.business)} km
</span>
<span className="admin-stat-label">Služební km</span>
</div>
</div>
</motion.div>
<StatCard
label="Počet jízd"
value={totals.count}
icon={TripsIcon}
color="info"
/>
<StatCard
label="Celkem naježděno"
value={`${formatKm(totals.total)} km`}
icon={TotalIcon}
color="default"
/>
<StatCard
label="Služební km"
value={`${formatKm(totals.business)} km`}
icon={BusinessIcon}
color="success"
/>
</Box>
{/* Trips Table */}
<motion.div
className="admin-card mt-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<Card sx={{ mt: 3 }}>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
<LoadingState />
) : (
<>
{trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
<DataTable<Trip>
columns={columns}
rows={trips}
rowKey={(trip) => trip.id}
empty={
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
}
/>
)}
{trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Řidič</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td style={{ color: "var(--text-secondary)" }}>
{trip.driver_name}
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span
style={{
whiteSpace: "nowrap",
color: "var(--text-secondary)",
}}
>
{formatKm(trip.start_km)} -{" "}
{formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td
style={{
color: "var(--text-secondary)",
maxWidth: "200px",
}}
>
{trip.notes || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</motion.div>
</div>
</Card>
</PageEnter>
);
}

366
src/admin/pages/UiKit.tsx Normal file
View File

@@ -0,0 +1,366 @@
import { useState } from "react";
import { Link as RouterLink } from "react-router-dom";
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 {
Button,
Card,
TextField,
Select,
StatusChip,
Tabs,
TabPanel,
Pagination,
CheckboxField,
Alert,
DataTable,
DateField,
type DataColumn,
PageHeader,
EmptyState,
LoadingState,
FilterBar,
StatCard,
} from "../ui";
import { useTheme } from "../../context/ThemeContext";
const BoxIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
</svg>
);
const InboxIcon = (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg>
);
interface DemoRow {
id: number;
name: string;
qty: number;
}
const DEMO_ROWS: DemoRow[] = [
{ id: 1, name: "Beta", qty: 30 },
{ id: 2, name: "Alfa", qty: 10 },
{ id: 3, name: "Gama", qty: 20 },
];
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<Typography
variant="overline"
color="text.secondary"
display="block"
mt={2}
>
{children}
</Typography>
);
}
export default function UiKit() {
const { theme, toggleTheme } = useTheme();
const [selectVal, setSelectVal] = useState("a");
const [tab, setTab] = useState("one");
const [page, setPage] = useState(2);
const [checked, setChecked] = useState(true);
const [date, setDate] = useState("2026-06-06");
const [sortBy, setSortBy] = useState("name");
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [lastClicked, setLastClicked] = useState<string | null>(null);
const onSort = (key: string) => {
if (sortBy === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortBy(key);
setSortDir("asc");
}
};
const sortedRows = [...DEMO_ROWS].sort((a, b) => {
const cmp = String(a[sortBy as keyof DemoRow]).localeCompare(
String(b[sortBy as keyof DemoRow]),
"cs",
{ numeric: true },
);
return sortDir === "asc" ? cmp : -cmp;
});
const columns: DataColumn<DemoRow>[] = [
{ key: "name", header: "Název", sortKey: "name", render: (r) => r.name },
{
key: "qty",
header: "Množství",
align: "right",
mono: true,
sortKey: "qty",
render: (r) => r.qty,
},
];
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: 640 }}>
<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}>
<StatusChip label="Zaplaceno" color="success" />
<StatusChip label="Po splatnosti" color="error" />
</Stack>
<TextField
label="Hledat fakturu"
placeholder="Číslo nebo klient…"
/>
<Box mt={2}>
<Button>Nová faktura</Button>
</Box>
</Card>
<Card>
<Typography variant="h6">Kit Phase 3</Typography>
<SectionLabel>Select</SectionLabel>
<Select
value={selectVal}
onChange={setSelectVal}
options={[
{ value: "a", label: "Možnost A" },
{ value: "b", label: "Možnost B" },
{ value: "c", label: "Možnost C" },
]}
/>
<SectionLabel>StatusChip</SectionLabel>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<StatusChip label="default" />
<StatusChip label="success" color="success" />
<StatusChip label="error" color="error" />
<StatusChip label="warning" color="warning" />
<StatusChip label="info" color="info" />
<StatusChip
label="clickable"
color="success"
onClick={() => {}}
/>
</Stack>
<SectionLabel>Tabs</SectionLabel>
<Tabs
value={tab}
onChange={setTab}
tabs={[
{ value: "one", label: "První" },
{ value: "two", label: "Druhá" },
]}
/>
<TabPanel value="one" current={tab}>
<Typography>Obsah první záložky.</Typography>
</TabPanel>
<TabPanel value="two" current={tab}>
<Typography>Obsah druhé záložky.</Typography>
</TabPanel>
<SectionLabel>Sortable DataTable + Pagination</SectionLabel>
<DataTable<DemoRow>
columns={columns}
rows={sortedRows}
rowKey={(r) => r.id}
sortBy={sortBy}
sortDir={sortDir}
onSort={onSort}
/>
<Pagination page={page} pageCount={5} onChange={setPage} />
<SectionLabel>DateField + Checkbox</SectionLabel>
<DateField value={date} onChange={setDate} label="Datum" />
<Box>
<CheckboxField
label="Souhlasím"
checked={checked}
onChange={setChecked}
/>
</Box>
<SectionLabel>Alert</SectionLabel>
<Stack spacing={1}>
<Alert severity="success">Úspěšně uloženo.</Alert>
<Alert severity="error">Něco se pokazilo.</Alert>
<Alert severity="warning">Pozor na toto.</Alert>
<Alert severity="info">Informace.</Alert>
</Stack>
</Card>
{/* ── Phase 6 primitives ── */}
<Card>
<Typography variant="h6">Kit Phase 6 Primitives</Typography>
<SectionLabel>PageHeader title only</SectionLabel>
<PageHeader title="Přehled projektů" />
<SectionLabel>PageHeader with subtitle + actions</SectionLabel>
<PageHeader
title="Sklady"
subtitle="Správa skladových zásob"
actions={
<>
<Button variant="outlined" color="inherit" size="small">
Export
</Button>
<Button size="small">Nová položka</Button>
</>
}
/>
<SectionLabel>EmptyState icon + action</SectionLabel>
<EmptyState
icon={InboxIcon}
title="Žádné záznamy"
description="Zatím nebyly přidány žádné položky."
action={<Button size="small">Přidat první položku</Button>}
/>
<SectionLabel>EmptyState title only</SectionLabel>
<EmptyState title="Žádné výsledky" />
<SectionLabel>LoadingState</SectionLabel>
<LoadingState label="Načítám data…" />
<SectionLabel>FilterBar</SectionLabel>
<FilterBar>
<Box sx={{ width: 200 }}>
<TextField placeholder="Hledat…" />
</Box>
<Box sx={{ width: 160 }}>
<Select
value={selectVal}
onChange={setSelectVal}
options={[
{ value: "a", label: "Všechny stavy" },
{ value: "b", label: "Aktivní" },
{ value: "c", label: "Uzavřené" },
]}
/>
</Box>
<Button size="small" variant="outlined" color="inherit">
Resetovat
</Button>
</FilterBar>
<SectionLabel>StatCard grid all colors</SectionLabel>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))",
gap: 1.5,
}}
>
<StatCard label="Celkem" value="1 240" icon={BoxIcon} />
<StatCard
label="Aktivní"
value="38"
icon={BoxIcon}
color="primary"
/>
<StatCard
label="Dokončené"
value="112"
icon={BoxIcon}
color="success"
footer="↑ 12 oproti minulému měsíci"
/>
<StatCard
label="Varování"
value="5"
icon={BoxIcon}
color="warning"
/>
<StatCard label="Chyby" value="2" icon={BoxIcon} color="error" />
<StatCard
label="Informace"
value="99"
icon={BoxIcon}
color="info"
/>
</Box>
<SectionLabel>
DataTable onRowClick + rowDanger (row 2 = danger)
</SectionLabel>
{lastClicked && (
<Typography
variant="caption"
color="text.secondary"
mb={1}
display="block"
>
Naposledy kliknuto: {lastClicked}
</Typography>
)}
<DataTable<DemoRow>
columns={columns}
rows={DEMO_ROWS}
rowKey={(r) => r.id}
onRowClick={(r) => setLastClicked(r.name)}
rowDanger={(r) => r.id === 2}
rowInactive={(r) => r.id === 3}
/>
<SectionLabel>Button as RouterLink</SectionLabel>
<Stack direction="row" spacing={1}>
<Button
component={RouterLink}
to="/ui-kit"
variant="outlined"
color="inherit"
size="small"
>
RouterLink button (current page)
</Button>
<Button
component="a"
href="https://mui.com"
target="_blank"
rel="noopener"
size="small"
variant="outlined"
color="inherit"
>
Anchor button
</Button>
</Stack>
</Card>
</Stack>
</Box>
</ScopedCssBaseline>
);
}

View File

@@ -1,18 +1,33 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Avatar from "@mui/material/Avatar";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import FormModal from "../components/FormModal";
import { useApiMutation } from "../lib/queries/mutations";
import {
userListOptions,
roleListOptions,
type User,
} from "../lib/queries/users";
import {
Button,
Card,
DataTable,
Modal,
ConfirmDialog,
Field,
TextField,
SwitchField,
Select,
StatusChip,
LoadingState,
PageEnter,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
@@ -22,10 +37,59 @@ interface FormData {
password: string;
first_name: string;
last_name: string;
role_id: number | string;
role_id: string;
is_active: boolean;
}
// Payload sent to the API. The kit Select is string-based, so the form keeps
// role_id as a string and we convert it to a number at the submit boundary
// (the server expects a numeric role_id).
type UserPayload = Omit<FormData, "role_id"> & { role_id: number };
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
export default function Users() {
const { user: currentUser, updateUser, hasPermission } = useAuth();
const alert = useAlert();
@@ -39,6 +103,7 @@ export default function Users() {
user: User | null;
}>({ isOpen: false, user: null });
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [formData, setFormData] = useState<FormData>({
username: "",
email: "",
@@ -58,8 +123,8 @@ export default function Users() {
"projects",
];
const saveUser = useApiMutation<FormData, void>({
url: (input) =>
const saveUser = useApiMutation<UserPayload, void>({
url: () =>
editingUser ? `${API_BASE}/users/${editingUser.id}` : `${API_BASE}/users`,
method: () => (editingUser ? "PUT" : "POST"),
invalidate: USER_INVALIDATE,
@@ -115,9 +180,10 @@ export default function Users() {
password: "",
first_name: "",
last_name: "",
role_id: roles[0]?.id || "",
role_id: roles[0]?.id ? String(roles[0].id) : "",
is_active: true,
});
setErrors({});
setShowModal(true);
};
@@ -129,9 +195,10 @@ export default function Users() {
password: "",
first_name: user.first_name,
last_name: user.last_name,
role_id: user.role_id,
role_id: String(user.role_id),
is_active: user.is_active,
});
setErrors({});
setShowModal(true);
};
@@ -141,9 +208,22 @@ export default function Users() {
};
const handleSubmit = async () => {
const newErrors: Record<string, string> = {};
if (!formData.first_name) newErrors.first_name = "Zadejte jméno";
if (!formData.last_name) newErrors.last_name = "Zadejte příjmení";
if (!formData.username) newErrors.username = "Zadejte uživatelské jméno";
if (!formData.email) newErrors.email = "Zadejte e-mail";
if (!editingUser && !formData.password)
newErrors.password = "Zadejte heslo";
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true);
try {
await saveUser.mutateAsync(formData);
await saveUser.mutateAsync({
...formData,
role_id: Number(formData.role_id),
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
@@ -151,14 +231,6 @@ export default function Users() {
}
};
const openDeleteModal = (user: User) => {
setDeleteModal({ isOpen: true, user });
};
const closeDeleteModal = () => {
setDeleteModal({ isOpen: false, user: null });
};
const handleDelete = async () => {
if (!deleteModal.user) return;
try {
@@ -175,295 +247,225 @@ export default function Users() {
});
};
const getRoleBadgeClass = (roleName: string): string => {
switch (roleName) {
case "admin":
return "admin-badge admin-badge-admin";
default:
return "admin-badge admin-badge-viewer";
}
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
const columns: DataColumn<User>[] = [
{
key: "user",
header: "Uživatel",
render: (u) => (
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Avatar sx={{ bgcolor: "primary.main", width: 36, height: 36 }}>
{(u.first_name || u.username).charAt(0).toUpperCase()}
</Avatar>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{u.first_name} {u.last_name}
</Typography>
<Typography variant="caption" color="text.secondary">
@{u.username}
</Typography>
</Box>
</Box>
),
},
{ key: "email", header: "E-mail", render: (u) => u.email },
{
key: "role",
header: "Role",
render: (u) => (
<StatusChip
label={u.roles?.display_name || u.roles?.name || "—"}
color={u.roles?.name === "admin" ? "warning" : "default"}
/>
),
},
{
key: "status",
header: "Stav",
render: (u) => (
<StatusChip
label={u.is_active ? "Aktivní" : "Neaktivní"}
color={u.is_active ? "success" : "default"}
onClick={u.id === currentUser?.id ? undefined : () => toggleActive(u)}
/>
),
},
{
key: "actions",
header: "Akce",
align: "right",
render: (u) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => openEditModal(u)}
aria-label="Upravit"
title="Upravit"
>
<div>
<h1 className="admin-page-title">Uživatelé</h1>
<p className="admin-page-subtitle">
Správa uživatelských úč a oprávnění
</p>
</div>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
{EditIcon}
</IconButton>
{u.id !== currentUser?.id && (
<IconButton
size="small"
color="error"
onClick={() => setDeleteModal({ isOpen: true, user: u })}
aria-label="Smazat"
title="Smazat"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat uživatele
</button>
</motion.div>
{DeleteIcon}
</IconButton>
)}
</Box>
),
},
];
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Uživatel</th>
<th>E-mail</th>
<th>Role</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<div className="admin-table-user">
<div className="admin-table-avatar">
{(user.first_name || user.username)
.charAt(0)
.toUpperCase()}
</div>
<div>
<div className="admin-table-name">
{user.first_name} {user.last_name}
</div>
<div className="admin-table-username">
@{user.username}
</div>
</div>
</div>
</td>
<td>{user.email}</td>
<td>
<span
className={getRoleBadgeClass(user.roles?.name ?? "")}
>
{user.roles?.display_name || user.roles?.name || "—"}
</span>
</td>
<td>
<button
onClick={() =>
user.id !== currentUser?.id && toggleActive(user)
}
disabled={user.id === currentUser?.id}
className={`admin-badge ${user.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
style={{
cursor:
user.id === currentUser?.id
? "not-allowed"
: "pointer",
return (
<PageEnter>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 3,
flexWrap: "wrap",
gap: 2,
}}
>
{user.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(user)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{user.id !== currentUser?.id && (
<button
onClick={() => openDeleteModal(user)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</motion.div>
<Typography variant="h4">Správa uživatelů</Typography>
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat uživatele
</Button>
</Box>
<FormModal
<Card>
<DataTable<User>
columns={columns}
rows={users}
rowKey={(u) => u.id}
rowInactive={(u) => !u.is_active}
empty={
<Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom>
Zatím nejsou žádní uživatelé.
</Typography>
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat prvního uživatele
</Button>
</Box>
}
/>
</Card>
<Modal
isOpen={showModal}
onClose={closeModal}
onSubmit={handleSubmit}
title={editingUser ? "Upravit uživatele" : "Přidat nového uživatele"}
submitLabel={editingUser ? "Uložit změny" : "Vytvořit uživatele"}
loading={saving}
submitText={editingUser ? "Uložit změny" : "Vytvořit uživatele"}
>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Jméno">
<input
type="text"
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Jméno" required error={errors.first_name}>
<TextField
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required
className="admin-form-input"
error={!!errors.first_name}
onChange={(e) => {
setFormData({ ...formData, first_name: e.target.value });
setErrors((prev) => ({ ...prev, first_name: "" }));
}}
/>
</FormField>
<FormField label="Příjmení">
<input
type="text"
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Příjmení" required error={errors.last_name}>
<TextField
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
error={!!errors.last_name}
onChange={(e) => {
setFormData({ ...formData, last_name: e.target.value });
setErrors((prev) => ({ ...prev, last_name: "" }));
}}
/>
</FormField>
</div>
</Field>
</Box>
</Box>
<FormField label="Uživatelské jméno">
<input
type="text"
<Field label="Uživatelské jméno" required error={errors.username}>
<TextField
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
error={!!errors.username}
onChange={(e) => {
setFormData({ ...formData, username: e.target.value });
setErrors((prev) => ({ ...prev, username: "" }));
}}
/>
</FormField>
</Field>
<FormField label="E-mail">
<input
<Field label="E-mail" required error={errors.email}>
<TextField
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
className="admin-form-input"
error={!!errors.email}
onChange={(e) => {
setFormData({ ...formData, email: e.target.value });
setErrors((prev) => ({ ...prev, email: "" }));
}}
/>
</FormField>
</Field>
<FormField
<Field
label={`Heslo${editingUser ? " (ponechte prázdné pro zachování stávajícího)" : ""}`}
required={!editingUser}
error={errors.password}
>
<input
<TextField
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required={!editingUser}
className="admin-form-input"
error={!!errors.password}
onChange={(e) => {
setFormData({ ...formData, password: e.target.value });
setErrors((prev) => ({ ...prev, password: "" }));
}}
/>
</FormField>
</Field>
<FormField label="Role">
<select
<Field label="Role">
<Select
value={formData.role_id}
onChange={(e) =>
setFormData({ ...formData, role_id: e.target.value })
}
required
className="admin-form-select"
>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>
</FormField>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData({
...formData,
is_active: e.target.checked,
})
}
onChange={(value) => setFormData({ ...formData, role_id: value })}
options={roles.map((role) => ({
value: String(role.id),
label: role.display_name || role.name,
}))}
/>
<span>Účet je aktivní</span>
</label>
</div>
</FormModal>
</Field>
<ConfirmModal
<SwitchField
label="Účet je aktivní"
checked={formData.is_active}
onChange={(v) => setFormData({ ...formData, is_active: v })}
/>
</Modal>
<ConfirmDialog
isOpen={deleteModal.isOpen}
onClose={closeDeleteModal}
onClose={() => setDeleteModal({ isOpen: false, user: null })}
onConfirm={handleDelete}
title="Smazat uživatele"
message={`Opravdu chcete smazat uživatele "${deleteModal.user?.first_name} ${deleteModal.user?.last_name}"? Tato akce je nevratná.`}
message={
deleteModal.user
? `Opravdu chcete smazat uživatele "${deleteModal.user.first_name} ${deleteModal.user.last_name}"? Tato akce je nevratná.`
: ""
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
confirmVariant="danger"
loading={deleteUser.isPending}
/>
</div>
</PageEnter>
);
}

View File

@@ -1,16 +1,28 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Chip from "@mui/material/Chip";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import { formatKm } from "../utils/formatters";
import FormField from "../components/FormField";
import { vehicleListOptions } from "../lib/queries/vehicles";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
Modal,
ConfirmDialog,
Field,
TextField,
SwitchField,
LoadingState,
PageEnter,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin";
@@ -35,6 +47,50 @@ interface VehicleForm {
is_active: boolean;
}
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
export default function Vehicles() {
const alert = useAlert();
const { hasPermission } = useAuth();
@@ -167,279 +223,197 @@ export default function Vehicles() {
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Správa vozidel</h1>
</div>
<div className="admin-page-actions">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat vozidlo
</button>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{vehicles.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" />
<polygon points="16 8 20 8 23 11 23 16 16 16 16 8" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
</div>
<p>Zatím nejsou žádná vozidla.</p>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
Přidat první vozidlo
</button>
</div>
)}
{vehicles.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>SPZ</th>
<th>Název</th>
<th>Značka / Model</th>
<th>Počáteční km</th>
<th>Aktuální km</th>
<th>Počet jízd</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{vehicles.map((vehicle) => (
<tr
key={vehicle.id}
className={
!vehicle.is_active ? "admin-table-row-inactive" : ""
}
>
<td className="admin-mono fw-500">{vehicle.spz}</td>
<td>{vehicle.name}</td>
<td>
{vehicle.brand || vehicle.model
? `${vehicle.brand || ""} ${vehicle.model || ""}`.trim()
: "—"}
</td>
<td className="admin-mono">
{formatKm(vehicle.initial_km)} km
</td>
<td className="admin-mono fw-500">
{formatKm(vehicle.current_km)} km
</td>
<td className="admin-mono">{vehicle.trip_count}</td>
<td>
<button
onClick={() => toggleActive(vehicle)}
className={`admin-badge ${vehicle.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
>
{vehicle.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(vehicle)}
className="admin-btn-icon"
title="Upravit"
const columns: DataColumn<Vehicle>[] = [
{ key: "spz", header: "SPZ", mono: true, bold: true, render: (v) => v.spz },
{ key: "name", header: "Název", render: (v) => v.name },
{
key: "brand",
header: "Značka / Model",
render: (v) =>
v.brand || v.model ? `${v.brand || ""} ${v.model || ""}`.trim() : "—",
},
{
key: "initial_km",
header: "Počáteční km",
align: "right",
mono: true,
render: (v) => `${formatKm(v.initial_km)} km`,
},
{
key: "current_km",
header: "Aktuální km",
align: "right",
mono: true,
bold: true,
render: (v) => `${formatKm(v.current_km)} km`,
},
{
key: "trips",
header: "Počet jízd",
align: "right",
mono: true,
render: (v) => v.trip_count,
},
{
key: "status",
header: "Stav",
render: (v) => (
<Chip
label={v.is_active ? "Aktivní" : "Neaktivní"}
size="small"
color={v.is_active ? "success" : "default"}
onClick={() => toggleActive(v)}
sx={{ cursor: "pointer", fontWeight: 600 }}
/>
),
},
{
key: "actions",
header: "Akce",
align: "right",
render: (v) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => openEditModal(v)}
aria-label="Upravit"
title="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() =>
setDeleteConfirm({ show: true, vehicle })
}
className="admin-btn-icon danger"
title="Smazat"
{EditIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, vehicle: v })}
aria-label="Smazat"
title="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{DeleteIcon}
</IconButton>
</Box>
),
},
];
{/* Add/Edit Modal */}
<FormModal
return (
<PageEnter>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
mb: 3,
flexWrap: "wrap",
gap: 2,
}}
>
<Typography variant="h4">Správa vozidel</Typography>
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat vozidlo
</Button>
</Box>
<Card>
<DataTable<Vehicle>
columns={columns}
rows={vehicles}
rowKey={(v) => v.id}
rowInactive={(v) => !v.is_active}
empty={
<Box sx={{ textAlign: "center", py: 6 }}>
<Typography color="text.secondary" gutterBottom>
Zatím nejsou žádná vozidla.
</Typography>
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat první vozidlo
</Button>
</Box>
}
/>
</Card>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingVehicle ? "Upravit vozidlo" : "Přidat vozidlo"}
loading={saving}
>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="SPZ" error={errors.spz} required>
<input
type="text"
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="SPZ" required error={errors.spz}>
<TextField
value={form.spz}
placeholder="1AB 2345"
error={!!errors.spz}
onChange={(e) => {
setForm({
...form,
spz: e.target.value.toUpperCase(),
});
setForm({ ...form, spz: e.target.value.toUpperCase() });
setErrors((prev) => ({ ...prev, spz: "" }));
}}
className="admin-form-input"
placeholder="1AB 2345"
aria-invalid={!!errors.spz}
/>
</FormField>
<FormField label="Název" error={errors.name} required>
<input
type="text"
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Název" required error={errors.name}>
<TextField
value={form.name}
placeholder="Služební #1"
error={!!errors.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Služební #1"
aria-invalid={!!errors.name}
/>
</FormField>
</div>
</Field>
</Box>
</Box>
<div className="admin-form-row">
<FormField label="Značka">
<input
type="text"
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Značka">
<TextField
value={form.brand}
onChange={(e) => setForm({ ...form, brand: e.target.value })}
className="admin-form-input"
placeholder="Škoda"
onChange={(e) => setForm({ ...form, brand: e.target.value })}
/>
</FormField>
<FormField label="Model">
<input
type="text"
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Model">
<TextField
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
className="admin-form-input"
placeholder="Octavia Combi"
onChange={(e) => setForm({ ...form, model: e.target.value })}
/>
</FormField>
</div>
</Field>
</Box>
</Box>
<div className="admin-form-group">
<label className="admin-form-label">Počáteční stav km</label>
<input
<Field
label="Počáteční stav km"
hint="Stav tachometru při přidání vozidla"
>
<TextField
type="number"
inputMode="numeric"
value={form.initial_km}
slotProps={{ htmlInput: { min: 0, inputMode: "numeric" } }}
onChange={(e) =>
setForm({
...form,
initial_km: parseInt(e.target.value) || 0,
})
setForm({ ...form, initial_km: parseInt(e.target.value) || 0 })
}
className="admin-form-input"
min="0"
/>
<small className="admin-form-hint">
Stav tachometru při přidání vozidla
</small>
</div>
</Field>
<label className="admin-form-checkbox">
<input
type="checkbox"
<SwitchField
label="Vozidlo je aktivní"
checked={form.is_active}
onChange={(e) =>
setForm({ ...form, is_active: e.target.checked })
}
onChange={(v) => setForm({ ...form, is_active: v })}
/>
<span>Vozidlo je aktivní</span>
</label>
</div>
</FormModal>
</Modal>
{/* Delete Confirmation */}
<ConfirmModal
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
onConfirm={handleDelete}
@@ -452,6 +426,6 @@ export default function Vehicles() {
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
</PageEnter>
);
}

View File

@@ -1,32 +1,89 @@
import { Link } from "react-router-dom";
import { Link as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import {
warehouseStockStatusOptions,
warehouseBelowMinimumOptions,
warehouseReservationListOptions,
warehouseMovementLogOptions,
type WarehouseItem,
type WarehouseReservation,
} from "../lib/queries/warehouse";
import { formatCurrency, formatDate } from "../utils/formatters";
import useReducedMotion from "../hooks/useReducedMotion";
import {
Button,
Card,
DataTable,
StatCard,
StatusChip,
EmptyState,
LoadingState,
PageHeader,
PageEnter,
type DataColumn,
type StatCardColor,
} from "../ui";
const TYPE_LABEL: Record<string, string> = {
receipt: "Příjem",
issue: "Výdej",
};
const TYPE_TONE: Record<string, string> = {
receipt: "admin-badge-incoming",
issue: "admin-badge-outgoing",
// Movement type → StatusChip color (legacy: incoming=success, outgoing=info)
const TYPE_COLOR: Record<string, "success" | "info"> = {
receipt: "success",
issue: "info",
};
const ReceiptIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
</svg>
);
const IssueIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
</svg>
);
interface BelowMinRow {
id: number;
name: string;
available_quantity: number;
min_quantity: number;
}
interface MovementRow {
_idx: number;
date: string;
type: string;
document_number: string;
item_name: string;
quantity: number;
supplier_or_project: string;
}
export default function Warehouse() {
const { hasPermission } = useAuth();
const reducedMotion = useReducedMotion();
const { data: stockItems, isPending: stockPending } = useQuery(
warehouseStockStatusOptions(),
@@ -60,253 +117,249 @@ export default function Warehouse() {
const isLoading =
stockPending || belowMinPending || reservationsPending || movementsLoading;
const belowMinColor: StatCardColor =
belowMinimumCount > 0 ? "error" : "warning";
const belowMinColumns: DataColumn<BelowMinRow>[] = [
{
key: "name",
header: "Název",
width: "50%",
bold: true,
render: (item) => item.name,
},
{
key: "available_quantity",
header: "K dispozici",
width: "25%",
mono: true,
render: (item) => String(item.available_quantity),
},
{
key: "min_quantity",
header: "Min. množství",
width: "25%",
mono: true,
render: (item) => String(item.min_quantity),
},
];
const movementColumns: DataColumn<MovementRow>[] = [
{
key: "date",
header: "Datum",
width: "12%",
mono: true,
render: (row) => formatDate(row.date),
},
{
key: "type",
header: "Typ",
width: "12%",
render: (row) => (
<StatusChip
label={TYPE_LABEL[row.type] ?? row.type}
color={TYPE_COLOR[row.type] ?? "info"}
/>
),
},
{
key: "document_number",
header: "Doklad",
width: "16%",
mono: true,
render: (row) => row.document_number || "—",
},
{
key: "item_name",
header: "Položka",
width: "28%",
bold: true,
render: (row) => row.item_name,
},
{
key: "quantity",
header: "Množství",
width: "12%",
mono: true,
render: (row) => String(row.quantity),
},
{
key: "supplier_or_project",
header: "Dodavatel / Projekt",
width: "20%",
render: (row) => row.supplier_or_project || "—",
},
];
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Sklad</h1>
<p className="admin-page-subtitle">Přehled skladových zásob</p>
</div>
<div className="admin-page-actions">
<PageEnter>
<PageHeader
title="Sklad"
subtitle="Přehled skladových zásob"
actions={
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
{hasPermission("warehouse.operate") && (
<>
<Link
<Button
component={RouterLink}
to="/warehouse/receipts/new"
className="admin-btn admin-btn-primary"
startIcon={ReceiptIcon}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
</svg>
Nový příjem
</Link>
<Link
</Button>
<Button
component={RouterLink}
to="/warehouse/issues/new"
className="admin-btn admin-btn-secondary"
variant="outlined"
color="inherit"
startIcon={IssueIcon}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
</svg>
Nový výdej
</Link>
</Button>
</>
)}
{hasPermission("warehouse.manage") && (
<Link
<Button
component={RouterLink}
to="/warehouse/categories"
className="admin-btn admin-btn-secondary"
variant="outlined"
color="inherit"
>
Kategorie
</Link>
</Button>
)}
<Link to="/warehouse/items" className="admin-btn admin-btn-secondary">
<Button
component={RouterLink}
to="/warehouse/items"
variant="outlined"
color="inherit"
>
Položky
</Link>
<Link
</Button>
<Button
component={RouterLink}
to="/warehouse/reports"
className="admin-btn admin-btn-secondary"
variant="outlined"
color="inherit"
>
Reporty
</Link>
</div>
</motion.div>
</Button>
</Box>
}
/>
{/* KPI cards */}
<div className="admin-kpi-grid admin-kpi-4">
<motion.div
className="admin-stat-card info"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.06,
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
lg: "repeat(4, 1fr)",
},
gap: 2,
mb: 3,
}}
>
<div className="admin-stat-label">Celkem položek</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : totalItems}
</div>
</motion.div>
<motion.div
className="admin-stat-card success"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.08,
}}
>
<div className="admin-stat-label">Hodnota zásob</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
</div>
</motion.div>
<motion.div
className={`admin-stat-card ${belowMinimumCount > 0 ? "danger" : "warning"}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.1,
}}
>
<div className="admin-stat-label">Pod minimem</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : belowMinimumCount}
</div>
</motion.div>
<motion.div
className="admin-stat-card info"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.12,
}}
>
<div className="admin-stat-label">Aktivní rezervace</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : activeReservationCount}
</div>
</motion.div>
</div>
<StatCard
color="info"
label="Celkem položek"
value={isLoading ? "—" : totalItems}
/>
<StatCard
color="success"
label="Hodnota zásob"
value={isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
/>
<StatCard
color={belowMinColor}
label="Pod minimem"
value={isLoading ? "—" : belowMinimumCount}
/>
<StatCard
color="info"
label="Aktivní rezervace"
value={isLoading ? "—" : activeReservationCount}
/>
</Box>
{/* Below minimum alert */}
{belowMin.length > 0 && (
<motion.div
className="admin-card admin-card-danger"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.14,
<Box sx={{ mb: 3 }}>
<Card sx={{ borderColor: "error.main", borderWidth: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
mb: 2,
}}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title" style={{ color: "var(--danger)" }}>
<Typography variant="h6" sx={{ color: "error.main" }}>
Položky pod minimem
</h2>
<Link
</Typography>
<Button
component={RouterLink}
to="/warehouse/reports"
className="admin-btn admin-btn-primary admin-btn-sm"
size="small"
sx={{ flexShrink: 0 }}
>
Reporty &rarr;
</Link>
</div>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>K dispozici</th>
<th>Min. množství</th>
</tr>
</thead>
<tbody>
{belowMin.map((item) => (
<tr key={item.id} className="admin-warehouse-row-danger">
<td className="fw-500">{item.name}</td>
<td className="admin-mono admin-warehouse-danger-value">
{item.available_quantity ?? 0}
</td>
<td className="admin-mono">{item.min_quantity ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</motion.div>
</Button>
</Box>
<DataTable<BelowMinRow>
columns={belowMinColumns}
rows={belowMin.map((item) => ({
id: item.id,
name: item.name,
available_quantity: item.available_quantity ?? 0,
min_quantity: item.min_quantity ?? 0,
}))}
rowKey={(item) => item.id}
rowDanger={() => true}
/>
</Card>
</Box>
)}
{/* Recent movements */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.16,
<Box>
<Card>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
flexWrap: "wrap",
mb: 2,
}}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Poslední pohyby</h2>
<Link
<Typography variant="h6">Poslední pohyby</Typography>
<Button
component={RouterLink}
to="/warehouse/reports"
className="admin-btn admin-btn-primary admin-btn-sm"
size="small"
sx={{ flexShrink: 0 }}
>
Vše &rarr;
</Link>
</div>
<div className="admin-card-body">
</Button>
</Box>
{movementsLoading ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : recentMovements.length === 0 ? (
<div className="admin-empty-state">Žádné nedávné pohyby</div>
<LoadingState />
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Doklad</th>
<th>Položka</th>
<th>Množství</th>
<th>Dodavatel / Projekt</th>
</tr>
</thead>
<tbody>
{recentMovements.map((row, i) => (
<tr key={i}>
<td className="admin-mono">{formatDate(row.date)}</td>
<td>
<span
className={`admin-badge ${TYPE_TONE[row.type] ?? ""}`}
>
{TYPE_LABEL[row.type] ?? row.type}
</span>
</td>
<td className="admin-mono">
{row.document_number || "—"}
</td>
<td className="fw-500">{row.item_name}</td>
<td className="admin-mono">{row.quantity}</td>
<td>{row.supplier_or_project || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
<DataTable<MovementRow>
columns={movementColumns}
rows={recentMovements.map((row, i) => ({ ...row, _idx: i }))}
rowKey={(row) => row._idx}
empty={<EmptyState title="Žádné nedávné pohyby" />}
/>
)}
</div>
</motion.div>
</div>
</Card>
</Box>
</PageEnter>
);
}

View File

@@ -1,18 +1,29 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import {
warehouseCategoryListOptions,
type WarehouseCategory,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
Modal,
ConfirmDialog,
Field,
TextField,
PageHeader,
PageEnter,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin/warehouse/categories";
@@ -22,6 +33,50 @@ interface CategoryForm {
sort_order: number;
}
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const DeleteIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
export default function WarehouseCategories() {
const alert = useAlert();
const { hasPermission } = useAuth();
@@ -121,207 +176,145 @@ export default function WarehouseCategories() {
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Kategorie skladu</h1>
</div>
<div className="admin-page-actions">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat kategorii
</button>
</div>
</motion.div>
const total = categories.length;
const subtitle = `${total} ${
total === 1
? "kategorie"
: total >= 2 && total <= 4
? "kategorie"
: "kategorií"
}`;
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{categories.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</div>
<p>Zatím nejsou žádné kategorie.</p>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
Přidat první kategorii
</button>
</div>
)}
{categories.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>Popis</th>
<th>Pořadí</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{categories.map((category) => (
<tr key={category.id}>
<td className="fw-500">{category.name}</td>
<td>{category.description || "—"}</td>
<td className="admin-mono">{category.sort_order}</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(category)}
className="admin-btn-icon"
title="Upravit"
const columns: DataColumn<WarehouseCategory>[] = [
{
key: "name",
header: "Název",
width: "35%",
bold: true,
render: (c) => c.name,
},
{
key: "description",
header: "Popis",
width: "40%",
render: (c) => c.description || "—",
},
{
key: "sort_order",
header: "Pořadí",
width: "15%",
mono: true,
render: (c) => String(c.sort_order),
},
{
key: "actions",
header: "Akce",
width: "10%",
align: "right",
render: (c) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => openEditModal(c)}
aria-label="Upravit"
title="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() =>
setDeleteConfirm({ show: true, category })
}
className="admin-btn-icon danger"
title="Smazat"
{EditIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, category: c })}
aria-label="Smazat"
title="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{DeleteIcon}
</IconButton>
</Box>
),
},
];
return (
<PageEnter>
<PageHeader
title="Kategorie skladu"
subtitle={subtitle}
actions={
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat kategorii
</Button>
}
/>
<Card>
<DataTable<WarehouseCategory>
columns={columns}
rows={categories}
rowKey={(c) => c.id}
empty={
<EmptyState
title="Zatím nejsou žádné kategorie."
action={
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat první kategorii
</Button>
}
/>
}
/>
</Card>
{/* Add/Edit Modal */}
<FormModal
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingCategory ? "Upravit kategorii" : "Přidat kategorii"}
loading={saving}
>
<div className="admin-form">
<FormField label="Název" error={errors.name} required>
<input
type="text"
<Field label="Název" required error={errors.name}>
<TextField
value={form.name}
error={!!errors.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Název kategorie"
aria-invalid={!!errors.name}
/>
</FormField>
</Field>
<FormField label="Popis">
<textarea
<Field label="Popis">
<TextField
multiline
minRows={3}
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
className="admin-form-input"
rows={3}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Volitelný popis kategorie"
/>
</FormField>
</Field>
<FormField label="Pořadí řazení">
<input
<Field label="Pořadí řazení">
<TextField
type="number"
inputMode="numeric"
value={form.sort_order}
value={String(form.sort_order)}
onChange={(e) =>
setForm({
...form,
sort_order: parseInt(e.target.value) || 0,
})
}
className="admin-form-input"
min="0"
inputProps={{ min: 0 }}
/>
<small className="admin-form-hint">
Nižší hodnota = výše v seznamu
</small>
</FormField>
</div>
</FormModal>
</Field>
</Modal>
{/* Delete Confirmation */}
<ConfirmModal
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, category: null })}
onConfirm={handleDelete}
@@ -333,7 +326,8 @@ export default function WarehouseCategories() {
}
confirmText="Smazat"
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
</div>
</PageEnter>
);
}

View File

@@ -1,9 +1,8 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import Box from "@mui/material/Box";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import Pagination from "../components/Pagination";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { formatDate } from "../utils/formatters";
@@ -11,12 +10,29 @@ import {
warehouseInventoryListOptions,
type WarehouseInventorySession,
} from "../lib/queries/warehouse";
import {
Button,
Card,
DataTable,
Pagination,
Select,
StatusChip,
PageHeader,
PageEnter,
FilterBar,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const PER_PAGE = 20;
const STATUS_BADGE: Record<string, string> = {
DRAFT: "admin-badge-warning",
CONFIRMED: "admin-badge-active",
const STATUS_COLOR: Record<
string,
"warning" | "success" | "error" | "info" | "default"
> = {
DRAFT: "warning",
CONFIRMED: "success",
};
const STATUS_LABEL: Record<string, string> = {
@@ -24,8 +40,23 @@ const STATUS_LABEL: Record<string, string> = {
CONFIRMED: "Potvrzeno",
};
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
export default function WarehouseInventory() {
const { hasPermission } = useAuth();
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<string>("");
@@ -39,190 +70,129 @@ export default function WarehouseInventory() {
}),
);
const navigate = useNavigate();
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
const handleRowClick = (session: WarehouseInventorySession) => {
navigate(`/warehouse/inventory/${session.id}`);
};
const resetFilters = () => {
setStatusFilter("");
setPage(1);
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Inventury</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? items.length}{" "}
{pagination?.total === 1
const total = pagination?.total ?? items.length;
const subtitle = `${total} ${
total === 1
? "inventura"
: pagination?.total !== undefined &&
pagination.total >= 2 &&
pagination.total <= 4
: total >= 2 && total <= 4
? "inventury"
: "inventur"}
</p>
</div>
<div className="admin-page-actions">
<button
onClick={() => navigate("/warehouse/inventory/new")}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Nová inventura
</button>
</div>
</motion.div>
: "inventur"
}`;
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
const columns: DataColumn<WarehouseInventorySession>[] = [
{
key: "session_number",
header: "Číslo inventury",
width: "30%",
mono: true,
bold: true,
render: (s) => s.session_number || "—",
},
{
key: "status",
header: "Stav",
width: "20%",
render: (s) => (
<StatusChip
label={STATUS_LABEL[s.status] ?? s.status}
color={STATUS_COLOR[s.status] ?? "default"}
/>
),
},
{
key: "created_at",
header: "Vytvořeno",
width: "30%",
mono: true,
render: (s) => formatDate(s.created_at),
},
{
key: "items",
header: "Položky",
width: "20%",
mono: true,
render: (s) => String(s.items?.length ?? 0),
},
];
return (
<PageEnter>
<PageHeader
title="Inventury"
subtitle={subtitle}
actions={
<Button
startIcon={PlusIcon}
onClick={() => navigate("/warehouse/inventory/new")}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<select
Nová inventura
</Button>
}
/>
<FilterBar>
<Box sx={{ flex: "0 0 160px" }}>
<Select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
onChange={(val) => {
setStatusFilter(val);
setPage(1);
}}
className="admin-form-select"
>
<option value="">Všechny stavy</option>
<option value="DRAFT">Návrh</option>
<option value="CONFIRMED">Potvrzeno</option>
</select>
options={[
{ value: "", label: "Všechny stavy" },
{ value: "DRAFT", label: "Návrh" },
{ value: "CONFIRMED", label: "Potvrzeno" },
]}
/>
</Box>
{statusFilter && (
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={resetFilters}
style={{ whiteSpace: "nowrap" }}
>
<Button variant="outlined" color="inherit" onClick={resetFilters}>
Zrušit filtry
</button>
</Button>
)}
</div>
</FilterBar>
<AnimatePresence mode="wait">
<motion.div
key={`${statusFilter}-${page}-${items.length}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{items.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>
{statusFilter
? "Žádné inventury pro zadaný filtr."
: "Zatím nejsou žádné inventury."}
</p>
{!statusFilter && (
<button
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
<DataTable<WarehouseInventorySession>
columns={columns}
rows={items}
rowKey={(s) => s.id}
onRowClick={(s) => navigate(`/warehouse/inventory/${s.id}`)}
empty={
statusFilter ? (
<EmptyState title="Žádné inventury pro zadaný filtr." />
) : (
<EmptyState
title="Zatím nejsou žádné inventury."
action={
<Button
startIcon={PlusIcon}
onClick={() => navigate("/warehouse/inventory/new")}
className="admin-btn admin-btn-primary"
>
Vytvořit první inventuru
</button>
)}
</div>
)}
{items.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Číslo inventury</th>
<th>Stav</th>
<th>Vytvořeno</th>
<th>Položky</th>
</tr>
</thead>
<tbody>
{items.map((session) => (
<tr
key={session.id}
onClick={() => handleRowClick(session)}
style={{ cursor: "pointer" }}
>
<td className="admin-mono fw-500">
{session.session_number || "—"}
</td>
<td>
<span
className={`admin-badge ${STATUS_BADGE[session.status] ?? ""}`}
>
{STATUS_LABEL[session.status] ?? session.status}
</span>
</td>
<td className="admin-mono">
{formatDate(session.created_at)}
</td>
<td className="admin-mono">
{session.items?.length ?? 0}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</motion.div>
</AnimatePresence>
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
</div>
</Button>
}
/>
)
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
</PageEnter>
);
}

View File

@@ -1,23 +1,39 @@
import { useState } from "react";
import { useParams, Link } from "react-router-dom";
import { useParams, Link as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import { formatDate } from "../utils/formatters";
import {
warehouseInventoryDetailOptions,
type WarehouseInventorySession,
type WarehouseInventoryItem,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
StatusChip,
EmptyState,
LoadingState,
PageHeader,
PageEnter,
ConfirmDialog,
headerActionsSx,
type DataColumn,
} from "../ui";
const STATUS_BADGE: Record<string, string> = {
DRAFT: "admin-badge-warning",
CONFIRMED: "admin-badge-active",
const STATUS_COLOR: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
DRAFT: "warning",
CONFIRMED: "success",
};
const STATUS_LABEL: Record<string, string> = {
@@ -25,6 +41,19 @@ const STATUS_LABEL: Record<string, string> = {
CONFIRMED: "Potvrzeno",
};
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
export default function WarehouseInventoryDetail() {
const { id } = useParams();
const alert = useAlert();
@@ -65,206 +94,204 @@ export default function WarehouseInventoryDetail() {
const confirming = confirmMutation.isPending;
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
if (error || !session) {
return (
<div>
<div className="admin-page-header">
<div>
<Link
<Box>
<PageHeader
title="Inventura"
actions={
<Button
component={RouterLink}
to="/warehouse/inventory"
className="admin-btn-icon"
title="Zpět na seznam"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-empty-state">
<p>Inventura nebyla nalezena.</p>
</div>
</div>
</div>
</div>
Zpět
</Button>
}
/>
<Card>
<EmptyState title="Inventura nebyla nalezena." />
</Card>
</Box>
);
}
const s = session as WarehouseInventorySession;
const items = s.items ?? [];
const itemColumns: DataColumn<WarehouseInventoryItem>[] = [
{
key: "item",
header: "Položka",
width: "25%",
bold: true,
render: (row) => row.item?.name || `ID: ${row.item_id}`,
},
{
key: "location",
header: "Lokace",
width: "15%",
mono: true,
render: (row) => row.location?.code || "—",
},
{
key: "system_qty",
header: "Systémové množství",
width: "18%",
mono: true,
render: (row) => String(Number(row.system_qty)),
},
{
key: "actual_qty",
header: "Skutečné množství",
width: "18%",
mono: true,
render: (row) => String(Number(row.actual_qty)),
},
{
key: "difference",
header: "Rozdíl",
width: "12%",
render: (row) => {
const diff = Number(row.difference);
const color =
diff > 0
? "success.main"
: diff < 0
? "error.main"
: "text.secondary";
return (
<div>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
color,
}}
>
{diff > 0 ? `+${diff}` : diff}
</Typography>
);
},
},
{
key: "notes",
header: "Poznámka",
width: "12%",
render: (row) => row.notes || "—",
},
];
return (
<PageEnter>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<Link
<PageHeader
title={s.session_number || "Inventura"}
actions={
<Box sx={headerActionsSx}>
<Button
component={RouterLink}
to="/warehouse/inventory"
className="admin-btn-icon"
title="Zpět na seznam"
aria-label="Zpět na seznam"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">
{s.session_number || "Inventura"}
</h1>
</div>
<span
className={`admin-badge ${STATUS_BADGE[s.status] ?? ""}`}
style={{ marginLeft: "0.5rem" }}
>
{STATUS_LABEL[s.status] ?? s.status}
</span>
</div>
<div className="admin-page-actions">
Zpět
</Button>
<StatusChip
label={STATUS_LABEL[s.status] ?? s.status}
color={STATUS_COLOR[s.status] ?? "default"}
/>
{s.status === "DRAFT" && (
<button
<Button
onClick={() => setShowConfirmModal(true)}
className="admin-btn admin-btn-primary"
disabled={confirming}
>
{confirming ? "Potvrzování..." : "Potvrdit inventuru"}
</button>
</Button>
)}
</div>
</motion.div>
</Box>
}
/>
{/* Header info */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo inventury
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Číslo inventury">
<div className="admin-mono fw-500">
{s.session_number || "—"}
</div>
</FormField>
<FormField label="Vytvořeno">
<div className="admin-mono">{formatDate(s.created_at)}</div>
</FormField>
</div>
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vytvořeno
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(s.created_at)}
</Typography>
</Box>
{s.notes && (
<FormField label="Poznámky">
<div style={{ whiteSpace: "pre-wrap" }}>{s.notes}</div>
</FormField>
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{s.notes}
</Typography>
</Box>
)}
</div>
</div>
</motion.div>
</Box>
</Card>
{/* Lines table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Položky</h3>
{items.length === 0 ? (
<div className="admin-empty-state">Žádné řádky</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Položka</th>
<th>Lokace</th>
<th>Systémové množství</th>
<th>Skutečné množství</th>
<th>Rozdíl</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const diff = Number(item.difference);
const diffColor =
diff > 0
? "var(--success)"
: diff < 0
? "var(--danger)"
: "var(--text-secondary)";
return (
<tr key={item.id}>
<td className="fw-500">
{item.item?.name || `ID: ${item.item_id}`}
</td>
<td className="admin-mono">
{item.location?.code || "—"}
</td>
<td className="admin-mono">
{Number(item.system_qty)}
</td>
<td className="admin-mono">
{Number(item.actual_qty)}
</td>
<td
className="admin-mono fw-500"
style={{ color: diffColor }}
>
{diff > 0 ? `+${diff}` : diff}
</td>
<td>{item.notes || "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
<DataTable<WarehouseInventoryItem>
columns={itemColumns}
rows={items}
rowKey={(row) => row.id}
empty={<EmptyState title="Žádné řádky" />}
/>
</Card>
{/* Confirm modal */}
<ConfirmModal
<ConfirmDialog
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
onConfirm={handleConfirm}
title="Potvrdit inventuru"
message={`Opravdu chcete potvrdit inventuru „${s.session_number || s.id}"? Potvrzením se aktualizují skladové zásoby.`}
confirmText="Potvrdit inventuru"
type="warning"
loading={confirming}
/>
</div>
</PageEnter>
);
}

View File

@@ -1,18 +1,19 @@
import { useState } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useState, useId, useRef } from "react";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import FormField from "../components/FormField";
import ItemPicker from "../components/warehouse/ItemPicker";
import LocationSelect from "../components/warehouse/LocationSelect";
import WarehouseMovementTable from "../components/warehouse/WarehouseMovementTable";
import { warehouseLocationListOptions } from "../lib/queries/warehouse";
import type { WarehouseLocation } from "../lib/queries/warehouse";
import {
warehouseLocationListOptions,
type WarehouseLocation,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
import { Button, Card, TextField, Select, Field, PageEnter } from "../ui";
interface InventoryItem {
key: string;
@@ -21,22 +22,63 @@ interface InventoryItem {
actual_qty: number;
}
const defaultInventoryItem = () => ({
item_id: null,
location_id: null,
actual_qty: 0,
});
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
const RemoveIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
const PlusIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
export default function WarehouseInventoryForm() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const [items, setItems] = useState<InventoryItem[]>([]);
const [notes, setNotes] = useState("");
const [saving, setSaving] = useState(false);
const { data: locations = [] } = useQuery(warehouseLocationListOptions());
const { data: locations } = useQuery(warehouseLocationListOptions());
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
@@ -90,151 +132,153 @@ export default function WarehouseInventoryForm() {
}
};
const addItem = () => {
setItems((prev) => [
...prev,
{ key: nextKey(), item_id: null, location_id: null, actual_qty: 0 },
]);
};
const removeItem = (index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
};
const updateItem = (
index: number,
field: keyof InventoryItem,
value: unknown,
) => {
setItems((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
};
const locationOptions = [
{ value: "", label: "-- bez lokace --" },
...(locations ?? []).map((loc: WarehouseLocation) => ({
value: String(loc.id),
label: `${loc.code} - ${loc.name}`,
})),
];
return (
<div>
<PageEnter>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<Link
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<IconButton
component={RouterLink}
to="/warehouse/inventory"
className="admin-btn-icon"
title="Zpět na seznam"
aria-label="Zpět na seznam"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">Nová inventura</h1>
</div>
</div>
<div className="admin-page-actions">
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{BackIcon}
</IconButton>
<Typography variant="h4">Nová inventura</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button onClick={handleSubmit} disabled={saving}>
{saving ? "Ukládání..." : "Vytvořit inventuru"}
</button>
</div>
</motion.div>
</Button>
</Box>
</Box>
{/* Notes */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<FormField label="Poznámky">
<textarea
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Field label="Poznámky">
<TextField
multiline
minRows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="admin-form-input"
rows={3}
placeholder="Volitelné poznámky k inventuře"
/>
</FormField>
</div>
</motion.div>
</Field>
</Card>
{/* Lines */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, index) => (
<Box
key={item.key}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
md: "minmax(180px, 2fr) minmax(140px, 1.5fr) 120px auto",
},
gap: 1,
alignItems: "start",
}}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Položky</h3>
<WarehouseMovementTable<InventoryItem>
items={items}
onChange={setItems}
mode="inventory"
defaultItem={defaultInventoryItem}
renderHeader={() => (
<>
<th className="admin-warehouse-col-item">Položka</th>
<th className="admin-warehouse-col-location">Lokace</th>
<th className="admin-warehouse-col-qty">Skutečné množství</th>
<th>Akce</th>
</>
)}
renderRow={(item, _index, updateItem, removeItem) => (
<>
<td className="admin-warehouse-col-item">
<ItemPicker
value={item.item_id}
onChange={(id) => updateItem("item_id", id)}
onChange={(id) => updateItem(index, "item_id", id)}
/>
</td>
<td className="admin-warehouse-col-location">
<LocationSelect
value={item.location_id}
onChange={(locId) => updateItem("location_id", locId)}
<Select
value={
item.location_id === null ? "" : String(item.location_id)
}
onChange={(val) =>
updateItem(index, "location_id", val ? Number(val) : null)
}
options={locationOptions}
/>
</td>
<td className="admin-warehouse-col-qty">
<input
<TextField
type="number"
className="admin-form-input"
value={item.actual_qty || ""}
onChange={(e) =>
updateItem("actual_qty", Number(e.target.value))
updateItem(index, "actual_qty", Number(e.target.value))
}
min="0"
step="0.001"
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Skutečné množství, řádek ${_index + 1}`}
inputProps={{
min: 0,
step: 0.001,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Skutečné množství, řádek ${index + 1}`,
}}
placeholder="Skutečné množství"
/>
</td>
<td>
<div className="admin-table-actions">
<button
type="button"
className="admin-btn-icon danger"
onClick={removeItem}
<IconButton
color="error"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
sx={{ justifySelf: { xs: "start", md: "center" } }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{RemoveIcon}
</IconButton>
</Box>
))}
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={PlusIcon}
onClick={addItem}
sx={{ mt: 2 }}
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</td>
</>
)}
/>
</div>
</motion.div>
</div>
Přidat položku
</Button>
</Card>
</PageEnter>
);
}

View File

@@ -1,24 +1,40 @@
import { useState } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
warehouseIssueDetailOptions,
type WarehouseIssue,
type WarehouseIssueItem,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
StatusChip,
EmptyState,
LoadingState,
PageHeader,
ConfirmDialog,
PageEnter,
headerActionsSx,
type DataColumn,
} from "../ui";
const STATUS_BADGE: Record<string, string> = {
DRAFT: "admin-badge-warning",
CONFIRMED: "admin-badge-active",
CANCELLED: "admin-badge-danger",
const STATUS_COLOR: Record<
string,
"default" | "success" | "error" | "warning" | "info"
> = {
DRAFT: "warning",
CONFIRMED: "success",
CANCELLED: "error",
};
const STATUS_LABEL: Record<string, string> = {
@@ -27,6 +43,19 @@ const STATUS_LABEL: Record<string, string> = {
CANCELLED: "Zrušeno",
};
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
export default function WarehouseIssueDetail() {
const { id } = useParams();
const alert = useAlert();
@@ -92,257 +121,272 @@ export default function WarehouseIssueDetail() {
const cancelling = cancelMutation.isPending;
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
if (error || !issue) {
return (
<div>
<div className="admin-page-header">
<div>
<Link
<Box>
<PageHeader
title="Výdejka"
actions={
<Button
component={RouterLink}
to="/warehouse/issues"
className="admin-btn-icon"
title="Zpět na seznam"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
</div>
</div>
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-empty-state">
<p>Výdejka nebyla nalezena.</p>
</div>
</div>
</div>
</div>
Zpět
</Button>
}
/>
<Card>
<EmptyState title="Výdejka nebyla nalezena." />
</Card>
</Box>
);
}
const iss = issue as WarehouseIssue;
const items = iss.items ?? [];
const itemColumns: DataColumn<WarehouseIssueItem>[] = [
{
key: "item",
header: "Položka",
width: "20%",
bold: true,
render: (row) => row.item?.name || `ID: ${row.item_id}`,
},
{
key: "batch",
header: "Šarže",
width: "18%",
mono: true,
render: (row) =>
row.batch
? `${formatDate(row.batch.received_at)} ${formatCurrency(Number(row.batch.unit_price), "CZK")}`
: "—",
},
{
key: "quantity",
header: "Množství",
width: "10%",
mono: true,
render: (row) => String(Number(row.quantity)),
},
{
key: "unit_price",
header: "Cena/ks",
width: "13%",
mono: true,
render: (row) =>
row.batch ? formatCurrency(Number(row.batch.unit_price), "CZK") : "—",
},
{
key: "total",
header: "Celkem",
width: "13%",
mono: true,
bold: true,
render: (row) =>
row.batch
? formatCurrency(
Number(row.quantity) * Number(row.batch.unit_price),
"CZK",
)
: "—",
},
{
key: "location",
header: "Lokace",
width: "10%",
mono: true,
render: (row) => row.location?.code || "—",
},
{
key: "reservation",
header: "Rezervace",
width: "10%",
mono: true,
render: (row) =>
row.reservation
? `ID: ${row.reservation.id} (${Number(row.reservation.remaining_qty)} ks)`
: "—",
},
{
key: "notes",
header: "Poznámka",
width: "6%",
render: (row) => row.notes || "—",
},
];
return (
<div>
<PageEnter>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<Link
<PageHeader
title={iss.issue_number || "Nová výdejka"}
subtitle={
iss.project
? `${iss.project.project_number} ${iss.project.name}`
: undefined
}
actions={
<Box sx={headerActionsSx}>
<Button
component={RouterLink}
to="/warehouse/issues"
className="admin-btn-icon"
title="Zpět na seznam"
aria-label="Zpět na seznam"
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">
{iss.issue_number || "Nová výdejka"}
</h1>
{iss.project && (
<p className="admin-page-subtitle">
{iss.project.project_number} {iss.project.name}
</p>
)}
</div>
<span
className={`admin-badge ${STATUS_BADGE[iss.status] ?? ""}`}
style={{ marginLeft: "0.5rem" }}
>
{STATUS_LABEL[iss.status] ?? iss.status}
</span>
</div>
{canOperate && (
<div className="admin-page-actions">
{iss.status === "DRAFT" && (
Zpět
</Button>
<StatusChip
label={STATUS_LABEL[iss.status] ?? iss.status}
color={STATUS_COLOR[iss.status] ?? "default"}
/>
{canOperate && iss.status === "DRAFT" && (
<>
<button
<Button
variant="outlined"
color="inherit"
onClick={() => navigate(`/warehouse/issues/${iss.id}/edit`)}
className="admin-btn admin-btn-secondary"
>
Upravit
</button>
<button
onClick={handleConfirm}
className="admin-btn admin-btn-primary"
disabled={confirming}
>
</Button>
<Button onClick={handleConfirm} disabled={confirming}>
{confirming ? "Potvrzování..." : "Potvrdit"}
</button>
<button
</Button>
<Button
variant="outlined"
color="error"
onClick={() => setCancelConfirm(true)}
className="admin-btn admin-btn-secondary"
style={{ color: "var(--danger)" }}
>
Zrušit
</button>
</Button>
</>
)}
{iss.status === "CONFIRMED" && (
<button
{canOperate && iss.status === "CONFIRMED" && (
<Button
variant="outlined"
color="error"
onClick={() => setCancelConfirm(true)}
className="admin-btn admin-btn-secondary"
style={{ color: "var(--danger)" }}
>
Zrušit
</button>
</Button>
)}
</div>
)}
</motion.div>
</Box>
}
/>
{/* Header info */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
{/* Basic info */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
gap: 2,
}}
>
<Box>
<Typography variant="caption" color="text.secondary">
Číslo dokladu
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="admin-form-row admin-form-row-3">
<FormField label="Číslo dokladu">
<div className="admin-mono fw-500">
{iss.issue_number || "—"}
</div>
</FormField>
<FormField label="Projekt">
<div>
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Projekt
</Typography>
{iss.project ? (
<a
className="link-accent admin-mono fw-500"
<Typography
variant="body2"
component="a"
href={`/projects/${iss.project.id}`}
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
color: "primary.main",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
display: "block",
}}
>
{iss.project.project_number} {iss.project.name}
</a>
</Typography>
) : (
"—"
<Typography variant="body2"></Typography>
)}
</div>
</FormField>
<FormField label="Vydal">
<div className="admin-mono fw-500">
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vydal
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 500,
}}
>
{iss.issued_by_user
? `${iss.issued_by_user.first_name} ${iss.issued_by_user.last_name}`
: "—"}
</div>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Vytvořeno">
<div className="admin-mono">{formatDate(iss.created_at)}</div>
</FormField>
</div>
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Vytvořeno
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
>
{formatDate(iss.created_at)}
</Typography>
</Box>
{iss.notes && (
<FormField label="Poznámky">
<div style={{ whiteSpace: "pre-wrap" }}>{iss.notes}</div>
</FormField>
<Box sx={{ gridColumn: { sm: "1 / -1" } }}>
<Typography variant="caption" color="text.secondary">
Poznámky
</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>
{iss.notes}
</Typography>
</Box>
)}
</div>
</div>
</motion.div>
</Box>
</Card>
{/* Lines table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Položky</h3>
{items.length === 0 ? (
<div className="admin-empty-state">Žádné řádky</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Položka</th>
<th>Šarže</th>
<th>Množství</th>
<th>Cena/ks</th>
<th>Celkem</th>
<th>Lokace</th>
<th>Rezervace</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td className="fw-500">
{item.item?.name || `ID: ${item.item_id}`}
</td>
<td className="admin-mono">
{item.batch
? `${formatDate(item.batch.received_at)} ${formatCurrency(Number(item.batch.unit_price), "CZK")}`
: "—"}
</td>
<td className="admin-mono">{Number(item.quantity)}</td>
<td className="admin-mono">
{item.batch
? formatCurrency(Number(item.batch.unit_price), "CZK")
: "—"}
</td>
<td className="admin-mono fw-500">
{item.batch
? formatCurrency(
Number(item.quantity) *
Number(item.batch.unit_price),
"CZK",
)
: "—"}
</td>
<td className="admin-mono">
{item.location?.code || "—"}
</td>
<td className="admin-mono">
{item.reservation
? `ID: ${item.reservation.id} (${Number(item.reservation.remaining_qty)} ks)`
: "—"}
</td>
<td>{item.notes || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
<DataTable<WarehouseIssueItem>
columns={itemColumns}
rows={items}
rowKey={(row) => row.id}
empty={<EmptyState title="Žádné řádky" />}
/>
</Card>
{/* Cancel confirmation modal */}
<ConfirmModal
{/* Cancel confirmation dialog */}
<ConfirmDialog
isOpen={cancelConfirm}
onClose={() => setCancelConfirm(false)}
onConfirm={handleCancel}
@@ -352,6 +396,6 @@ export default function WarehouseIssueDetail() {
confirmVariant="danger"
loading={cancelling}
/>
</div>
</PageEnter>
);
}

View File

@@ -1,24 +1,170 @@
import { useState, useEffect, useRef, useId } from "react";
import { useParams, useNavigate, useLocation, Link } from "react-router-dom";
import {
useParams,
useNavigate,
useLocation,
Link as RouterLink,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import FormField from "../components/FormField";
import WarehouseMovementTable, {
type MovementItem,
} from "../components/warehouse/WarehouseMovementTable";
import ItemPicker from "../components/warehouse/ItemPicker";
import { warehouseIssueDetailOptions } from "../lib/queries/warehouse";
import { jsonQuery } from "../lib/apiAdapter";
import {
warehouseIssueDetailOptions,
warehouseLocationListOptions,
type WarehouseLocation,
} from "../lib/queries/warehouse";
import { projectListOptions, type Project } from "../lib/queries/projects";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
TextField,
Select,
Field,
LoadingState,
PageEnter,
} from "../ui";
interface IssueForm {
project_id: number | null;
notes: string;
}
interface MovementItem {
key: string;
item_id: number | null;
item_name?: string;
quantity: number;
unit_price: number;
location_id: number | null;
batch_id: number | null;
reservation_id: number | null;
notes: string | null;
}
interface Batch {
id: number;
quantity: number;
unit_price: number;
received_at: string;
is_consumed: boolean;
}
interface BatchesResponse {
batches: Batch[];
total_stock: number;
reserved_quantity: number;
available_quantity: number;
}
function parseDecimal(raw: string): number {
const cleaned = raw.replace(/[eE+\-]/g, "");
const n = Number(cleaned);
return Number.isFinite(n) ? n : 0;
}
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
const RemoveIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
);
const PlusIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
/** Inline batch dropdown: lists FIFO batches for an item, sets batch + unit price. */
function BatchSelect({
itemId,
value,
onChange,
}: {
itemId: number | null;
value: number | null;
onChange: (batchId: number, unitPrice: number) => void;
}) {
const { data: response } = useQuery({
queryKey: ["warehouse", "batches", itemId],
queryFn: () =>
jsonQuery<BatchesResponse>(
`/api/admin/warehouse/items/${itemId}/batches`,
),
enabled: !!itemId,
});
const batches = response?.batches ?? [];
return (
<Box>
{response && response.reserved_quantity > 0 && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mb: 0.5 }}
>
Dostupné: {response.available_quantity} ks (z toho rezervováno:{" "}
{response.reserved_quantity} ks)
</Typography>
)}
<Select
value={value === null ? "" : String(value)}
onChange={(val) => {
const batchId = Number(val);
const batch = batches.find((b) => b.id === batchId);
if (batch) onChange(batch.id, Number(batch.unit_price));
}}
options={[
{ value: "", label: "-- auto FIFO --" },
...batches.map((b) => ({
value: String(b.id),
label: `${new Date(b.received_at).toLocaleDateString("cs-CZ")} | ${b.quantity} ks | ${Number(b.unit_price).toFixed(2)}`,
})),
]}
/>
</Box>
);
}
export default function WarehouseIssueForm() {
const { id } = useParams();
const alert = useAlert();
@@ -87,6 +233,8 @@ export default function WarehouseIssueForm() {
const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 }));
const projects = projectsData?.data ?? [];
const { data: locations } = useQuery(warehouseLocationListOptions());
useEffect(() => {
formInitialized.current = false;
}, [id]);
@@ -134,6 +282,22 @@ export default function WarehouseIssueForm() {
]);
};
const removeItem = (index: number) => {
setItems((prev) => prev.filter((_, i) => i !== index));
};
const updateItem = (
index: number,
field: keyof MovementItem,
value: unknown,
) => {
setItems((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!form.project_id) {
@@ -245,138 +409,201 @@ export default function WarehouseIssueForm() {
};
if (isEdit && isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
const projectOptions = [
{ value: "", label: "Vyberte projekt..." },
...projects.map((p: Project) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
];
const locationOptions = [
{ value: "", label: "-- bez lokace --" },
...(locations ?? []).map((loc: WarehouseLocation) => ({
value: String(loc.id),
label: `${loc.code} - ${loc.name}`,
})),
];
return (
<div>
<PageEnter>
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 2,
mb: 3,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<Link
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<IconButton
component={RouterLink}
to="/warehouse/issues"
className="admin-btn-icon"
title="Zpět na seznam"
aria-label="Zpět na seznam"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="admin-page-title">
{BackIcon}
</IconButton>
<Typography variant="h4">
{isEdit ? "Upravit výdejku" : "Nová výdejka"}
</h1>
</div>
</div>
<div className="admin-page-actions">
<button
type="button"
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button
variant="outlined"
color="inherit"
onClick={handleSaveDraft}
className="admin-btn admin-btn-secondary"
disabled={saving}
>
{saving ? "Ukládání..." : "Uložit jako návrh"}
</button>
<button
type="button"
onClick={handleSaveAndConfirm}
className="admin-btn admin-btn-primary"
disabled={saving}
>
</Button>
<Button onClick={handleSaveAndConfirm} disabled={saving}>
{saving ? "Ukládání..." : "Uložit a potvrdit"}
</button>
</div>
</motion.div>
</Button>
</Box>
</Box>
{/* Header fields */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<FormField label="Projekt">
<select
value={form.project_id ?? ""}
onChange={(e) =>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Základní údaje
</Typography>
<Field label="Projekt" error={errors.project_id}>
<Select
value={form.project_id === null ? "" : String(form.project_id)}
onChange={(val) =>
setForm((prev) => ({
...prev,
project_id:
e.target.value === "" ? null : Number(e.target.value),
project_id: val === "" ? null : Number(val),
}))
}
className="admin-form-select"
>
<option value="">Vyberte projekt...</option>
{projects.map((p: Project) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
{errors.project_id && (
<div className="admin-form-error">{errors.project_id}</div>
)}
</FormField>
<FormField label="Poznámky">
<textarea
options={projectOptions}
/>
</Field>
<Field label="Poznámky">
<TextField
multiline
minRows={3}
value={form.notes}
onChange={(e) =>
setForm((prev) => ({ ...prev, notes: e.target.value }))
}
className="admin-form-input"
rows={3}
placeholder="Volitelné poznámky"
/>
</FormField>
</div>
</div>
</motion.div>
</Field>
</Card>
{/* Lines */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Položky</h3>
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Položky
</Typography>
{errors.items && (
<div
className="admin-form-error"
style={{ marginBottom: "0.75rem" }}
<Typography
variant="caption"
color="error"
sx={{ display: "block", mb: 1.5 }}
>
{errors.items}
</div>
</Typography>
)}
<WarehouseMovementTable
items={items}
onChange={setItems}
mode="issue"
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{items.map((item, index) => (
<Box
key={item.key}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
md: "minmax(160px, 1.6fr) minmax(160px, 1.6fr) 100px 100px minmax(130px, 1.1fr) minmax(120px, 1.2fr) auto",
},
gap: 1,
alignItems: "start",
}}
>
<ItemPicker
value={item.item_id}
itemName={item.item_name}
onChange={(id) => updateItem(index, "item_id", id)}
/>
</div>
</motion.div>
</div>
<BatchSelect
itemId={item.item_id}
value={item.batch_id}
onChange={(batchId, unitPrice) => {
updateItem(index, "batch_id", batchId);
updateItem(index, "unit_price", unitPrice);
}}
/>
<TextField
type="number"
value={item.quantity || ""}
onChange={(e) =>
updateItem(index, "quantity", parseDecimal(e.target.value))
}
inputProps={{
min: 0,
step: 0.001,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Množství, řádek ${index + 1}`,
}}
placeholder="Množství"
/>
<TextField
type="number"
value={item.unit_price || ""}
disabled
inputProps={{
min: 0,
step: 0.01,
inputMode: "decimal",
pattern: "[0-9]+([\\.,][0-9]+)?",
"aria-label": `Cena za kus, řádek ${index + 1}`,
}}
placeholder="Cena/ks"
/>
<Select
value={
item.location_id === null ? "" : String(item.location_id)
}
onChange={(val) =>
updateItem(index, "location_id", val ? Number(val) : null)
}
options={locationOptions}
/>
<TextField
value={item.notes ?? ""}
onChange={(e) => updateItem(index, "notes", e.target.value)}
placeholder="Poznámka"
/>
<IconButton
color="error"
onClick={() => removeItem(index)}
title="Odebrat řádek"
aria-label="Odebrat řádek"
sx={{ justifySelf: { xs: "start", md: "center" } }}
>
{RemoveIcon}
</IconButton>
</Box>
))}
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={PlusIcon}
onClick={addItem}
sx={{ mt: 2 }}
>
Přidat položku
</Button>
</Card>
</PageEnter>
);
}

View File

@@ -1,12 +1,9 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import AdminDatePicker from "../components/AdminDatePicker";
import { motion, AnimatePresence } from "framer-motion";
import Pagination from "../components/Pagination";
import Box from "@mui/material/Box";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
@@ -16,13 +13,32 @@ import {
type WarehouseIssue,
} from "../lib/queries/warehouse";
import { projectListOptions, type Project } from "../lib/queries/projects";
import {
Button,
Card,
DataTable,
Pagination,
TextField,
Select,
DateField,
StatusChip,
PageHeader,
PageEnter,
FilterBar,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const PER_PAGE = 20;
const STATUS_BADGE: Record<string, string> = {
DRAFT: "admin-badge-warning",
CONFIRMED: "admin-badge-active",
CANCELLED: "admin-badge-danger",
const STATUS_COLOR: Record<
string,
"warning" | "success" | "error" | "info" | "default"
> = {
DRAFT: "warning",
CONFIRMED: "success",
CANCELLED: "error",
};
const STATUS_LABEL: Record<string, string> = {
@@ -31,8 +47,23 @@ const STATUS_LABEL: Record<string, string> = {
CANCELLED: "Zrušeno",
};
const PlusIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
);
export default function WarehouseIssues() {
const { hasPermission } = useAuth();
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
@@ -58,16 +89,10 @@ export default function WarehouseIssues() {
}),
);
const navigate = useNavigate();
if (!hasPermission("warehouse.view")) return <Forbidden />;
const canOperate = hasPermission("warehouse.operate");
const handleRowClick = (issue: WarehouseIssue) => {
navigate(`/warehouse/issues/${issue.id}`);
};
const resetFilters = () => {
setSearch("");
setStatusFilter("");
@@ -77,256 +102,190 @@ export default function WarehouseIssues() {
setPage(1);
};
const hasActiveFilters = statusFilter || projectId || dateFrom || dateTo;
const hasActiveFilters = !!(statusFilter || projectId || dateFrom || dateTo);
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Výdejky</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? items.length}{" "}
{pagination?.total === 1
? "doklad"
: pagination?.total !== undefined &&
pagination.total >= 2 &&
pagination.total <= 4
? "doklady"
: "dokladů"}
</p>
</div>
<div className="admin-page-actions">
{canOperate && (
<button
onClick={() => navigate("/warehouse/issues/new")}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Nová výdejka
</button>
)}
</div>
</motion.div>
const total = pagination?.total ?? items.length;
const subtitle = `${total} ${
total === 1 ? "doklad" : total >= 2 && total <= 4 ? "doklady" : "dokladů"
}`;
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
const columns: DataColumn<WarehouseIssue>[] = [
{
key: "issue_number",
header: "Číslo dokladu",
width: "15%",
mono: true,
bold: true,
render: (r) => r.issue_number || "—",
},
{
key: "project",
header: "Projekt",
width: "25%",
render: (r) =>
r.project ? `${r.project.project_number} ${r.project.name}` : "—",
},
{
key: "status",
header: "Stav",
width: "13%",
render: (r) => (
<StatusChip
label={STATUS_LABEL[r.status] ?? r.status}
color={STATUS_COLOR[r.status] ?? "default"}
/>
),
},
{
key: "issued_by_user",
header: "Vydal",
width: "20%",
render: (r) =>
r.issued_by_user
? `${r.issued_by_user.first_name} ${r.issued_by_user.last_name}`
: "—",
},
{
key: "created_at",
header: "Vytvořeno",
width: "14%",
mono: true,
render: (r) => formatDate(r.created_at),
},
{
key: "items_count",
header: "Položek",
width: "13%",
mono: true,
render: (r) => String(r._count?.items ?? 0),
},
];
return (
<PageEnter>
<PageHeader
title="Výdejky"
subtitle={subtitle}
actions={
canOperate ? (
<Button
startIcon={PlusIcon}
onClick={() => navigate("/warehouse/issues/new")}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<div className="admin-search">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
Nová výdejka
</Button>
) : undefined
}
/>
<FilterBar>
<Box sx={{ flex: "1 1 320px" }}>
<TextField
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Hledat podle čísla dokladu..."
className="admin-form-input"
fullWidth
/>
</div>
<select
</Box>
<Box sx={{ flex: "0 0 160px" }}>
<Select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
onChange={(val) => {
setStatusFilter(val);
setPage(1);
}}
className="admin-form-select"
>
<option value="">Všechny stavy</option>
<option value="DRAFT">Návrh</option>
<option value="CONFIRMED">Potvrzeno</option>
<option value="CANCELLED">Zrušeno</option>
</select>
options={[
{ value: "", label: "Všechny stavy" },
{ value: "DRAFT", label: "Návrh" },
{ value: "CONFIRMED", label: "Potvrzeno" },
{ value: "CANCELLED", label: "Zrušeno" },
]}
/>
</Box>
{projects.length > 0 && (
<select
value={projectId}
onChange={(e) => {
setProjectId(
e.target.value === "" ? "" : Number(e.target.value),
);
<Box sx={{ flex: "0 0 220px" }}>
<Select
value={projectId === "" ? "" : String(projectId)}
onChange={(val) => {
setProjectId(val === "" ? "" : Number(val));
setPage(1);
}}
className="admin-form-select"
>
<option value="">Všechny projekty</option>
{projects.map((p: Project) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
options={[
{ value: "", label: "Všechny projekty" },
...projects.map((p: Project) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
]}
/>
</Box>
)}
<AdminDatePicker
mode="date"
<Box sx={{ flex: "0 0 150px" }}>
<DateField
value={dateFrom}
onChange={(val) => {
setDateFrom(val);
setPage(1);
}}
placeholder="Od"
label="Od"
/>
<AdminDatePicker
mode="date"
</Box>
<Box sx={{ flex: "0 0 150px" }}>
<DateField
value={dateTo}
onChange={(val) => {
setDateTo(val);
setPage(1);
}}
placeholder="Do"
label="Do"
/>
</Box>
{hasActiveFilters && (
<button
type="button"
className="admin-btn admin-btn-secondary"
onClick={resetFilters}
style={{ whiteSpace: "nowrap" }}
>
<Button variant="outlined" color="inherit" onClick={resetFilters}>
Zrušit filtry
</button>
</Button>
)}
</div>
</FilterBar>
<AnimatePresence mode="wait">
<motion.div
key={`${debouncedSearch}-${statusFilter}-${projectId}-${dateFrom}-${dateTo}-${page}-${items.length}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{items.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>
{search || hasActiveFilters
? "Žádné doklady pro zadaný filtr."
: "Zatím nejsou žádné výdejky."}
</p>
{canOperate && !search && !hasActiveFilters && (
<button
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
<DataTable<WarehouseIssue>
columns={columns}
rows={items}
rowKey={(r) => r.id}
onRowClick={(r) => navigate(`/warehouse/issues/${r.id}`)}
empty={
search || hasActiveFilters ? (
<EmptyState title="Žádné doklady pro zadaný filtr." />
) : (
<EmptyState
title="Zatím nejsou žádné výdejky."
action={
canOperate ? (
<Button
startIcon={PlusIcon}
onClick={() => navigate("/warehouse/issues/new")}
className="admin-btn admin-btn-primary"
>
Vytvořit první výdejku
</button>
)}
</div>
)}
{items.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Číslo dokladu</th>
<th>Projekt</th>
<th>Stav</th>
<th>Vydal</th>
<th>Vytvořeno</th>
<th>Položek</th>
</tr>
</thead>
<tbody>
{items.map((issue) => (
<tr
key={issue.id}
onClick={() => handleRowClick(issue)}
style={{ cursor: "pointer" }}
>
<td className="admin-mono fw-500">
{issue.issue_number || "—"}
</td>
<td>
{issue.project
? `${issue.project.project_number} ${issue.project.name}`
: "—"}
</td>
<td>
<span
className={`admin-badge ${STATUS_BADGE[issue.status] ?? ""}`}
>
{STATUS_LABEL[issue.status] ?? issue.status}
</span>
</td>
<td>
{issue.issued_by_user
? `${issue.issued_by_user.first_name} ${issue.issued_by_user.last_name}`
: "—"}
</td>
<td className="admin-mono">
{formatDate(issue.created_at)}
</td>
<td className="admin-mono">
{issue._count?.items ?? 0}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</motion.div>
</AnimatePresence>
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
</div>
</Button>
) : undefined
}
/>
)
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
</PageEnter>
);
}

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