Compare commits

...

196 Commits

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

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

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

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

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

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

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

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

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

Bump version to 2.0.4.

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

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

Bump version to 2.0.3.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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.2",
"version": "2.0.7",
"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

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

View File

@@ -172,6 +172,12 @@ const PERMISSIONS: {
module: "projects",
description: "Prohlížet seznam projektů",
},
{
name: "projects.create",
display_name: "Vytvořit projekt",
module: "projects",
description: "Ručně vytvářet projekty",
},
{
name: "projects.edit",
display_name: "Upravit projekt",

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,142 @@
import { describe, it, expect, afterEach } from "vitest";
import prisma from "../config/database";
import { createProject } from "../services/projects.service";
import { createOrder } from "../services/orders.service";
const createdProjectIds: number[] = [];
const createdOrderIds: number[] = [];
afterEach(async () => {
for (const id of createdProjectIds)
await prisma.projects.deleteMany({ where: { id } });
for (const id of createdOrderIds)
await prisma.orders.deleteMany({ where: { id } });
createdProjectIds.length = 0;
createdOrderIds.length = 0;
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
});
const baseOrder = {
status: "prijata" as const,
currency: "CZK",
language: "cs",
vat_rate: 21,
apply_vat: true,
exchange_rate: 1,
};
describe("createOrder (manual, optional linked project)", () => {
it("creates an order AND a project sharing one number when create_project=true", async () => {
const res = await createOrder({
...baseOrder,
scope_title: "Ruční zakázka",
create_project: true,
});
expect("id" in res).toBe(true);
if (!("id" in res)) return;
createdOrderIds.push(res.id);
expect(res.project_id).toBeTruthy();
const project = await prisma.projects.findUnique({
where: { id: res.project_id! },
});
if (project) createdProjectIds.push(project.id);
expect(project?.project_number).toBe(res.order_number);
expect(project?.order_id).toBe(res.id);
});
it("creates only the order when create_project=false", async () => {
const res = await createOrder({ ...baseOrder, create_project: false });
expect("id" in res).toBe(true);
if (!("id" in res)) return;
createdOrderIds.push(res.id);
expect(res.project_id).toBeUndefined();
});
});
describe("shared pool coherence (tracking)", () => {
it("order → standalone project → order produce three distinct shared numbers", async () => {
const o1 = await createOrder({ ...baseOrder, create_project: true });
if ("id" in o1) {
createdOrderIds.push(o1.id);
if (o1.project_id) createdProjectIds.push(o1.project_id);
}
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
if ("project_number" in p) createdProjectIds.push(p.id);
const o2 = await createOrder({ ...baseOrder, create_project: false });
if ("id" in o2) createdOrderIds.push(o2.id);
const n1 = "id" in o1 ? o1.order_number : null;
const np = "project_number" in p ? p.project_number : null;
const n2 = "id" in o2 ? o2.order_number : null;
expect(new Set([n1, np, n2]).size).toBe(3);
});
});
describe("createProject (manual, standalone)", () => {
it("assigns a shared number and is not linked to an order", async () => {
const result = await createProject({
name: "Test projekt",
status: "aktivni",
});
expect("project_number" in result).toBe(true);
if (!("project_number" in result)) return;
createdProjectIds.push(result.id);
expect(typeof result.project_number).toBe("string");
expect(result.project_number!.length).toBeGreaterThan(0);
expect(result.order_id).toBeNull();
});
it("gives consecutive standalone projects distinct numbers", async () => {
const a = await createProject({ name: "Projekt A", status: "aktivni" });
const b = await createProject({ name: "Projekt B", status: "aktivni" });
if ("project_number" in a) createdProjectIds.push(a.id);
if ("project_number" in b) createdProjectIds.push(b.id);
expect("project_number" in a && "project_number" in b).toBe(true);
if ("project_number" in a && "project_number" in b) {
expect(a.project_number).not.toBe(b.project_number);
}
});
});
describe("createOrder with price line item + attachment", () => {
it("stores a single line item with the entered price", async () => {
const res = await createOrder({
...baseOrder,
create_project: false,
items: [
{
description: "Práce",
quantity: 1,
unit_price: 12345,
is_included_in_total: true,
},
],
});
expect("id" in res).toBe(true);
if (!("id" in res)) return;
createdOrderIds.push(res.id);
const items = await prisma.order_items.findMany({
where: { order_id: res.id },
});
expect(items.length).toBe(1);
expect(Number(items[0].unit_price)).toBe(12345);
});
it("stores an uploaded PO attachment", async () => {
const buf = Buffer.from("%PDF-1.4 fake");
const res = await createOrder(
{ ...baseOrder, create_project: false },
buf,
"po.pdf",
);
expect("id" in res).toBe(true);
if (!("id" in res)) return;
createdOrderIds.push(res.id);
const order = await prisma.orders.findUnique({
where: { id: res.id },
select: { attachment_name: true, attachment_data: true },
});
expect(order?.attachment_name).toBe("po.pdf");
expect(order?.attachment_data).toBeTruthy();
});
});

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

@@ -20,6 +20,7 @@ import {
deleteOverride,
listEntries,
listOverrides,
bulkCreateEntries,
} from "../services/plan.service";
// ---------------------------------------------------------------------------
@@ -58,6 +59,20 @@ beforeEach(async () => {
await prisma.work_plan_overrides.deleteMany({
where: { note: { contains: N } },
});
// Also clean up empty-note rows on the specific test dates used by the
// "allows an empty note" tests. These rows are not caught by the prefix
// filter above, and would cause the per-cell cap check to reject them
// once three accumulate across runs.
await prisma.work_plan_entries.deleteMany({
where: {
date_from: {
in: [
new Date("2099-07-15T00:00:00.000Z"),
new Date("2097-08-05T00:00:00.000Z"),
],
},
},
});
});
afterAll(async () => {
@@ -75,10 +90,9 @@ afterAll(async () => {
// ---------------------------------------------------------------------------
describe("plan.service.resolveCell", () => {
it("returns null when nothing covers the date", async () => {
// No entry, no override for (adminUserId, "2099-01-01")
it("returns [] when nothing covers the date", async () => {
const result = await resolveCell(adminUserId, "2099-01-01");
expect(result).toBeNull();
expect(result).toEqual([]);
});
it("returns the entry that covers the date", async () => {
@@ -93,17 +107,14 @@ describe("plan.service.resolveCell", () => {
},
});
const result = await resolveCell(adminUserId, "2099-06-05");
expect(result).not.toBeNull();
expect(result?.source).toBe("entry");
expect(result?.note).toBe(`${N}PLC upgrade`);
expect(result?.category).toBe("work");
expect(result?.rangeFrom).toBe("2099-06-01");
expect(result?.rangeTo).toBe("2099-06-10");
expect(result.length).toBe(1);
expect(result[0].source).toBe("entry");
expect(result[0].note).toBe(`${N}PLC upgrade`);
expect(result[0].rangeFrom).toBe("2099-06-01");
expect(result[0].rangeTo).toBe("2099-06-10");
});
it("returns the override for that day, not the entry", async () => {
// An entry covers 2099-06-01..2099-06-10, but an override on 2099-06-05
// takes precedence.
it("returns overrides (entries hidden) when an override exists", async () => {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
@@ -124,10 +135,51 @@ describe("plan.service.resolveCell", () => {
},
});
const result = await resolveCell(adminUserId, "2099-06-05");
expect(result).not.toBeNull();
expect(result?.source).toBe("override");
expect(result?.note).toBe(`${N}Volno po noční`);
expect(result?.category).toBe("leave");
expect(result.length).toBe(1);
expect(result[0].source).toBe("override");
expect(result[0].note).toBe(`${N}Volno po noční`);
});
it("returns multiple additive entries newest-first", async () => {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-01"),
date_to: new Date("2099-06-10"),
category: "work",
note: `${N}first`,
created_by: adminUserId,
},
});
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-05"),
date_to: new Date("2099-06-05"),
category: "work",
note: `${N}second`,
created_by: adminUserId,
},
});
const result = await resolveCell(adminUserId, "2099-06-05");
expect(result.length).toBe(2);
expect(result[0].note).toBe(`${N}second`); // newest first
});
it("caps the returned records at MAX_RECORDS_PER_CELL", async () => {
for (let i = 0; i < 4; i++) {
await prisma.work_plan_overrides.create({
data: {
user_id: adminUserId,
shift_date: new Date("2099-06-06"),
category: "leave",
note: `${N}cap${i}`,
created_by: adminUserId,
},
});
}
const result = await resolveCell(adminUserId, "2099-06-06");
expect(result.length).toBe(3);
});
it("ignores soft-deleted entries and overrides", async () => {
@@ -143,7 +195,7 @@ describe("plan.service.resolveCell", () => {
},
});
const result = await resolveCell(adminUserId, "2099-06-05");
expect(result).toBeNull();
expect(result).toEqual([]);
});
});
@@ -164,11 +216,11 @@ describe("plan.service.resolveGrid", () => {
},
});
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-05");
expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-04"]).toBeNull();
expect(cells[adminUserId]["2099-06-05"]).toBeNull();
expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-04"]).toEqual([]);
expect(cells[adminUserId]["2099-06-05"]).toEqual([]);
});
it("applies override on a covered day", async () => {
@@ -192,9 +244,78 @@ describe("plan.service.resolveGrid", () => {
},
});
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-03");
expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}B`);
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`);
expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}B`);
expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
});
it("stacks additive entries newest-first and caps the day at MAX_RECORDS_PER_CELL", async () => {
// Two entries cover 2099-06-12 → both appear, newest-first. A 3rd entry
// covering the same day and a 4th overlapping one push past the cap, so the
// day must still return exactly 3 (mirrors resolveCell's cap behaviour).
//
// created_at is @db.Timestamp(0) (second precision, MySQL TIMESTAMP range
// tops out at 2038), so rows created in the same second tie on the
// `created_at desc` sort. We set explicit, distinct in-range created_at
// values so the newest-first assertion is deterministic.
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-11"),
date_to: new Date("2099-06-12"),
category: "work",
note: `${N}g1`,
created_by: adminUserId,
created_at: new Date("2037-06-01T08:00:00.000Z"),
},
});
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-12"),
date_to: new Date("2099-06-12"),
category: "work",
note: `${N}g2`,
created_by: adminUserId,
created_at: new Date("2037-06-01T09:00:00.000Z"), // newer than g1
},
});
// Two-entry day: assert length 2 and newest-first ordering.
const twoDay = await resolveGrid([adminUserId], "2099-06-11", "2099-06-12");
expect(twoDay[adminUserId]["2099-06-12"].length).toBe(2);
expect(twoDay[adminUserId]["2099-06-12"][0].note).toBe(`${N}g2`); // newest first
// Day with no second entry stays single.
expect(twoDay[adminUserId]["2099-06-11"].length).toBe(1);
// Add a 3rd and a 4th overlapping entry on 2099-06-12 to exceed the cap.
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-12"),
date_to: new Date("2099-06-12"),
category: "work",
note: `${N}g3`,
created_by: adminUserId,
created_at: new Date("2037-06-01T10:00:00.000Z"),
},
});
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2099-06-12"),
date_to: new Date("2099-06-12"),
category: "work",
note: `${N}g4`,
created_by: adminUserId,
created_at: new Date("2037-06-01T11:00:00.000Z"),
},
});
const cappedDay = await resolveGrid(
[adminUserId],
"2099-06-12",
"2099-06-12",
);
expect(cappedDay[adminUserId]["2099-06-12"].length).toBe(3);
});
});
@@ -341,6 +462,36 @@ describe("plan.service.createEntry", () => {
expect("error" in result).toBe(true);
if ("error" in result) expect(result.status).toBe(403);
});
it("rejects a 4th entry covering a day already at the cap", async () => {
for (let i = 0; i < 3; i++) {
const r = await createEntry(
{
user_id: adminUserId,
date_from: "2099-07-20",
date_to: "2099-07-20",
category: "work",
note: `${N}e${i}`,
},
adminUserId,
false,
);
expect("data" in r).toBe(true);
}
const fourth = await createEntry(
{
user_id: adminUserId,
date_from: "2099-07-20",
date_to: "2099-07-20",
category: "work",
note: `${N}e3`,
},
adminUserId,
false,
);
expect("error" in fourth).toBe(true);
if ("error" in fourth) expect(fourth.status).toBe(400);
});
});
// ---------------------------------------------------------------------------
@@ -439,7 +590,7 @@ describe("plan.service.deleteEntry", () => {
// ---------------------------------------------------------------------------
describe("plan.service.createOverride", () => {
it("creates an override and returns { data, oldData: null, replacedData: null }", async () => {
it("creates an override and returns { data, oldData: null }", async () => {
const result = await createOverride(
{
user_id: adminUserId,
@@ -454,42 +605,45 @@ describe("plan.service.createOverride", () => {
if ("data" in result) {
expect(result.data.note).toBe(`${N}day off`);
expect(result.oldData).toBeNull();
expect(result.replacedData).toBeNull();
}
});
it("soft-deletes the existing override and reports it in replacedData", async () => {
await createOverride(
it("stacks additive overrides and caps at MAX_RECORDS_PER_CELL", async () => {
for (let i = 0; i < 3; i++) {
const r = await createOverride(
{
user_id: adminUserId,
shift_date: "2099-10-02",
category: "leave",
note: `${N}first`,
note: `${N}o${i}`,
},
adminUserId,
false,
);
const second = await createOverride(
expect("data" in r).toBe(true);
}
// A 4th record on the same day is rejected by the cap.
const fourth = await createOverride(
{
user_id: adminUserId,
shift_date: "2099-10-02",
category: "sick",
note: `${N}second`,
category: "leave",
note: `${N}o3`,
},
adminUserId,
false,
);
expect("data" in second).toBe(true);
if ("data" in second) {
expect((second.replacedData as any)?.note).toBe(`${N}first`);
}
const all = await prisma.work_plan_overrides.findMany({
where: { user_id: adminUserId, shift_date: new Date("2099-10-02") },
expect("error" in fourth).toBe(true);
if ("error" in fourth) expect(fourth.status).toBe(400);
// All three earlier overrides remain active — no replace happened.
const active = await prisma.work_plan_overrides.findMany({
where: {
user_id: adminUserId,
shift_date: new Date("2099-10-02"),
is_deleted: false,
},
});
// The first should be soft-deleted, the second active
expect(all.find((o) => o.note === `${N}first`)?.is_deleted).toBe(true);
expect(all.find((o) => o.note === `${N}second`)?.is_deleted).toBe(false);
expect(active.length).toBe(3);
});
it("rejects a past date without force", async () => {
@@ -635,6 +789,184 @@ describe("plan.service.listOverrides (employee scoping)", () => {
});
});
// ---------------------------------------------------------------------------
// bulkCreateEntries
// ---------------------------------------------------------------------------
describe("plan.service.bulkCreateEntries", () => {
it("creates one continuous range per employee when weekends are included", async () => {
// 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one
// range 06-01..06-03 covering all 3 days.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-wknd`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(1);
expect(res.data.created_days).toBe(3);
expect(res.data.skipped_days).toBe(0);
expect(res.data.users).toBe(1);
});
it("splits into per-work-week ranges and skips weekends when excluded", async () => {
// 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue.
// Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-05",
include_weekends: false,
project_id: null,
category: "work",
note: `${N}bulk-week`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(3);
expect(res.data.skipped_days).toBe(0);
});
it("skips days already at the cap and splits the range around them", async () => {
// Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on
// bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips
// 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on.
for (let i = 0; i < 3; i++) {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2096-07-02"),
date_to: new Date("2096-07-02"),
category: "work",
note: `${N}cap${i}`,
created_by: adminUserId,
},
});
}
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-07-01",
date_to: "2096-07-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-cap`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(2);
expect(res.data.skipped_days).toBe(1);
});
it("aggregates across multiple employees", async () => {
const res = await bulkCreateEntries(
{
user_ids: [adminUserId, noPermUserId],
date_from: "2096-08-03", // Friday
date_to: "2096-08-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-multi`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.users).toBe(2);
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(2);
});
it("rejects empty user_ids, past dates, bad range, and inactive category", async () => {
const empty = await bulkCreateEntries(
{
user_ids: [],
date_from: "2096-06-01",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in empty && empty.status).toBe(400);
const tooLong = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-01-01",
date_to: "2096-12-31", // > 92 days
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in tooLong && tooLong.status).toBe(400);
const past = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2000-01-01",
date_to: "2000-01-02",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in past && past.status).toBe(403);
const badRange = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-10",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in badRange && badRange.status).toBe(400);
const badCat = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "__nope__",
note: `${N}x`,
},
adminUserId,
);
expect("error" in badCat && badCat.status).toBe(400);
});
});
// ---------------------------------------------------------------------------
// HTTP route tests (Fastify inject)
// ---------------------------------------------------------------------------
@@ -810,7 +1142,7 @@ describe("HTTP /api/admin/plan", () => {
const body = res.json();
expect(body.data.users).toBeDefined();
expect(body.data.cells[adminUserId]).toBeDefined();
expect(body.data.cells[adminUserId]["2097-06-01"].note).toBe(
expect(body.data.cells[adminUserId]["2097-06-01"][0].note).toBe(
`${N}grid test`,
);
});
@@ -883,4 +1215,32 @@ describe("HTTP /api/admin/plan", () => {
);
expect(res.statusCode).toBe(201);
});
it("POST /entries/bulk requires attendance.manage", async () => {
const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, {
user_ids: [adminUserId],
date_from: "2095-06-01",
date_to: "2095-06-01",
include_weekends: true,
category: "work",
note: `${N}bulk-forbidden`,
});
expect(res.statusCode).toBe(403);
});
it("POST /entries/bulk creates entries for an admin and returns a summary", async () => {
const res = await authPost("/api/admin/plan/entries/bulk", adminToken, {
user_ids: [adminUserId],
date_from: "2095-06-05", // Sunday; weekends on → 1 day
date_to: "2095-06-05",
include_weekends: true,
category: "work",
note: `${N}bulk-http`,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.created_days).toBe(1);
expect(body.data.users).toBe(1);
});
});

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,436 +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;
}

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,223 +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;
}
const modeToInputType: Record<string, string> = {
month: "month",
time: "time",
};
function NativeInput({
mode,
value,
onChange,
required,
minDate,
maxDate,
disabled,
}: 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"
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();
if (useNative) {
return (
<NativeInput
mode={mode}
value={value}
onChange={onChange}
required={required}
minDate={minDate}
maxDate={maxDate}
disabled={disabled}
/>
);
}
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={placeholder}
disabled={disabled}
/>
);
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: "cs",
customInput,
placeholderText: placeholder,
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,6 +1,6 @@
import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from "./AdminDatePicker";
import useModalLock from "../hooks/useModalLock";
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,182 +39,137 @@ export default function BulkAttendanceModal({
toggleUser,
toggleAllUsers,
}: BulkAttendanceModalProps) {
useModalLock(isOpen);
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<Modal
isOpen={isOpen}
onClose={onClose}
title="Vyplnit docházku za měsíc"
maxWidth="lg"
loading={submitting}
onSubmit={onSubmit}
submitText="Vyplnit měsíc"
submitDisabled={form.user_ids.length === 0}
>
<div
className="admin-modal-backdrop"
onClick={() => !submitting && onClose()}
/>
<motion.div
className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="bulk-attendance-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.25, mb: 2 }}
>
<div className="admin-modal-header">
<h2
id="bulk-attendance-modal-title"
className="admin-modal-title"
>
Vyplnit docházku za měsíc
</h2>
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
fontSize: "0.875rem",
}}
>
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky
označí. Existující záznamy se přeskočí.
</p>
</div>
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
Existující záznamy se přeskočí.
</Typography>
<div className="admin-modal-body">
<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 })
}
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 })
}
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 })
}
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 })
}
onChange={(val) => setForm({ ...form, break_end_time: val })}
/>
</div>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={submitting}
>
Zrušit
</button>
<button
type="button"
onClick={onSubmit}
className="admin-btn admin-btn-primary"
disabled={submitting || form.user_ids.length === 0}
>
{submitting ? "Vytvářím záznamy..." : "Vyplnit měsíc"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</Field>
</Box>
</Modal>
);
}

View File

@@ -0,0 +1,191 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import {
Modal,
Field,
DateField,
Select,
TextField,
CheckboxField,
} from "../ui";
import type { PlanUser, PlanCategory } from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects";
export interface BulkPlanForm {
date_from: string;
date_to: string;
is_range: boolean;
user_ids: string[];
include_weekends: boolean;
project_id: string;
category: string;
note: string;
}
interface Props {
isOpen: boolean;
onClose: () => void;
form: BulkPlanForm;
setForm: (form: BulkPlanForm) => void;
users: PlanUser[];
projects: Project[];
categories: PlanCategory[];
onSubmit: () => void;
submitting: boolean;
toggleUser: (userId: number) => void;
toggleAllUsers: () => void;
}
export default function BulkPlanModal({
isOpen,
onClose,
form,
setForm,
users,
projects,
categories,
onSubmit,
submitting,
toggleUser,
toggleAllUsers,
}: Props) {
const activeCategories = categories.filter((c) => c.is_active);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Hromadné přiřazení"
maxWidth="lg"
loading={submitting}
onSubmit={onSubmit}
submitText="Vytvořit"
submitDisabled={form.user_ids.length === 0}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.25, mb: 2 }}
>
Vytvoří záznamy pro vybrané dny u zvolených zaměstnanců. Dny, které
mají 3 záznamy, se přeskočí.
</Typography>
<Field label="Datum od">
<DateField
value={form.date_from}
onChange={(val) => setForm({ ...form, date_from: val })}
/>
</Field>
<Field label="Rozsah dnů">
<CheckboxField
label="Více dní"
checked={form.is_range}
onChange={(checked) => setForm({ ...form, is_range: checked })}
/>
</Field>
{form.is_range && (
<Field label="Datum do">
<DateField
value={form.date_to}
onChange={(val) => setForm({ ...form, date_to: val })}
/>
</Field>
)}
<Field label="Víkendy">
<CheckboxField
label="Zahrnout víkendy"
checked={form.include_weekends}
onChange={(checked) =>
setForm({ ...form, include_weekends: checked })
}
/>
</Field>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 0.5 }}>
<Typography
component="span"
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Zaměstnanci
</Typography>
<Typography
component="button"
type="button"
onClick={toggleAllUsers}
variant="caption"
sx={{
background: "none",
border: "none",
p: 0,
cursor: "pointer",
fontWeight: 500,
color: "primary.main",
}}
>
{users.length > 0 && form.user_ids.length === users.length
? "Odznačit vše"
: "Vybrat vše"}
</Typography>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 0.25,
maxHeight: 200,
overflowY: "auto",
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
border: 1,
borderColor: "divider",
}}
>
{users.map((u) => (
<CheckboxField
key={u.id}
label={u.full_name}
checked={form.user_ids.includes(String(u.id))}
onChange={() => toggleUser(u.id)}
/>
))}
</Box>
<Typography variant="caption" color="text.secondary">
Vybráno: {form.user_ids.length} z {users.length}
</Typography>
</Box>
<Field label="Projekt">
<Select
value={form.project_id}
onChange={(val) => setForm({ ...form, project_id: val })}
options={[
{ value: "", label: "— bez projektu —" },
...projects.map((p) => ({ value: String(p.id), label: p.name })),
]}
/>
</Field>
<Field label="Kategorie">
<Select
value={form.category}
onChange={(val) => setForm({ ...form, category: val })}
options={activeCategories.map((c) => ({
value: c.key,
label: c.label,
}))}
/>
</Field>
<Field label="Text poznámky (volitelný)">
<TextField
multiline
minRows={2}
value={form.note}
onChange={(e) => setForm({ ...form, note: e.target.value })}
inputProps={{ maxLength: 500 }}
/>
</Field>
</Modal>
);
}

View File

@@ -1,165 +0,0 @@
import { useEffect, type ReactNode } from "react";
import { motion, AnimatePresence } from "framer-motion";
import useReducedMotion from "../hooks/useReducedMotion";
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();
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={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,191 +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;
children: ReactNode; // modal body (form fields)
submitLabel?: string; // primary button text
cancelLabel?: string; // cancel button text
loading?: 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,
children,
submitLabel = "Uložit",
cancelLabel = "Zrušit",
loading = 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">
<h2 id={titleId} className="admin-modal-title">
{title}
</h2>
{!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}
>
{loading ? "Zpracování..." : submitLabel}
</button>
)}
</div>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,5 +1,10 @@
import { useState, useCallback } from "react";
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 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,135 +110,205 @@ export default function OrderConfirmationModal({
}, [defaultVatRate]);
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className={
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
<Modal
isOpen={isOpen}
onClose={onClose}
title={`Potvrzení objednávky ${orderNumber}`}
maxWidth={step === "edit" ? "lg" : "md"}
loading={loading}
submitText={
step === "choose" ? "Použít položky z objednávky" : "Vygenerovat PDF"
}
role="dialog"
aria-modal="true"
aria-labelledby="order-confirmation-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
submitDisabled={step === "edit" && items.length === 0}
onSubmit={step === "choose" ? handleUseExisting : handleEditGenerate}
>
<div className="admin-modal-header">
<h2
id="order-confirmation-modal-title"
className="admin-modal-title"
>
Potvrzení objednávky {orderNumber}
</h2>
</div>
<div className="admin-modal-body">
{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>
</Button>
</Field>
</Box>
) : (
<div className="admin-form">
<div className="admin-table-responsive">
<table className="admin-table">
<Box>
<Box sx={{ mb: 1.5 }}>
<Button
size="small"
variant="outlined"
color="inherit"
onClick={() => setStep("choose")}
disabled={loading}
>
Zpět
</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>
) : (
<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>
@@ -246,18 +323,16 @@ 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) =>
@@ -267,24 +342,21 @@ export default function OrderConfirmationModal({
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" }}
sx={{ width: 70 }}
/>
</td>
<td>
<input
<TextField
type="number"
value={item.unit_price}
onChange={(e) =>
@@ -294,13 +366,12 @@ 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) =>
@@ -310,16 +381,17 @@ export default function OrderConfirmationModal({
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"
@@ -332,65 +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>
</Button>
</Box>
</Box>
)}
</div>
<div className="admin-modal-footer">
{step === "edit" && (
<>
<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>
</>
)}
{step === "choose" && (
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
disabled={loading}
>
Zrušit
</button>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</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,
@@ -22,6 +31,7 @@ interface Project {
export type PlanCellModalMode =
| { kind: "closed" }
| { kind: "create"; userId: number; date: string }
| { kind: "create-override"; userId: number; date: string }
| {
kind: "edit-range";
entryId: number;
@@ -55,7 +65,8 @@ export type PlanCellModalMode =
note: string;
};
}
| { kind: "view"; userId: number; date: string; cell: ResolvedCell };
| { kind: "view"; userId: number; date: string; cell: ResolvedCell }
| { kind: "day"; userId: number; date: string; cells: ResolvedCell[] };
interface Props {
open: boolean;
@@ -75,14 +86,45 @@ interface Props {
date: string,
) => Promise<void>;
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
/** Open the create form for a brand-new record on (userId, date). The
* `source` picks the layer the new record lands in: "entry" in normal mode,
* "override" in exception mode (a day whose shown records are overrides). */
onAddRecord: (
userId: number,
date: string,
source: "entry" | "override",
) => void;
/** Open the right editor for one of the day's existing records. */
onEditRecord: (cell: ResolvedCell, 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;
if (mode.kind === "view") return <ViewModal {...props} />;
if (mode.kind === "day-in-range")
return <DayInRangeModal {...props} mode={mode} />;
if (mode.kind === "day") return <DayPanel {...props} mode={mode} />;
return <EditForm {...props} />;
}
@@ -102,21 +144,26 @@ function EditForm(props: Props) {
const [confirmDelete, setConfirmDelete] = useState(false);
// Narrow mode to the kinds EditForm actually handles. The call site in
// PlanCellModal only passes "create" | "edit-range" | "edit-override" but
// the parameter type is the full union, so we narrow explicitly here.
// PlanCellModal only passes "create" | "create-override" | "edit-range" |
// "edit-override" but the parameter type is the full union, so we narrow
// explicitly here.
if (
mode.kind !== "create" &&
mode.kind !== "create-override" &&
mode.kind !== "edit-range" &&
mode.kind !== "edit-override"
) {
return null;
}
const isOverride = mode.kind === "edit-override";
// An override is a single-day record — both editing one ("edit-override")
// and creating one ("create-override") lock the date and hide the range UI.
const isOverride =
mode.kind === "edit-override" || mode.kind === "create-override";
const activeCategories = props.categories.filter((c) => c.is_active);
const initial = (() => {
if (mode.kind === "create") {
if (mode.kind === "create" || mode.kind === "create-override") {
return {
date_from: mode.date,
date_to: mode.date,
@@ -161,6 +208,8 @@ function EditForm(props: Props) {
const title =
mode.kind === "create"
? "Nový záznam plánu"
: mode.kind === "create-override"
? "Nové přepsání dne"
: mode.kind === "edit-range"
? "Upravit rozsah"
: "Upravit přepsání dne";
@@ -178,6 +227,17 @@ function EditForm(props: Props) {
category,
note,
});
} else if (mode.kind === "create-override") {
// An override is a single day — lock it to mode.date regardless of
// any local dateFrom/isRange state (the date field is disabled for
// overrides, but be explicit).
await onSaveOverride({
user_id: mode.userId,
shift_date: mode.date,
project_id: projectId,
category,
note,
});
} else if (mode.kind === "edit-range") {
await onUpdateEntry(mode.entryId, {
date_from: dateFrom,
@@ -213,128 +273,115 @@ 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);
// Both create flows ("create" entry, "create-override") show "Vytvořit" and
// have no Smazat button (there is no existing row to delete yet).
const isCreate = mode.kind === "create" || mode.kind === "create-override";
return (
<>
<FormModal
<Modal
isOpen
title={title}
onClose={onClose}
onSubmit={handleSubmit}
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
submitText={isCreate ? "Vytvořit" : "Uložit"}
loading={submitting}
footerLeft={
mode.kind !== "create" ? (
<button
type="button"
className="admin-btn admin-btn-secondary"
>
{!isCreate && (
<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 +423,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 +452,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 +508,150 @@ 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>
);
}
const EditIcon = (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<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.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
function DayPanel(
props: Props & { mode: Extract<PlanCellModalMode, { kind: "day" }> },
) {
const { mode, onClose, onAddRecord, onEditRecord, categories } = props;
const catMap = Object.fromEntries(categories.map((x) => [x.key, x]));
const atCap = mode.cells.length >= 3;
const free = 3 - mode.cells.length;
// A day is in "exception mode" when its shown records are overrides. Cells
// are single-layer (all overrides OR all entries), so cells[0] is enough.
// "+ Přidat záznam" must add to the layer that's showing — an override here,
// otherwise resolveCell would hide a freshly-created entry behind the
// existing override after refetch.
const isOverrideMode = mode.cells[0]?.source === "override";
return (
<Modal
isOpen
title={`Plán — ${formatDate(mode.date)}`}
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít"
hideCancel
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25, mb: 2 }}>
{mode.cells.map((c, i) => {
const color =
catMap[c.category]?.color || "var(--mui-palette-divider)";
const projectLabel = cellProjectLabel(c);
return (
<Card
key={
c.entryId != null
? c.entryId
: c.overrideId != null
? `o${c.overrideId}`
: i
}
variant="outlined"
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
width: 4,
alignSelf: "stretch",
minHeight: 28,
borderRadius: 2,
bgcolor: color,
}}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontWeight: 600, fontSize: "0.85rem" }}>
{planCategoryLabel(c.category, catMap)}
{projectLabel ? ` · ${projectLabel}` : ""}
</Typography>
{c.note && (
<Typography variant="body2" color="text.secondary" noWrap>
{c.note}
</Typography>
)}
</Box>
<Button
variant="outlined"
color="inherit"
startIcon={EditIcon}
onClick={() => onEditRecord(c, mode.userId, mode.date)}
>
Upravit
</Button>
</Box>
</Card>
);
})}
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Button
variant="contained"
color="primary"
disabled={atCap}
onClick={() =>
onAddRecord(
mode.userId,
mode.date,
isOverrideMode ? "override" : "entry",
)
}
>
+ Přidat záznam
</Button>
<Typography variant="caption" color="text.secondary">
{atCap
? "Maximum 3 záznamy na den"
: `${free} ${free === 1 ? "volné místo" : "volná místa"}`}
</Typography>
</Box>
</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,368 @@ 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" },
"& .plan-cell-record": {
display: "block",
width: "100%",
minWidth: 0,
},
"& .plan-cell-record + .plan-cell-record": {
marginTop: 5,
paddingTop: 5,
borderTop: `1px dashed ${theme.vars!.palette.divider}`,
},
// 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;
@@ -20,11 +384,7 @@ interface Props {
// the value so back-to-back mutations on the same cell re-trigger
// the animation (React needs a new key to re-mount the class).
pulseKey?: { userId: number; date: string; nonce: number } | null;
onCellClick: (
userId: number,
date: string,
cell: ResolvedCell | null,
) => void;
onCellClick: (userId: number, date: string, cells: ResolvedCell[]) => void;
}
// Full Czech weekday names, indexed by Date.getUTCDay() (0 = Sunday).
@@ -107,12 +467,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 +479,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
@@ -181,7 +536,8 @@ export default function PlanGrid({
</span>
</td>
{users.map((u) => {
const cell = data.cells[u.id]?.[date] ?? null;
const cellArr = data.cells[u.id]?.[date] ?? [];
const cell = cellArr[0] ?? null;
const past = isPastDate(date, today);
// Past-day cells are read-only in the UI: an empty past
// cell is non-interactive (no create modal, no "+" hint),
@@ -223,29 +579,50 @@ export default function PlanGrid({
} as CSSProperties)
: undefined
}
onClick={() => onCellClick(u.id, date, cell)}
onClick={() => onCellClick(u.id, date, cellArr)}
aria-label={
cell
? cellArr.length === 1
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
: isLocked
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
: `${u.full_name}, ${date}, prázdné — přidat záznam`
}
>
{cellArr.map((c, i) => (
<Box
key={
c.entryId != null
? c.entryId
: c.overrideId != null
? `o${c.overrideId}`
: i
}
className="plan-cell-record"
style={
{
"--cat-color": catMap[c.category]?.color,
} as CSSProperties
}
>
<PlanRangeChips
cell={cell}
cell={c}
project={
cell?.project_id
c.project_id
? (projects.find(
(p) => p.id === cell.project_id,
(p) => p.id === c.project_id,
) ?? null)
: null
}
readonly={!canEdit}
categoryLabel={
cell ? planCategoryLabel(cell.category, catMap) : ""
}
categoryLabel={planCategoryLabel(
c.category,
catMap,
)}
/>
</Box>
))}
</button>
</td>
);
@@ -255,6 +632,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,6 +1,16 @@
import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from "./AdminDatePicker";
import useModalLock from "../hooks/useModalLock";
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,
@@ -81,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) {
@@ -101,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>
);
}
@@ -138,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>
);
}
@@ -216,7 +223,6 @@ export default function ShiftFormModal({
onShiftDateChange,
editingRecord,
}: ShiftFormModalProps) {
useModalLock(isOpen);
const isCreate = mode === "create";
const isWorkType = form.leave_type === "work";
@@ -247,224 +253,181 @@ export default function ShiftFormModal({
};
return (
<AnimatePresence>
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<Modal
isOpen={isOpen}
onClose={onClose}
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
subtitle={
!isCreate && editingRecord
? `${editingRecord.user_name}${formatDate(editingRecord.shift_date)}`
: undefined
}
maxWidth="lg"
onSubmit={onSubmit}
submitText="Uložit"
cancelText="Zrušit"
>
<div className="admin-modal-backdrop" onClick={onClose} />
<motion.div
className="admin-modal admin-modal-lg"
role="dialog"
aria-modal="true"
aria-labelledby="shift-form-modal-title"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 id="shift-form-modal-title" className="admin-modal-title">
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
</h2>
{!isCreate && editingRecord && (
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
{isCreate ? (
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
{editingRecord.user_name} {" "}
{formatDate(editingRecord.shift_date)}
</p>
)}
</div>
<div className="admin-modal-body">
<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
<Field label="Zaměstnanec" required>
<Select
value={form.user_id}
onChange={(e) => updateField("user_id", e.target.value)}
className="admin-form-select"
>
<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"
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)
}
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)
}
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
@@ -476,47 +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>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={onClose}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={onSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</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, AnimatePresence } from "framer-motion";
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 useModalLock from "../../hooks/useModalLock";
import apiFetch from "../../utils/api";
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: "",
@@ -71,8 +112,6 @@ export default function DashProfile({
last_name: "",
});
useModalLock(showModal);
const openEditModal = () => {
const nameParts = (user?.fullName || "").split(" ");
setFormData({
@@ -135,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"
@@ -222,217 +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 */}
<AnimatePresence>
{showModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title="Upravit profil"
maxWidth="sm"
onSubmit={handleSubmit}
submitText="Uložit změny"
>
<div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit profil</h2>
</div>
<div className="admin-modal-body">
<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"
<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>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
Uložit změny
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</Field>
</Modal>
{/* 2FA Setup Modal */}
<AnimatePresence>
{show2FASetup && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => {
if (!backupCodes) {
{/* 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("");
}
}}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
}
fullWidth
maxWidth="sm"
disableScrollLock
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
<DialogTitle>
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
</h2>
</div>
<div className="admin-modal-body">
</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"
@@ -445,169 +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"),
);
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>
</div>
</Button>
</Box>
</Box>
) : (
<div>
<p
className="text-secondary"
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
>
Naskenujte QR kód v autentizační aplikaci (Google
Authenticator, Authy, Microsoft Authenticator apod.)
</p>
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
Authy, Microsoft Authenticator apod.)
</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, ""))
}
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) {
@@ -615,128 +581,86 @@ export default function DashProfile({
}
}}
/>
</div>
</div>
</Field>
</Box>
)}
</div>
<div className="admin-modal-footer">
</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
<Button
onClick={() => {
setShow2FASetup(false);
setBackupCodes(null);
}}
className="admin-btn admin-btn-primary"
>
Rozumím, uložil jsem si kódy
</button>
</Button>
) : (
<>
<button
onClick={() => setShow2FASetup(false)}
className="admin-btn admin-btn-secondary"
<Button
onClick={() => {
setShow2FASetup(false);
setTotpCode("");
}}
variant="text"
color="inherit"
disabled={totpSubmitting}
>
Zrušit
</button>
<button
</Button>
<Button
onClick={onConfirm2FA}
className="admin-btn admin-btn-primary"
disabled={totpSubmitting || totpCode.length !== 6}
>
{totpSubmitting ? "Ověřuji..." : "Aktivovat 2FA"}
</button>
{totpSubmitting ? "Ukládám…" : "Aktivovat 2FA"}
</Button>
</>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</DialogActions>
</Dialog>
{/* 2FA Disable Modal */}
<AnimatePresence>
{show2FADisable && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setShow2FADisable(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Deaktivovat 2FA</h2>
</div>
<div className="admin-modal-body">
<p
style={{
color: "var(--text-secondary)",
fontSize: "0.875rem",
marginBottom: "1rem",
}}
<Modal
isOpen={show2FADisable}
onClose={() => setShow2FADisable(false)}
title="Deaktivovat 2FA"
maxWidth="sm"
loading={totpSubmitting}
onSubmit={onDisable2FA}
submitDisabled={disableCode.length !== 6}
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, ""))
}
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>
</div>
<div className="admin-modal-footer">
<button
onClick={() => setShow2FADisable(false)}
className="admin-btn admin-btn-secondary"
disabled={totpSubmitting}
>
Zrušit
</button>
<button
onClick={onDisable2FA}
className="admin-btn admin-btn-primary"
disabled={totpSubmitting || disableCode.length !== 6}
>
{totpSubmitting ? "Deaktivuji..." : "Deaktivovat 2FA"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</Field>
</Modal>
</>
);
}

View File

@@ -1,12 +1,12 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
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 useModalLock from "../../hooks/useModalLock";
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,
@@ -69,8 +77,6 @@ export default function DashQuickActions({
});
const [tripErrors, setTripErrors] = useState<TripErrors>({});
useModalLock(showTripModal);
const openTripModal = async () => {
setTripForm({
vehicle_id: "",
@@ -286,303 +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>
<AnimatePresence>
{showTripModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<Modal
isOpen={showTripModal}
onClose={() => setShowTripModal(false)}
title="Přidat jízdu"
maxWidth="md"
onSubmit={handleTripSubmit}
submitText="Uložit"
loading={tripSubmitting}
>
<div
className="admin-modal-backdrop"
onClick={() => setShowTripModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Přidat jízdu</h2>
</div>
<div className="admin-modal-body">
<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
<Field label="Vozidlo" required error={tripErrors.vehicle_id}>
<Select
value={tripForm.vehicle_id}
onChange={(e) => {
handleTripVehicleChange(e.target.value);
setTripErrors((prev) => ({
...prev,
vehicle_id: undefined,
}));
onChange={(val) => {
handleTripVehicleChange(val);
setTripErrors((prev) => ({ ...prev, vehicle_id: undefined }));
}}
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"
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>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowTripModal(false)}
className="admin-btn admin-btn-secondary"
disabled={tripSubmitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleTripSubmit}
className="admin-btn admin-btn-primary"
disabled={tripSubmitting}
>
{tripSubmitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</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,114 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Card } from "../../ui";
import { formatDate } from "../../utils/formatters";
/** One resolved plan record 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";
}
function PlanRow({ plan }: { plan: TodayPlan }) {
const color = plan.category_color || "var(--mui-palette-primary-main)";
return (
<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>
);
}
/** "Vaše dnešní zařazení" — the logged-in user's resolved plan record(s) for
* today (up to 3). Empty array = nothing scheduled. */
export default function DashTodayPlan({ plans }: { plans: TodayPlan[] }) {
return (
<Card sx={{ mb: 3 }}>
<Typography
variant="subtitle2"
sx={{ mb: 1.5, fontWeight: 600, color: "text.secondary" }}
>
Vaše dnešní zařazení
</Typography>
{plans.length > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{plans.map((p, i) => (
<Box
key={i}
sx={{
pt: i === 0 ? 0 : 2,
borderTop: i === 0 ? 0 : 1,
borderColor: "divider",
}}
>
<PlanRow plan={p} />
</Box>
))}
</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,199 +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;
}

View File

@@ -1,171 +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;
}

View File

@@ -1,488 +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-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;
}

View File

@@ -12,9 +12,11 @@ export type ViewMode = "week" | "month";
export type ModalMode =
| { kind: "closed" }
| { kind: "create"; userId: number; date: string }
| { kind: "create-override"; userId: number; date: string }
| { kind: "edit-range"; entryId: number; userId: number; date: string }
| { kind: "edit-override"; overrideId: number; userId: number; date: string }
| { kind: "day-in-range"; userId: number; date: string; entryId: number }
| { kind: "day"; userId: number; date: string }
| { kind: "view"; userId: number; date: string };
function isoDate(d: Date): string {
@@ -150,16 +152,16 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
key: readonly unknown[],
userId: number,
dates: string[],
mutator: (prev: ResolvedCell | null) => ResolvedCell | null,
): Record<string, ResolvedCell | null> | null {
mutator: (prev: ResolvedCell[]) => ResolvedCell[],
): Record<string, ResolvedCell[]> | null {
const prev = snapshotGrid(key);
if (!prev) return null;
const userPrev = prev.cells[userId] ?? {};
const rolled: Record<string, ResolvedCell | null> = {};
const userNext: Record<string, ResolvedCell | null> = { ...userPrev };
const rolled: Record<string, ResolvedCell[]> = {};
const userNext: Record<string, ResolvedCell[]> = { ...userPrev };
for (const date of dates) {
rolled[date] = userPrev[date] ?? null;
userNext[date] = mutator(rolled[date]);
rolled[date] = userPrev[date] ?? [];
userNext[date] = mutator(rolled[date]).slice(0, 3);
}
qc.setQueryData(key, {
...prev,
@@ -244,7 +246,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// the ResolvedCell mirrors the request body.
const days = eachDay(body.date_from, body.date_to);
const id = data && typeof data.id === "number" ? data.id : null;
const rolled = patchCells(currentGridKey, body.user_id, days, () =>
const rolled = patchCells(currentGridKey, body.user_id, days, (prev) => [
makeEntryCell({
userId: body.user_id,
date: days[0],
@@ -255,7 +257,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
rangeTo: body.date_to,
entryId: id,
}),
);
...prev,
]);
// Stash the rollback on the mutation object so PlanWork can call
// it from onError.
(createEntry as any)._rolled = rolled;
@@ -294,8 +297,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
let ownerUserId: number | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const cell of Object.values(byDate)) {
if (cell && cell.entryId === id) {
for (const cells of Object.values(byDate)) {
if (cells.some((c) => c.entryId === id)) {
ownerUserId = Number(uidStr);
break;
}
@@ -304,20 +307,27 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
}
}
if (ownerUserId !== null) {
const rolled = patchCells(currentGridKey, ownerUserId, days, (prev) =>
makeEntryCell({
const rolled = patchCells(
currentGridKey,
ownerUserId,
days,
(prev) => {
const existing = prev.find((c) => c.entryId === id) ?? null;
const updated = makeEntryCell({
userId: ownerUserId!,
date: days[0],
projectId:
body.project_id === undefined
? (prev?.project_id ?? null)
? (existing?.project_id ?? null)
: body.project_id,
category: body.category ?? prev?.category ?? "work",
note: body.note ?? prev?.note ?? "",
category: body.category ?? existing?.category ?? "work",
note: body.note ?? existing?.note ?? "",
rangeFrom: body.date_from,
rangeTo: body.date_to,
entryId: id,
}),
});
return [updated, ...prev.filter((c) => c.entryId !== id)];
},
);
(updateEntry as any)._rolled = rolled;
}
@@ -338,26 +348,29 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
const grid = qc.getQueryData<GridData>(currentGridKey as any);
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [, cell] of Object.entries(byDate)) {
if (
cell &&
cell.entryId === vars.id &&
cell.rangeFrom &&
cell.rangeTo
) {
const days = eachDay(cell.rangeFrom, cell.rangeTo);
let range: { from: string; to: string } | null = null;
for (const cells of Object.values(byDate)) {
const hit = cells.find(
(c) => c.entryId === vars.id && c.rangeFrom && c.rangeTo,
);
if (hit) {
range = { from: hit.rangeFrom!, to: hit.rangeTo! };
break;
}
}
if (range) {
const days = eachDay(range.from, range.to);
const rolled = patchCells(
currentGridKey,
Number(uidStr),
days,
() => null,
(prev) => prev.filter((c) => c.entryId !== vars.id),
);
(deleteEntry as any)._rolled = rolled;
break;
}
}
}
}
invalidate();
},
});
@@ -375,7 +388,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
currentGridKey,
vars.user_id,
[vars.shift_date],
() =>
(prev) => [
makeOverrideCell({
userId: vars.user_id,
date: vars.shift_date,
@@ -384,6 +397,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
note: vars.note,
overrideId: id,
}),
...prev,
],
);
(createOverride as any)._rolled = rolled;
invalidate();
@@ -411,21 +426,30 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
const grid = qc.getQueryData<GridData>(currentGridKey as any);
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cell] of Object.entries(byDate)) {
if (cell && cell.overrideId === vars.id) {
for (const [date, cells] of Object.entries(byDate)) {
if (cells.some((c) => c.overrideId === vars.id)) {
const rolled = patchCells(
currentGridKey,
Number(uidStr),
[date],
(prev) =>
makeOverrideCell({
(prev) => {
const existing =
prev.find((c) => c.overrideId === vars.id) ?? null;
const updated = makeOverrideCell({
userId: Number(uidStr),
date,
projectId: vars.body.project_id ?? prev?.project_id ?? null,
category: vars.body.category ?? prev?.category ?? "work",
note: vars.body.note ?? prev?.note ?? "",
projectId:
vars.body.project_id ?? existing?.project_id ?? null,
category:
vars.body.category ?? existing?.category ?? "work",
note: vars.body.note ?? existing?.note ?? "",
overrideId: vars.id,
}),
});
return [
updated,
...prev.filter((c) => c.overrideId !== vars.id),
];
},
);
(updateOverride as any)._rolled = rolled;
break;
@@ -446,13 +470,13 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
const grid = qc.getQueryData<GridData>(currentGridKey as any);
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cell] of Object.entries(byDate)) {
if (cell && cell.overrideId === vars.id) {
for (const [date, cells] of Object.entries(byDate)) {
if (cells.some((c) => c.overrideId === vars.id)) {
const rolled = patchCells(
currentGridKey,
Number(uidStr),
[date],
() => null,
(prev) => prev.filter((c) => c.overrideId !== vars.id),
);
(deleteOverride as any)._rolled = rolled;
break;
@@ -464,6 +488,20 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
},
});
const bulkCreate = useMutation({
mutationFn: (body: any) =>
apiCall("/api/admin/plan/entries/bulk", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
}),
onSuccess: () => {
// No optimistic patch — bulk can touch many cells; just invalidate and
// let the grid refetch the authoritative state.
invalidate();
},
});
// Roll back an optimistic patch on mutation error. Called from
// PlanWork's mutation wrappers via `rollbackMutation(mutation, key)`.
// `mutation` is a TanStack mutation result with a `_rolled` snapshot stashed
@@ -472,15 +510,15 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
// directly without the "weak type" mismatch a `{ _rolled? }` param causes.
function rollbackMutation(mutation: unknown, userId: number) {
const stash = mutation as {
_rolled?: Record<string, ResolvedCell | null> | null;
_rolled?: Record<string, ResolvedCell[]> | null;
};
if (!stash._rolled) return;
const prev = qc.getQueryData<GridData>(currentGridKey as any);
if (!prev) return;
const userPrev = prev.cells[userId] ?? {};
const userNext = { ...userPrev };
for (const [date, cell] of Object.entries(stash._rolled)) {
userNext[date] = cell;
for (const [date, cells] of Object.entries(stash._rolled)) {
userNext[date] = cells;
}
qc.setQueryData(currentGridKey, {
...prev,
@@ -510,6 +548,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
createOverride,
updateOverride,
deleteOverride,
bulkCreate,
invalidate,
// Exposed so PlanWork can roll back an optimistic patch when a
// mutation throws. Pass the failed mutation and the userId it
@@ -518,10 +557,18 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
};
}
export function getCells(
grid: GridData | undefined,
userId: number,
date: string,
): ResolvedCell[] {
return grid?.cells?.[userId]?.[date] ?? [];
}
export function getCell(
grid: GridData | undefined,
userId: number,
date: string,
): ResolvedCell | null {
return grid?.cells?.[userId]?.[date] ?? null;
return getCells(grid, userId, date)[0] ?? null;
}

View File

@@ -1,142 +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) {
.invoice-month-btn {
width: 44px;
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

@@ -82,3 +82,12 @@ export const orderDetailOptions = (id: string | undefined) =>
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
enabled: !!id,
});
export const orderNextNumberOptions = () =>
queryOptions({
queryKey: ["orders", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
"/api/admin/orders/next-number",
),
});

View File

@@ -81,7 +81,7 @@ export interface GridData {
date_from: string;
date_to: string;
users: PlanUser[];
cells: Record<number, Record<string, ResolvedCell | null>>;
cells: Record<number, Record<string, ResolvedCell[]>>;
}
export const planKeys = {

View File

@@ -109,3 +109,12 @@ export const projectFilesOptions = (projectId: number, path: string) =>
};
},
});
export const projectNextNumberOptions = () =>
queryOptions({
queryKey: ["projects", "next-number"],
queryFn: () =>
jsonQuery<{ next_number?: string; number?: string }>(
"/api/admin/projects/next-number",
),
});

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,621 +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;
}
@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[];
[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 plans={dashData?.today_plan ?? []} />
)}
{/* 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,42 @@
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 } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
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 } from "../lib/queries/orders";
import {
orderListOptions,
orderNextNumberOptions,
} 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";
@@ -23,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 {
@@ -43,210 +70,20 @@ interface Order {
invoice_id?: number;
}
export default function Orders() {
const alert = useAlert();
const { hasPermission } = useAuth();
const { sort, order, handleSort, activeSort } = useTableSort("order_number");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
order: Order | null;
}>({ show: false, order: null });
const [deleteFiles, setDeleteFiles] = useState(false);
const deleteMutation = useApiMutation<
{ id: number; delete_files: boolean },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/orders/${id}`,
method: () => "DELETE",
invalidate: ["orders", "offers", "projects", "invoices"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
alert.success(data?.message || "Objednávka byla smazána");
},
});
const {
items: orders,
pagination,
isPending,
isFetching,
} = usePaginatedQuery<Order>(orderListOptions({ search, sort, order, page }));
if (!hasPermission("orders.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteConfirm.order) return;
try {
await deleteMutation.mutateAsync({
id: deleteConfirm.order.id,
delete_files: deleteFiles,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
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,
"objednávka",
"objednávky",
"objednávek",
)}
</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 }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
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..."
/>
</div>
{orders.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
const PlusIcon = (
<svg
width="28"
height="28"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<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" />
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</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}
/>
</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"
>
);
const ViewIcon = (
<svg
width="18"
height="18"
@@ -258,14 +95,8 @@ export default function Orders() {
<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"
>
);
const InvoiceViewIcon = (
<svg
width="18"
height="18"
@@ -288,15 +119,8 @@ export default function Orders() {
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"
>
);
const InvoiceCreateIcon = (
<svg
width="18"
height="18"
@@ -310,17 +134,8 @@ export default function Orders() {
<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"
>
);
const DeleteIcon = (
<svg
width="18"
height="18"
@@ -328,25 +143,376 @@ export default function Orders() {
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>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
);
<ConfirmModal
export default function Orders() {
const alert = useAlert();
const { hasPermission } = useAuth();
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<{
show: boolean;
order: Order | null;
}>({ show: false, order: null });
const [deleteFiles, setDeleteFiles] = useState(false);
const deleteMutation = useApiMutation<
{ id: number; delete_files: boolean },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/orders/${id}`,
method: () => "DELETE",
invalidate: ["orders", "offers", "projects", "invoices"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
alert.success(data?.message || "Objednávka byla smazána");
},
});
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({
customer_id: "",
customer_order_number: "",
currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "",
scope_description: "",
notes: "",
create_project: true,
price: "",
quantity: "1",
});
const [creating, setCreating] = useState(false);
const queryClient = useQueryClient();
const navigate = useNavigate();
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const customersQuery = useQuery({
...offerCustomersOptions(),
enabled: showCreate,
});
const nextNumberQuery = useQuery({
...orderNextNumberOptions(),
enabled: showCreate,
});
const openCreate = () => {
setCreateForm({
customer_id: "",
customer_order_number: "",
currency: "CZK",
vat_rate: "21",
apply_vat: true,
scope_title: "",
scope_description: "",
notes: "",
create_project: true,
price: "",
quantity: "1",
});
setOrderAttachment(null);
setShowCreate(true);
};
const handleCreate = async () => {
setCreating(true);
try {
const priceNum = createForm.price ? Number(createForm.price) : 0;
const qtyNum = createForm.quantity ? Number(createForm.quantity) : 1;
const items =
priceNum > 0
? [
{
description: createForm.scope_title || "Položka",
quantity: qtyNum,
unit_price: priceNum,
is_included_in_total: true,
},
]
: undefined;
let fetchOptions: RequestInit;
if (orderAttachment) {
const fd = new FormData();
if (createForm.customer_id)
fd.append("customer_id", createForm.customer_id);
fd.append("customer_order_number", createForm.customer_order_number);
fd.append("currency", createForm.currency);
fd.append("vat_rate", createForm.vat_rate);
fd.append("apply_vat", createForm.apply_vat ? "1" : "0");
fd.append("scope_title", createForm.scope_title);
fd.append("scope_description", createForm.scope_description);
fd.append("notes", createForm.notes);
fd.append("create_project", createForm.create_project ? "1" : "0");
if (items) fd.append("items", JSON.stringify(items));
fd.append("attachment", orderAttachment);
fetchOptions = { method: "POST", body: fd };
} else {
fetchOptions = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
customer_id: createForm.customer_id
? Number(createForm.customer_id)
: null,
customer_order_number: createForm.customer_order_number,
currency: createForm.currency,
vat_rate: createForm.vat_rate,
apply_vat: createForm.apply_vat,
scope_title: createForm.scope_title,
scope_description: createForm.scope_description,
notes: createForm.notes,
create_project: createForm.create_project,
...(items ? { items } : {}),
}),
};
}
const response = await apiFetch(`${API_BASE}/orders`, fetchOptions);
const result = await response.json();
if (result.success) {
setShowCreate(false);
alert.success(result.message || "Objednávka byla vytvořena");
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
if (result.data?.id) navigate(`/orders/${result.data.id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit objednávku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setCreating(false);
}
};
const {
items: orders,
pagination,
isPending,
isFetching,
} = usePaginatedQuery<Order>(
orderListOptions({ search: debouncedSearch, sort, order, page }),
);
if (!hasPermission("orders.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteConfirm.order) return;
try {
await deleteMutation.mutateAsync({
id: deleteConfirm.order.id,
delete_files: deleteFiles,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (isPending) {
return <LoadingState />;
}
const total = pagination?.total ?? orders.length;
const subtitle = `${total} ${czechPlural(
total,
"objednávka",
"objednávky",
"objednávek",
)}`;
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" },
}}
>
{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);
}}
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
fullWidth
/>
</Box>
</FilterBar>
<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."
/>
}
/>
<Pagination
page={page}
pageCount={pagination?.total_pages ?? 1}
onChange={setPage}
/>
</Card>
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => {
setDeleteConfirm({ show: false, order: null });
@@ -355,28 +521,184 @@ 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}
/>
</div>
</ConfirmDialog>
<Modal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSubmit={handleCreate}
title="Vytvořit objednávku bez nabídky"
subtitle={`Číslo objednávky: ${
nextNumberQuery.data?.number ??
nextNumberQuery.data?.next_number ??
"…"
} (přiděleno automaticky)`}
submitText="Vytvořit"
loading={creating}
>
<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={(value) =>
setCreateForm({ ...createForm, customer_id: value })
}
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({
...createForm,
customer_order_number: e.target.value,
})
}
/>
</Field>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Měna">
<Select
value={createForm.currency}
onChange={(value) =>
setCreateForm({ ...createForm, currency: value })
}
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 })
}
slotProps={{ htmlInput: { min: 0 } }}
/>
</Field>
</Box>
</Box>
<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 })
}
placeholder="0"
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
/>
</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 })
}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
/>
</Field>
</Box>
</Box>
<Field label="Předmět (scope)">
<TextField
value={createForm.scope_title}
onChange={(e) =>
setCreateForm({ ...createForm, scope_title: e.target.value })
}
/>
</Field>
<Field label="Popis">
<TextField
multiline
minRows={3}
value={createForm.scope_description}
onChange={(e) =>
setCreateForm({
...createForm,
scope_description: e.target.value,
})
}
/>
</Field>
<Field label="Poznámka">
<TextField
multiline
minRows={2}
value={createForm.notes}
onChange={(e) =>
setCreateForm({ ...createForm, notes: e.target.value })
}
/>
</Field>
<Field label="Příloha (PO zákazníka, PDF)">
<FileUpload
files={orderAttachment ? [orderAttachment] : []}
onFilesChange={(files) => setOrderAttachment(files[0] ?? null)}
accept="application/pdf"
multiple={false}
/>
</Field>
<CheckboxField
label="Účtovat DPH"
checked={createForm.apply_vat}
onChange={(v) => setCreateForm({ ...createForm, apply_vat: v })}
/>
<CheckboxField
label="Vytvořit propojený projekt"
checked={createForm.create_project}
onChange={(v) => setCreateForm({ ...createForm, create_project: v })}
/>
</Modal>
</PageEnter>
);
}

View File

@@ -1,20 +1,29 @@
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 {
usePlanWork,
getCell,
getCells,
type ModalMode as HookModalMode,
} from "../hooks/usePlanWork";
import type { PlanCellModalMode } from "../components/PlanCellModal";
import PlanGrid from "../components/PlanGrid";
import PlanCellModal from "../components/PlanCellModal";
import PlanCategoriesModal from "../components/PlanCategoriesModal";
import BulkPlanModal, { type BulkPlanForm } from "../components/BulkPlanModal";
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";
import {
planCategoriesOptions,
type PlanCategory,
type ResolvedCell,
} from "../lib/queries/plan";
// plan.css is imported once globally in AdminApp.tsx — no per-page re-import.
const MONTH_NAMES = [
@@ -120,6 +129,7 @@ export default function PlanWork() {
createOverride,
updateOverride,
deleteOverride,
bulkCreate,
rollbackMutation,
} = usePlanWork({ canEdit });
@@ -132,6 +142,84 @@ export default function PlanWork() {
const [catModalOpen, setCatModalOpen] = useState(false);
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkSubmitting, setBulkSubmitting] = useState(false);
const [bulkForm, setBulkForm] = useState<BulkPlanForm>(() => {
const today = todayIsoLocal();
return {
date_from: today,
date_to: today,
is_range: false,
user_ids: [],
include_weekends: false,
project_id: "",
category: "work",
note: "",
};
});
const planUsers = useMemo(() => grid?.users ?? [], [grid]);
const toggleBulkUser = useCallback((userId: number) => {
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.includes(String(userId))
? prev.user_ids.filter((u) => u !== String(userId))
: [...prev.user_ids, String(userId)],
}));
}, []);
const toggleAllBulkUsers = useCallback(() => {
const allIds = (grid?.users ?? []).map((u) => String(u.id));
if (allIds.length === 0) return; // grid not yet loaded — no-op
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.length === allIds.length ? [] : allIds,
}));
}, [grid]);
const submitBulk = useCallback(async () => {
if (bulkForm.user_ids.length === 0) return;
setBulkSubmitting(true);
try {
const data: any = await bulkCreate.mutateAsync({
user_ids: bulkForm.user_ids.map(Number),
date_from: bulkForm.date_from,
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
include_weekends: bulkForm.include_weekends,
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
category: bulkForm.category,
note: bulkForm.note,
});
// apiCall returns the full envelope { success, data, message };
// the summary counts are under .data
const summary = data?.data ?? data;
const skipped =
summary?.skipped_days > 0
? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).`
: "";
alert.success(
`Vytvořeno ${summary?.created_days ?? 0} záznamů pro ${summary?.users ?? 0} zaměstnanců.${skipped}`,
);
setBulkOpen(false);
// Reset the volatile parts so reopening starts clean (project /
// category / weekends stay sticky — users often repeat those).
const today = todayIsoLocal();
setBulkForm((prev) => ({
...prev,
date_from: today,
date_to: today,
is_range: false,
user_ids: [],
note: "",
}));
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
} finally {
setBulkSubmitting(false);
}
}, [bulkForm, bulkCreate, alert]);
// `lastMutated` is the (userId, date) of the most recent successful
// mutation. We pass it to PlanGrid as `pulseKey`; the matching cell
// gets a one-shot CSS pulse animation. We clear it after 700ms (the
@@ -190,84 +278,86 @@ export default function PlanWork() {
// Null when the modal is closed or in a "create" state with no source cell.
const [modalCell, setModalCell] = useState<ReturnType<typeof getCell>>(null);
// Open the create form for a brand-new record. `source` picks the layer:
// "entry" (default) opens the normal create form; "override" opens the
// single-day create-override form. The day panel passes "override" when the
// day is in exception mode so the new record lands in the same layer that's
// showing (otherwise resolveCell would hide a new entry behind the override).
const openCreate = useCallback(
(userId: number, date: string) => {
(userId: number, date: string, source: "entry" | "override" = "entry") => {
setModalCell(null);
if (source === "override") {
setHookModal({ kind: "create-override", userId, date });
} else {
setHookModal({ kind: "create", userId, date });
}
},
[setHookModal],
);
const openCell = useCallback(
(userId: number, date: string) => {
const cell = getCell(grid, userId, date);
(userId: number, date: string, cells: ResolvedCell[]) => {
const primary = cells[0] ?? null;
// Past-date guard: the server rejects create/edit on past dates with
// a 403 (see assertNotPastDate in plan.service.ts). To keep the user
// out of the "click → modal opens → error toast" state, route any
// past-day click to view mode (or to no-op if there's no record).
if (isPastDate(date, todayIsoLocal())) {
if (!cell) {
// Empty past cell — nothing to show or edit.
return;
}
setModalCell(cell);
if (!primary) return;
setModalCell(primary);
setHookModal({ kind: "view", userId, date });
return;
}
if (!cell) {
// Empty cell — open the create modal if the user can edit, otherwise
// there's nothing to view.
if (cells.length === 0) {
if (canEdit) openCreate(userId, date);
return;
}
if (!canEdit) {
setModalCell(primary);
setHookModal({ kind: "view", userId, date });
return;
}
setHookModal({ kind: "day", userId, date });
},
[canEdit, openCreate, setHookModal],
);
// Open the correct editor for one record from the day panel. This is the
// per-record version of the old openCell branch logic: a multi-day entry
// routes through the day-in-range chooser; a single-day entry → edit-range;
// an override → edit-override.
const editRecord = useCallback(
(cell: ReturnType<typeof getCell>, userId: number, date: string) => {
if (!cell) return;
setModalCell(cell);
if (cell.source === "entry" && cell.entryId !== null) {
if (canEdit) {
// A multi-day entry clicked on a single day → "day-in-range" so the
// user can decide between editing the range or creating a one-day
// override.
if (
cell.rangeFrom &&
cell.rangeTo &&
cell.rangeFrom !== cell.rangeTo
) {
if (cell.rangeFrom && cell.rangeTo && cell.rangeFrom !== cell.rangeTo) {
setHookModal({
kind: "day-in-range",
entryId: cell.entryId,
userId,
date,
});
return;
}
} else {
setHookModal({
kind: "edit-range",
entryId: cell.entryId,
userId,
date,
});
return;
}
// Read-only view
setHookModal({ kind: "view", userId, date });
return;
}
if (cell.source === "override" && cell.overrideId !== null) {
if (canEdit) {
setHookModal({
kind: "edit-override",
overrideId: cell.overrideId,
userId,
date,
});
} else {
setHookModal({ kind: "view", userId, date });
}
return;
}
// No source — treat as empty
if (canEdit) openCreate(userId, date);
},
[grid, canEdit, openCreate, setHookModal],
[setHookModal],
);
const closeModal = useCallback(() => {
@@ -277,11 +367,14 @@ export default function PlanWork() {
// Switch the modal from "day-in-range" to "edit-range" mode, keeping the
// same cell context so the EditForm opens with the range's values.
// Uses `modalCell` — the record the user actually clicked in the day panel
// (captured by editRecord) — NOT getCell (which returns the PRIMARY record).
// On a day with 2+ records these differ; using the primary would edit the
// wrong range. modalCell is already set to the clicked record at this point
// (editRecord → day-in-range → here).
const switchToEditRange = useCallback(
(entryId: number, userId: number, date: string) => {
const cell = getCell(grid, userId, date);
if (!cell) return;
setModalCell(cell);
if (!modalCell) return;
setHookModal({
kind: "edit-range",
entryId,
@@ -289,7 +382,7 @@ export default function PlanWork() {
date,
});
},
[grid, setHookModal],
[modalCell, setHookModal],
);
// --- Mutation wrappers -----------------------------------------------------
@@ -324,8 +417,9 @@ export default function PlanWork() {
let firstDate: string | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cell] of Object.entries(byDate)) {
if (cell && cell.entryId === id) {
for (const [date, cells] of Object.entries(byDate)) {
const hit = cells.find((c) => c.entryId === id);
if (hit) {
userId = Number(uidStr);
firstDate = body.date_from ?? date;
break;
@@ -354,10 +448,11 @@ export default function PlanWork() {
let firstDate: string | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [date, cell] of Object.entries(byDate)) {
if (cell && cell.entryId === id) {
for (const [date, cells] of Object.entries(byDate)) {
const hit = cells.find((c) => c.entryId === id);
if (hit) {
userId = Number(uidStr);
firstDate = cell.rangeFrom ?? date;
firstDate = hit.rangeFrom ?? date;
break;
}
}
@@ -399,8 +494,9 @@ export default function PlanWork() {
let date: string | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [d, cell] of Object.entries(byDate)) {
if (cell && cell.overrideId === id) {
for (const [d, cells] of Object.entries(byDate)) {
const hit = cells.find((c) => c.overrideId === id);
if (hit) {
userId = Number(uidStr);
date = d;
break;
@@ -428,8 +524,9 @@ export default function PlanWork() {
let date: string | null = null;
if (grid) {
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
for (const [d, cell] of Object.entries(byDate)) {
if (cell && cell.overrideId === id) {
for (const [d, cells] of Object.entries(byDate)) {
const hit = cells.find((c) => c.overrideId === id);
if (hit) {
userId = Number(uidStr);
date = d;
break;
@@ -452,11 +549,14 @@ export default function PlanWork() {
);
// "Create override from range" — turn a multi-day entry into a one-day
// override for the clicked date. We look up the cell's source entry and
// create a new override mirroring its fields, locked to a single date.
// override for the clicked date. We mirror the CLICKED record's fields
// (modalCell, captured by editRecord) — NOT getCell, which returns the
// PRIMARY record. On a day with 2+ records the clicked entry can differ
// from the primary; carving from the primary would copy the wrong
// project/category/note into the override.
const createOverrideFromRange = useCallback(
async (entryId: number, userId: number, date: string) => {
const cell = getCell(grid, userId, date);
const cell = modalCell;
if (!cell) {
alert.error("Nepodařilo se najít zdrojový záznam");
return;
@@ -480,7 +580,7 @@ export default function PlanWork() {
// symmetry with the modal contract.
void entryId;
},
[grid, createOverride, alert, firePulse, rollbackMutation],
[modalCell, createOverride, alert, firePulse, rollbackMutation],
);
// --- Modal key for remount-on-mode-change ----------------------------------
@@ -501,6 +601,9 @@ export default function PlanWork() {
const modalMode: PlanCellModalMode = useMemo(() => {
if (hookModal.kind === "closed") return { kind: "closed" };
if (hookModal.kind === "create") return hookModal;
// create-override carries the same userId/date payload as create and needs
// no merged cell/range — pass it straight through (EditForm handles it).
if (hookModal.kind === "create-override") return hookModal;
if (hookModal.kind === "view") {
if (!modalCell) return { kind: "closed" };
return {
@@ -533,8 +636,16 @@ export default function PlanWork() {
},
} as PlanCellModalMode;
}
if (hookModal.kind === "day") {
return {
kind: "day",
userId: hookModal.userId,
date: hookModal.date,
cells: getCells(grid, hookModal.userId, hookModal.date),
};
}
return { kind: "closed" };
}, [hookModal, modalCell]);
}, [hookModal, modalCell, grid]);
if (!canEdit && !hasPermission("attendance.record")) {
// Hard block for users who have neither manage nor record permission.
@@ -542,127 +653,118 @@ 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>
{canEdit && (
<Button
variant="outlined"
color="inherit"
onClick={() => setBulkOpen(true)}
>
Hromadné přiřazení
</Button>
)}
</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 +783,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 +791,6 @@ export default function PlanWork() {
pulseKey={lastMutated}
onCellClick={openCell}
/>
</motion.div>
) : null}
<PlanCellModal
@@ -712,12 +808,27 @@ export default function PlanWork() {
onDeleteOverride={deleteOverrideFn}
onCreateOverrideFromRange={createOverrideFromRange}
onSwitchToEditRange={switchToEditRange}
onAddRecord={openCreate}
onEditRecord={editRecord}
/>
<PlanCategoriesModal
isOpen={catModalOpen}
onClose={() => setCatModalOpen(false)}
categories={categories}
/>
</div>
<BulkPlanModal
isOpen={bulkOpen}
onClose={() => setBulkOpen(false)}
form={bulkForm}
setForm={setBulkForm}
users={planUsers}
projects={projects}
categories={categories}
onSubmit={submitBulk}
submitting={bulkSubmitting}
toggleUser={toggleBulkUser}
toggleAllUsers={toggleAllBulkUsers}
/>
</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();
@@ -81,9 +133,17 @@ export default function ProjectDetail() {
const notes: ProjectNote[] = project?.project_notes || [];
const { data: usersData } = useQuery(userListOptions("projects.view"));
const users: User[] = (usersData ?? []).map((u: ApiUser) => ({
// Only active users with projects.view — but keep the project's CURRENT
// assignee in the list even if they're now inactive, so the select still
// reflects the saved value (marked "(neaktivní)" so it's clear).
const assignedUserId = project?.responsible_user_id || "";
const users: User[] = (usersData ?? [])
.filter((u: ApiUser) => u.is_active || String(u.id) === assignedUserId)
.map((u: ApiUser) => ({
id: u.id,
name: `${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username,
name:
(`${u.first_name || ""} ${u.last_name || ""}`.trim() || u.username) +
(u.is_active ? "" : " (neaktivní)"),
}));
// Reset form sync when navigating to a different project
@@ -218,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);
@@ -643,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,18 +1,40 @@
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 { 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 { projectListOptions } from "../lib/queries/projects";
import {
projectListOptions,
projectNextNumberOptions,
} from "../lib/queries/projects";
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";
@@ -22,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 {
@@ -41,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);
@@ -73,6 +142,74 @@ export default function Projects() {
},
});
const [showCreate, setShowCreate] = useState(false);
const [createForm, setCreateForm] = useState({
name: "",
customer_id: "",
responsible_user_id: "",
status: "aktivni",
start_date: "",
end_date: "",
notes: "",
});
const [createErrors, setCreateErrors] = useState<Record<string, string>>({});
const [creating, setCreating] = useState(false);
const customersQuery = useQuery({
...offerCustomersOptions(),
enabled: showCreate,
});
// Responsible-person picker: only users whose role can view projects
// (projects.view, filtered server-side) and who are active.
const usersQuery = useQuery({
...userListOptions("projects.view"),
enabled: showCreate,
});
const nextNumberQuery = useQuery({
...projectNextNumberOptions(),
enabled: showCreate,
});
const createMutation = useApiMutation<typeof createForm, { id: number }>({
url: () => `${API_BASE}/projects`,
method: () => "POST",
invalidate: ["projects", "orders", "offers", "invoices", "attendance"],
onSuccess: () => {
setShowCreate(false);
alert.success("Projekt byl vytvořen");
},
});
const openCreate = () => {
setCreateForm({
name: "",
customer_id: "",
responsible_user_id: "",
status: "aktivni",
start_date: "",
end_date: "",
notes: "",
});
setCreateErrors({});
setShowCreate(true);
};
const handleCreate = async () => {
const errs: Record<string, string> = {};
if (!createForm.name.trim()) errs.name = "Zadejte název projektu";
setCreateErrors(errs);
if (Object.keys(errs).length > 0) return;
setCreating(true);
try {
await createMutation.mutateAsync(createForm);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setCreating(false);
}
};
const {
items: projects,
pagination,
@@ -99,239 +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>
</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",
}}
>
Projekt se vytvoří automaticky při vytvoření objednávky.
</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>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>
{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
<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".
</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);
@@ -340,25 +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}
/>
</div>
</ConfirmDialog>
<Modal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSubmit={handleCreate}
title="Přidat projekt"
subtitle={`Číslo projektu: ${
nextNumberQuery.data?.number ??
nextNumberQuery.data?.next_number ??
""
} (přiděleno automaticky)`}
submitText="Vytvořit"
loading={creating}
>
<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: "" }));
}}
/>
</Field>
<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={(value) =>
setCreateForm({ ...createForm, customer_id: value })
}
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={(value) =>
setCreateForm({ ...createForm, responsible_user_id: value })
}
options={[
{ value: "", label: " nepřiřazeno " },
...(usersQuery.data ?? [])
.filter((u) => u.is_active)
.map((u) => ({
value: String(u.id),
label: `${u.first_name} ${u.last_name}`,
})),
]}
/>
</Field>
</Box>
</Box>
<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 })
}
/>
</Field>
</Box>
<Box sx={{ flex: "1 1 200px" }}>
<Field label="Konec">
<DateField
value={createForm.end_date}
onChange={(val) =>
setCreateForm({ ...createForm, end_date: val })
}
/>
</Field>
</Box>
</Box>
<Field label="Poznámka">
<TextField
multiline
minRows={3}
value={createForm.notes}
onChange={(e) =>
setCreateForm({ ...createForm, notes: e.target.value })
}
/>
</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>
);
}

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