Compare commits
189 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a262508d4 | ||
|
|
23ac924472 | ||
|
|
9370423fb0 | ||
|
|
a146fc26a3 | ||
|
|
72888bf9cd | ||
|
|
80dc8a5c69 | ||
|
|
2cfa28dc47 | ||
|
|
628cd54a81 | ||
|
|
9ae69e09a3 | ||
|
|
df83eb2091 | ||
|
|
9c862034ca | ||
|
|
67d62a6df0 | ||
|
|
512f1fd92b | ||
|
|
941caf9d85 | ||
|
|
b00d18d0b3 | ||
|
|
decadd895e | ||
|
|
54f3c414f5 | ||
|
|
ca092c6166 | ||
|
|
c7f9d9aa36 | ||
|
|
5aaac88b56 | ||
|
|
671323b9ac | ||
|
|
2e4fe2a8fe | ||
|
|
27c690285a | ||
|
|
8e7ab9158c | ||
|
|
03285c6ff6 | ||
|
|
d424ef8c15 | ||
|
|
2a228ea659 | ||
|
|
96e35df535 | ||
|
|
fa90bcc93b | ||
|
|
3117e80027 | ||
|
|
2f7ca0f5a8 | ||
|
|
2367e8a0ff | ||
|
|
b168c68264 | ||
|
|
aeec0b703c | ||
|
|
2ff26241d7 | ||
|
|
f0b5fb8a21 | ||
|
|
1e155f6ffb | ||
|
|
8cee828212 | ||
|
|
b23d15453d | ||
|
|
c4e71475ce | ||
|
|
bf5d38df14 | ||
|
|
9278d48a6d | ||
|
|
4062029939 | ||
|
|
f017867d5f | ||
|
|
f1c533488a | ||
|
|
f9bb655f6a | ||
|
|
c1bfabb033 | ||
|
|
ff3df907f4 | ||
|
|
39fe84ce99 | ||
|
|
b273614128 | ||
|
|
1a0f57a247 | ||
|
|
c67dff4725 | ||
|
|
58da09cdc0 | ||
|
|
ccdf0b68ea | ||
|
|
3b072ceefa | ||
|
|
68a8dae0dc | ||
|
|
e13f977c4d | ||
|
|
6f428b2297 | ||
|
|
67f98c1f66 | ||
|
|
85d7ffaa14 | ||
|
|
088fe39bab | ||
|
|
9b43d4e827 | ||
|
|
5630beee7c | ||
|
|
90a27b893e | ||
|
|
2878041687 | ||
|
|
262f4c6ca5 | ||
|
|
063ffd15ef | ||
|
|
fa11d3e7e0 | ||
|
|
d46827dfdf | ||
|
|
601db41a09 | ||
|
|
8259c48f9e | ||
|
|
1163407440 | ||
|
|
1cefcab697 | ||
|
|
c228d7a8ca | ||
|
|
c997a22a3c | ||
|
|
4d016cdab5 | ||
|
|
177d3f1902 | ||
|
|
ef0bb9f27e | ||
|
|
126b567b7b | ||
|
|
e4038051e9 | ||
|
|
c7e42a923a | ||
|
|
ebda3ecf23 | ||
|
|
cc4b14f862 | ||
|
|
5459d8c325 | ||
|
|
5ca03bd662 | ||
|
|
734e4266e6 | ||
|
|
c690bd9a24 | ||
|
|
3eb364fba5 | ||
|
|
1f85a84e06 | ||
|
|
abbff0bfbf | ||
|
|
19eda2ffa3 | ||
|
|
9e5038c553 | ||
|
|
9ab2bd7ef2 | ||
|
|
3d1bd49ad6 | ||
|
|
f51d5fcba4 | ||
|
|
5d919c3c90 | ||
|
|
4b94efbb43 | ||
|
|
c8c40d80d6 | ||
|
|
acc5434aca | ||
|
|
ff527ca577 | ||
|
|
b54fa84ac7 | ||
|
|
ca386c9d98 | ||
|
|
a05f0f1665 | ||
|
|
1d0793d2bb | ||
|
|
74c2f9aa30 | ||
|
|
1074adee0d | ||
|
|
a5103ee738 | ||
|
|
4fb52e9626 | ||
|
|
06c6e242e7 | ||
|
|
91686f5999 | ||
|
|
03015bbe9d | ||
|
|
af07703e7f | ||
|
|
0dbe78bfe6 | ||
|
|
b22cd6b4da | ||
|
|
ba18cb07f0 | ||
|
|
26f12b01a0 | ||
|
|
1b372b93ea | ||
|
|
56bad52de5 | ||
|
|
a80905755d | ||
|
|
ea6c943a51 | ||
|
|
c0e1b718e6 | ||
|
|
fbcd5a8939 | ||
|
|
f5ec9fa0f2 | ||
|
|
fa8f0efc65 | ||
|
|
2efd1c6df4 | ||
|
|
305c3fd97b | ||
|
|
6c186acc94 | ||
|
|
c055bf8267 | ||
|
|
094c190ae1 | ||
|
|
55a2de3a04 | ||
|
|
1398d4b4dc | ||
|
|
9aa90ff5f4 | ||
|
|
225cf56433 | ||
|
|
58a3bcad70 | ||
|
|
ca55529ff6 | ||
|
|
b76731d790 | ||
|
|
05f25931c9 | ||
|
|
5855c94b50 | ||
|
|
81adebd63a | ||
|
|
16db585022 | ||
|
|
645d0127ec | ||
|
|
c550763f2a | ||
|
|
bd658cd00c | ||
|
|
efaf49e9df | ||
|
|
cc5ffb27b9 | ||
|
|
9dbb5dbaab | ||
|
|
ab7e1eda41 | ||
|
|
7a21c85cce | ||
|
|
a353178c2d | ||
|
|
eeac0c6b8b | ||
|
|
e511e203b9 | ||
|
|
9791166ed6 | ||
|
|
a853d12d9c | ||
|
|
6bc4a22338 | ||
|
|
859ae1a458 | ||
|
|
090cfd7400 | ||
|
|
bb7e53f47a | ||
|
|
d2e368a05e | ||
|
|
b5df88b62d | ||
|
|
116797ca17 | ||
|
|
ed4f1efd03 | ||
|
|
6b76d4336e | ||
|
|
a8c0e413f4 | ||
|
|
596d21712a | ||
|
|
b504d9d0b2 | ||
|
|
e082a6ef06 | ||
|
|
1662453d9c | ||
|
|
9dd2e07f19 | ||
|
|
18064a972c | ||
|
|
996835dae5 | ||
|
|
c73b980ce7 | ||
|
|
dafa6d6aab | ||
|
|
80803cc868 | ||
|
|
55fa4045ad | ||
|
|
7f156cc721 | ||
|
|
e0d2fccf50 | ||
|
|
a1c251650b | ||
|
|
103908cfb8 | ||
|
|
449a1a2243 | ||
|
|
337feb8b3a | ||
|
|
bc62d32efc | ||
|
|
30dd5a3f3b | ||
|
|
5140d23d73 | ||
|
|
a373102f3a | ||
|
|
49ec3482ae | ||
|
|
f6cdfb9801 | ||
|
|
bd9dc8d103 | ||
|
|
0c5d0ee2f7 | ||
|
|
fdf180809d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,3 +13,6 @@ dist-client/
|
|||||||
# Claude worktrees
|
# Claude worktrees
|
||||||
.claude/worktrees/
|
.claude/worktrees/
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Superpowers brainstorm mockups
|
||||||
|
.superpowers/
|
||||||
|
|||||||
55
CLAUDE.md
55
CLAUDE.md
@@ -14,7 +14,7 @@ Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operation
|
|||||||
| ORM | Prisma 6.19.2 → MySQL |
|
| ORM | Prisma 6.19.2 → MySQL |
|
||||||
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
|
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
|
||||||
| Validation | Zod 4.3.6 |
|
| 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 |
|
| Testing | Vitest 4.1.0 + Supertest |
|
||||||
| PDF | Puppeteer 24.x |
|
| PDF | Puppeteer 24.x |
|
||||||
| Email | nodemailer 8.x |
|
| Email | nodemailer 8.x |
|
||||||
@@ -35,17 +35,21 @@ src/
|
|||||||
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
|
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
|
||||||
├── config/ # env.ts (config singleton, Date.toJSON override)
|
├── config/ # env.ts (config singleton, Date.toJSON override)
|
||||||
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
|
├── 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
|
│ ├── AdminApp.tsx # Router + lazy-loaded pages
|
||||||
│ ├── contexts/ # AuthContext, AlertContext
|
│ ├── theme.ts # MUI theme — light/dark color schemes, tokens, component defaults
|
||||||
│ ├── components/ # Layout, modals, tables, editors
|
│ ├── 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
|
│ ├── 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
|
│ └── 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/
|
prisma/
|
||||||
├── schema.prisma # 32 models, MySQL, snake_case columns
|
├── schema.prisma # 49 models, MySQL, snake_case columns
|
||||||
└── migrations/ # Applied migrations
|
└── migrations/ # Applied migrations
|
||||||
|
|
||||||
dist/ # Compiled server (CommonJS, ES2022)
|
dist/ # Compiled server (CommonJS, ES2022)
|
||||||
@@ -104,7 +108,7 @@ TOTP_ENCRYPTION_KEY=<64-char hex string>
|
|||||||
Optional (with defaults):
|
Optional (with defaults):
|
||||||
|
|
||||||
```
|
```
|
||||||
PORT=3001 # Production port (dev default: 3000)
|
PORT=3001 # Production port (dev default: 3050, hardcoded in server.ts)
|
||||||
HOST=127.0.0.1
|
HOST=127.0.0.1
|
||||||
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
|
APP_ENV=local|production # Default: local. Controls CSP, CORS, HSTS
|
||||||
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
|
ACCESS_TOKEN_EXPIRY=900 # 15 minutes
|
||||||
@@ -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`).
|
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.
|
- Use `buildApp()` helper to spin up the Fastify instance for tests.
|
||||||
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout.
|
- 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.
|
- **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`.
|
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
|
||||||
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
|
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
|
||||||
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
|
- 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).
|
- 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: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
|
- Custom hooks: `usePaginatedQuery`, `useTableSort`, `useDebounce`, `useModalLock`, `useReducedMotion`.
|
||||||
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
|
- 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
|
### 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
|
## Database Conventions
|
||||||
|
|
||||||
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
|
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
|
||||||
|
|||||||
@@ -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 2–3), 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.
|
||||||
@@ -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 2–4), 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).
|
||||||
@@ -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 1–3), §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).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
1524
docs/superpowers/plans/2026-06-08-multi-record-plan-cells.md
Normal file
1524
docs/superpowers/plans/2026-06-08-multi-record-plan-cells.md
Normal file
File diff suppressed because it is too large
Load Diff
249
docs/superpowers/specs/2026-06-06-css-mui-migration-design.md
Normal file
249
docs/superpowers/specs/2026-06-06-css-mui-migration-design.md
Normal 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 `h1–h6`, 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 + `Kč`/`€`, `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 600–800 (headings), Plus Jakarta Sans 400–700 (body), DM Mono 400–500 (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.
|
||||||
@@ -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 0–3 `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[]` (0–3) 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 2–3 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 (from–to), 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 = Mon–Fri 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
914
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.9.1",
|
"version": "2.0.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -32,11 +32,15 @@
|
|||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
|
"@mui/material": "^7.3.11",
|
||||||
|
"@mui/x-date-pickers": "^8.29.0",
|
||||||
"@prisma/client": "^6.19.2",
|
"@prisma/client": "^6.19.2",
|
||||||
"@tanstack/react-query": "^5.100.5",
|
"@tanstack/react-query": "^5.100.5",
|
||||||
"@types/jsdom": "^28.0.1",
|
"@types/jsdom": "^28.0.1",
|
||||||
@@ -74,8 +78,8 @@
|
|||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^7.0.11",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^18.3.31",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -172,6 +172,12 @@ const PERMISSIONS: {
|
|||||||
module: "projects",
|
module: "projects",
|
||||||
description: "Prohlížet seznam projektů",
|
description: "Prohlížet seznam projektů",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "projects.create",
|
||||||
|
display_name: "Vytvořit projekt",
|
||||||
|
module: "projects",
|
||||||
|
description: "Ručně vytvářet projekty",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "projects.edit",
|
name: "projects.edit",
|
||||||
display_name: "Upravit projekt",
|
display_name: "Upravit projekt",
|
||||||
|
|||||||
20
src/App.tsx
20
src/App.tsx
@@ -2,7 +2,13 @@ import { Suspense } from "react";
|
|||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import AdminApp from "./admin/AdminApp";
|
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() {
|
function AdminLoader() {
|
||||||
|
const light =
|
||||||
|
typeof document !== "undefined" &&
|
||||||
|
document.documentElement.getAttribute("data-theme") === "light";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -10,10 +16,20 @@ function AdminLoader() {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/__tests__/manual-create.test.ts
Normal file
142
src/__tests__/manual-create.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
106
src/__tests__/nas-project-folder.test.ts
Normal file
106
src/__tests__/nas-project-folder.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|||||||
import {
|
import {
|
||||||
generateSharedNumber,
|
generateSharedNumber,
|
||||||
generateOfferNumber,
|
generateOfferNumber,
|
||||||
|
previewSharedNumber,
|
||||||
|
isSharedNumberTaken,
|
||||||
|
previewOfferNumber,
|
||||||
|
isOfferNumberTaken,
|
||||||
} from "../services/numbering.service";
|
} from "../services/numbering.service";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
|
|
||||||
@@ -48,3 +52,83 @@ describe("generateOfferNumber", () => {
|
|||||||
expect(num1).not.toBe(num2);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,6 +58,20 @@ beforeEach(async () => {
|
|||||||
await prisma.work_plan_overrides.deleteMany({
|
await prisma.work_plan_overrides.deleteMany({
|
||||||
where: { note: { contains: N } },
|
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 () => {
|
afterAll(async () => {
|
||||||
@@ -75,10 +89,9 @@ afterAll(async () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("plan.service.resolveCell", () => {
|
describe("plan.service.resolveCell", () => {
|
||||||
it("returns null when nothing covers the date", async () => {
|
it("returns [] when nothing covers the date", async () => {
|
||||||
// No entry, no override for (adminUserId, "2099-01-01")
|
|
||||||
const result = await resolveCell(adminUserId, "2099-01-01");
|
const result = await resolveCell(adminUserId, "2099-01-01");
|
||||||
expect(result).toBeNull();
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the entry that covers the date", async () => {
|
it("returns the entry that covers the date", async () => {
|
||||||
@@ -93,17 +106,14 @@ describe("plan.service.resolveCell", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||||
expect(result).not.toBeNull();
|
expect(result.length).toBe(1);
|
||||||
expect(result?.source).toBe("entry");
|
expect(result[0].source).toBe("entry");
|
||||||
expect(result?.note).toBe(`${N}PLC upgrade`);
|
expect(result[0].note).toBe(`${N}PLC upgrade`);
|
||||||
expect(result?.category).toBe("work");
|
expect(result[0].rangeFrom).toBe("2099-06-01");
|
||||||
expect(result?.rangeFrom).toBe("2099-06-01");
|
expect(result[0].rangeTo).toBe("2099-06-10");
|
||||||
expect(result?.rangeTo).toBe("2099-06-10");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the override for that day, not the entry", async () => {
|
it("returns overrides (entries hidden) when an override exists", async () => {
|
||||||
// An entry covers 2099-06-01..2099-06-10, but an override on 2099-06-05
|
|
||||||
// takes precedence.
|
|
||||||
await prisma.work_plan_entries.create({
|
await prisma.work_plan_entries.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: adminUserId,
|
user_id: adminUserId,
|
||||||
@@ -124,10 +134,51 @@ describe("plan.service.resolveCell", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||||
expect(result).not.toBeNull();
|
expect(result.length).toBe(1);
|
||||||
expect(result?.source).toBe("override");
|
expect(result[0].source).toBe("override");
|
||||||
expect(result?.note).toBe(`${N}Volno po noční`);
|
expect(result[0].note).toBe(`${N}Volno po noční`);
|
||||||
expect(result?.category).toBe("leave");
|
});
|
||||||
|
|
||||||
|
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 () => {
|
it("ignores soft-deleted entries and overrides", async () => {
|
||||||
@@ -143,7 +194,7 @@ describe("plan.service.resolveCell", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||||
expect(result).toBeNull();
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,11 +215,11 @@ describe("plan.service.resolveGrid", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-05");
|
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-01"][0]?.note).toBe(`${N}A`);
|
||||||
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}A`);
|
expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}A`);
|
||||||
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
|
expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
|
||||||
expect(cells[adminUserId]["2099-06-04"]).toBeNull();
|
expect(cells[adminUserId]["2099-06-04"]).toEqual([]);
|
||||||
expect(cells[adminUserId]["2099-06-05"]).toBeNull();
|
expect(cells[adminUserId]["2099-06-05"]).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies override on a covered day", async () => {
|
it("applies override on a covered day", async () => {
|
||||||
@@ -192,9 +243,78 @@ describe("plan.service.resolveGrid", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-03");
|
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-01"][0]?.note).toBe(`${N}A`);
|
||||||
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}B`);
|
expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}B`);
|
||||||
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
|
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 +461,36 @@ describe("plan.service.createEntry", () => {
|
|||||||
expect("error" in result).toBe(true);
|
expect("error" in result).toBe(true);
|
||||||
if ("error" in result) expect(result.status).toBe(403);
|
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 +589,7 @@ describe("plan.service.deleteEntry", () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("plan.service.createOverride", () => {
|
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(
|
const result = await createOverride(
|
||||||
{
|
{
|
||||||
user_id: adminUserId,
|
user_id: adminUserId,
|
||||||
@@ -454,42 +604,45 @@ describe("plan.service.createOverride", () => {
|
|||||||
if ("data" in result) {
|
if ("data" in result) {
|
||||||
expect(result.data.note).toBe(`${N}day off`);
|
expect(result.data.note).toBe(`${N}day off`);
|
||||||
expect(result.oldData).toBeNull();
|
expect(result.oldData).toBeNull();
|
||||||
expect(result.replacedData).toBeNull();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("soft-deletes the existing override and reports it in replacedData", async () => {
|
it("stacks additive overrides and caps at MAX_RECORDS_PER_CELL", async () => {
|
||||||
await createOverride(
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const r = await createOverride(
|
||||||
{
|
{
|
||||||
user_id: adminUserId,
|
user_id: adminUserId,
|
||||||
shift_date: "2099-10-02",
|
shift_date: "2099-10-02",
|
||||||
category: "leave",
|
category: "leave",
|
||||||
note: `${N}first`,
|
note: `${N}o${i}`,
|
||||||
},
|
},
|
||||||
adminUserId,
|
adminUserId,
|
||||||
false,
|
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,
|
user_id: adminUserId,
|
||||||
shift_date: "2099-10-02",
|
shift_date: "2099-10-02",
|
||||||
category: "sick",
|
category: "leave",
|
||||||
note: `${N}second`,
|
note: `${N}o3`,
|
||||||
},
|
},
|
||||||
adminUserId,
|
adminUserId,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
expect("data" in second).toBe(true);
|
expect("error" in fourth).toBe(true);
|
||||||
if ("data" in second) {
|
if ("error" in fourth) expect(fourth.status).toBe(400);
|
||||||
expect((second.replacedData as any)?.note).toBe(`${N}first`);
|
// All three earlier overrides remain active — no replace happened.
|
||||||
}
|
const active = await prisma.work_plan_overrides.findMany({
|
||||||
|
where: {
|
||||||
const all = await prisma.work_plan_overrides.findMany({
|
user_id: adminUserId,
|
||||||
where: { user_id: adminUserId, shift_date: new Date("2099-10-02") },
|
shift_date: new Date("2099-10-02"),
|
||||||
|
is_deleted: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
// The first should be soft-deleted, the second active
|
expect(active.length).toBe(3);
|
||||||
expect(all.find((o) => o.note === `${N}first`)?.is_deleted).toBe(true);
|
|
||||||
expect(all.find((o) => o.note === `${N}second`)?.is_deleted).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a past date without force", async () => {
|
it("rejects a past date without force", async () => {
|
||||||
@@ -810,7 +963,7 @@ describe("HTTP /api/admin/plan", () => {
|
|||||||
const body = res.json();
|
const body = res.json();
|
||||||
expect(body.data.users).toBeDefined();
|
expect(body.data.users).toBeDefined();
|
||||||
expect(body.data.cells[adminUserId]).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`,
|
`${N}grid test`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,29 +5,12 @@ import { AuthProvider } from "./context/AuthContext";
|
|||||||
import { AlertProvider } from "./context/AlertContext";
|
import { AlertProvider } from "./context/AlertContext";
|
||||||
import { queryClient } from "./lib/queryClient";
|
import { queryClient } from "./lib/queryClient";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import AdminLayout from "./components/AdminLayout";
|
import AppShell from "./ui/AppShell";
|
||||||
import AlertContainer from "./components/AlertContainer";
|
import AlertContainer from "./components/AlertContainer";
|
||||||
|
import MuiProvider from "./ui/MuiProvider";
|
||||||
|
import { LoadingState } from "./ui";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Dashboard from "./pages/Dashboard";
|
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 Users = lazy(() => import("./pages/Users"));
|
||||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
const Attendance = lazy(() => import("./pages/Attendance"));
|
||||||
@@ -81,24 +64,23 @@ const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
|
|||||||
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
|
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
|
||||||
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
|
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
|
||||||
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
|
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
|
||||||
|
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
|
||||||
|
|
||||||
export default function AdminApp() {
|
export default function AdminApp() {
|
||||||
return (
|
return (
|
||||||
|
<MuiProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AlertProvider>
|
<AlertProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AlertContainer />
|
<AlertContainer />
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Suspense
|
<Suspense fallback={<LoadingState />}>
|
||||||
fallback={
|
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="login" element={<Login />} />
|
<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 index element={<Dashboard />} />
|
||||||
<Route path="users" element={<Users />} />
|
<Route path="users" element={<Users />} />
|
||||||
<Route path="attendance" element={<Attendance />} />
|
<Route path="attendance" element={<Attendance />} />
|
||||||
@@ -156,7 +138,10 @@ export default function AdminApp() {
|
|||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
<Route path="audit-log" element={<AuditLog />} />
|
<Route path="audit-log" element={<AuditLog />} />
|
||||||
<Route path="warehouse" element={<Warehouse />} />
|
<Route path="warehouse" element={<Warehouse />} />
|
||||||
<Route path="warehouse/items" element={<WarehouseItems />} />
|
<Route
|
||||||
|
path="warehouse/items"
|
||||||
|
element={<WarehouseItems />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="warehouse/items/:id"
|
path="warehouse/items/:id"
|
||||||
element={<WarehouseItemDetail />}
|
element={<WarehouseItemDetail />}
|
||||||
@@ -233,5 +218,6 @@ export default function AdminApp() {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</AlertProvider>
|
</AlertProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</MuiProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
162
src/admin/GlobalStyles.tsx
Normal file
162
src/admin/GlobalStyles.tsx
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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" />;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,102 +1,29 @@
|
|||||||
import React from "react";
|
import Snackbar from "@mui/material/Snackbar";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import MuiAlert from "@mui/material/Alert";
|
||||||
import { useAlertState } from "../context/AlertContext";
|
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() {
|
export default function AlertContainer() {
|
||||||
const { alerts, removeAlert } = useAlertState();
|
const { alerts, removeAlert } = useAlertState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-alert-container" role="status" aria-live="polite">
|
<>
|
||||||
<AnimatePresence>
|
{alerts.map((alert, index) => (
|
||||||
{alerts.map((alert) => (
|
<Snackbar
|
||||||
<motion.div
|
|
||||||
key={alert.id}
|
key={alert.id}
|
||||||
className={`admin-toast admin-toast-${alert.type}`}
|
open
|
||||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
sx={{ bottom: { xs: 16 + index * 72 } }}
|
||||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
>
|
||||||
<span className="admin-toast-icon">{icons[alert.type]}</span>
|
<MuiAlert
|
||||||
<span className="admin-toast-message">{alert.message}</span>
|
severity={alert.type}
|
||||||
<button
|
onClose={() => removeAlert(alert.id)}
|
||||||
className="admin-toast-close"
|
variant="filled"
|
||||||
onClick={() => removeAlert(alert.id)}
|
sx={{ width: "100%", minWidth: 280 }}
|
||||||
aria-label="Zavřít"
|
|
||||||
>
|
>
|
||||||
<svg
|
{alert.message}
|
||||||
width="16"
|
</MuiAlert>
|
||||||
height="16"
|
</Snackbar>
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDatetime,
|
formatDatetime,
|
||||||
@@ -6,8 +8,8 @@ import {
|
|||||||
calculateWorkMinutes,
|
calculateWorkMinutes,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
getLeaveTypeName,
|
getLeaveTypeName,
|
||||||
getLeaveTypeBadgeClass,
|
|
||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
|
import { DataTable, StatusChip, EmptyState, type DataColumn } from "../ui";
|
||||||
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
|
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
|
||||||
|
|
||||||
interface AttendanceRecord extends HookAttendanceRecord {
|
interface AttendanceRecord extends HookAttendanceRecord {
|
||||||
@@ -23,6 +25,56 @@ interface AttendanceShiftTableProps {
|
|||||||
onDelete: (record: AttendanceRecord) => void;
|
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 {
|
function formatBreak(record: AttendanceRecord): string {
|
||||||
if (record.break_start && record.break_end) {
|
if (record.break_start && record.break_end) {
|
||||||
return `${formatTime(record.break_start)} - ${formatTime(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 {
|
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||||
if (record.project_logs && record.project_logs.length > 0) {
|
if (record.project_logs && record.project_logs.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
|
|
||||||
>
|
|
||||||
{record.project_logs.map((log, i) => {
|
{record.project_logs.map((log, i) => {
|
||||||
let h: number,
|
let h: number,
|
||||||
m: number,
|
m: number,
|
||||||
@@ -65,33 +115,32 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusChip
|
||||||
key={log.id ?? i}
|
key={log.id ?? i}
|
||||||
className="admin-badge"
|
color={isActive ? "info" : "default"}
|
||||||
style={{
|
label={`${log.project_name || `#${log.project_id}`} ${
|
||||||
fontSize: "0.7rem",
|
durationValid
|
||||||
display: "inline-block",
|
|
||||||
background: isActive ? "var(--accent-light)" : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{log.project_name || `#${log.project_id}`}{" "}
|
|
||||||
{durationValid
|
|
||||||
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
||||||
: "—"}
|
: "—"
|
||||||
</span>
|
}`}
|
||||||
|
sx={{ fontSize: "0.7rem", maxWidth: "100%" }}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (record.project_name) {
|
if (record.project_name) {
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusChip
|
||||||
className="admin-badge admin-badge-wrap"
|
color="default"
|
||||||
style={{ fontSize: "0.75rem" }}
|
label={record.project_name}
|
||||||
>
|
sx={{
|
||||||
{record.project_name}
|
fontSize: "0.75rem",
|
||||||
</span>
|
height: "auto",
|
||||||
|
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return "—";
|
return "—";
|
||||||
@@ -102,141 +151,164 @@ export default function AttendanceShiftTable({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: AttendanceShiftTableProps) {
|
}: 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 (
|
return (
|
||||||
<div className="admin-empty-state">
|
<StatusChip
|
||||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
color={leaveTypeChipColor(leaveType)}
|
||||||
</div>
|
label={getLeaveTypeName(leaveType)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
},
|
||||||
return (
|
{
|
||||||
<div className="admin-table-responsive">
|
key: "arrival",
|
||||||
<table className="admin-table">
|
header: "Příchod",
|
||||||
<thead>
|
width: "9%",
|
||||||
<tr>
|
mono: true,
|
||||||
<th>Datum</th>
|
render: (record) => {
|
||||||
<th>Zaměstnanec</th>
|
const isLeave = (record.leave_type || "work") !== "work";
|
||||||
<th>Typ</th>
|
return isLeave ? "—" : formatDatetime(record.arrival_time);
|
||||||
<th>Příchod</th>
|
},
|
||||||
<th>Pauza</th>
|
},
|
||||||
<th>Odchod</th>
|
{
|
||||||
<th>Hodiny</th>
|
key: "break",
|
||||||
<th>Projekt</th>
|
header: "Pauza",
|
||||||
<th>GPS</th>
|
width: "10%",
|
||||||
<th>Poznámka</th>
|
mono: true,
|
||||||
<th>Akce</th>
|
render: (record) => {
|
||||||
</tr>
|
const isLeave = (record.leave_type || "work") !== "work";
|
||||||
</thead>
|
return isLeave ? "—" : formatBreak(record);
|
||||||
<tbody>
|
},
|
||||||
{records.map((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 leaveType = record.leave_type || "work";
|
||||||
const isLeave = leaveType !== "work";
|
const isLeave = leaveType !== "work";
|
||||||
const workMinutes = isLeave
|
const workMinutes = isLeave
|
||||||
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
|
? (record.leave_hours != null ? Number(record.leave_hours) : 8) * 60
|
||||||
60
|
|
||||||
: calculateWorkMinutes({
|
: calculateWorkMinutes({
|
||||||
...record,
|
...record,
|
||||||
notes: record.notes ?? undefined,
|
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 =
|
const hasLocation =
|
||||||
(record.arrival_lat && record.arrival_lng) ||
|
(record.arrival_lat && record.arrival_lng) ||
|
||||||
(record.departure_lat && record.departure_lng);
|
(record.departure_lat && record.departure_lng);
|
||||||
|
return hasLocation ? (
|
||||||
return (
|
<Box
|
||||||
<tr key={record.id}>
|
component={RouterLink}
|
||||||
<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
|
|
||||||
to={`/attendance/location/${record.id}`}
|
to={`/attendance/location/${record.id}`}
|
||||||
className="attendance-gps-link"
|
|
||||||
title="Zobrazit polohu"
|
title="Zobrazit polohu"
|
||||||
aria-label="Zobrazit polohu"
|
aria-label="Zobrazit polohu"
|
||||||
|
sx={{ textDecoration: "none" }}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">{"📍"}</span>
|
<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",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
title={record.notes || ""}
|
|
||||||
>
|
>
|
||||||
{record.notes || ""}
|
{record.notes || ""}
|
||||||
</td>
|
</Box>
|
||||||
<td>
|
),
|
||||||
<div className="admin-table-actions">
|
},
|
||||||
<button
|
{
|
||||||
|
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)}
|
onClick={() => onEdit(record)}
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Upravit"
|
|
||||||
aria-label="Upravit"
|
aria-label="Upravit"
|
||||||
|
title="Upravit"
|
||||||
>
|
>
|
||||||
<svg
|
{EditIcon}
|
||||||
width="18"
|
</IconButton>
|
||||||
height="18"
|
<IconButton
|
||||||
viewBox="0 0 24 24"
|
size="small"
|
||||||
fill="none"
|
color="error"
|
||||||
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={() => onDelete(record)}
|
onClick={() => onDelete(record)}
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Smazat"
|
|
||||||
aria-label="Smazat"
|
aria-label="Smazat"
|
||||||
|
title="Smazat"
|
||||||
>
|
>
|
||||||
<svg
|
{DeleteIcon}
|
||||||
width="18"
|
</IconButton>
|
||||||
height="18"
|
</Box>
|
||||||
viewBox="0 0 24 24"
|
),
|
||||||
fill="none"
|
},
|
||||||
stroke="currentColor"
|
];
|
||||||
strokeWidth="2"
|
|
||||||
>
|
return (
|
||||||
<polyline points="3 6 5 6 21 6" />
|
<DataTable<AttendanceRecord>
|
||||||
<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" />
|
columns={columns}
|
||||||
</svg>
|
rows={records}
|
||||||
</button>
|
rowKey={(record) => record.id}
|
||||||
</div>
|
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
||||||
</td>
|
/>
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
import AdminDatePicker from "./AdminDatePicker";
|
import Typography from "@mui/material/Typography";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
import { Modal, MonthField, TimeField, Field, CheckboxField } from "../ui";
|
||||||
|
|
||||||
interface BulkAttendanceForm {
|
interface BulkAttendanceForm {
|
||||||
month: string;
|
month: string;
|
||||||
@@ -39,182 +39,137 @@ export default function BulkAttendanceModal({
|
|||||||
toggleUser,
|
toggleUser,
|
||||||
toggleAllUsers,
|
toggleAllUsers,
|
||||||
}: BulkAttendanceModalProps) {
|
}: BulkAttendanceModalProps) {
|
||||||
useModalLock(isOpen);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{isOpen && (
|
isOpen={isOpen}
|
||||||
<motion.div
|
onClose={onClose}
|
||||||
className="admin-modal-overlay"
|
title="Vyplnit docházku za měsíc"
|
||||||
initial={{ opacity: 0 }}
|
maxWidth="lg"
|
||||||
animate={{ opacity: 1 }}
|
loading={submitting}
|
||||||
exit={{ opacity: 0 }}
|
onSubmit={onSubmit}
|
||||||
transition={{ duration: 0.2 }}
|
submitText="Vyplnit měsíc"
|
||||||
|
submitDisabled={form.user_ids.length === 0}
|
||||||
>
|
>
|
||||||
<div
|
<Typography
|
||||||
className="admin-modal-backdrop"
|
variant="body2"
|
||||||
onClick={() => !submitting && onClose()}
|
color="text.secondary"
|
||||||
/>
|
sx={{ mt: 0.25, mb: 2 }}
|
||||||
<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 }}
|
|
||||||
>
|
>
|
||||||
<div className="admin-modal-header">
|
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
|
||||||
<h2
|
Existující záznamy se přeskočí.
|
||||||
id="bulk-attendance-modal-title"
|
</Typography>
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="admin-modal-body">
|
<Field label="Měsíc">
|
||||||
<div className="admin-form">
|
<MonthField
|
||||||
<div className="admin-form-group">
|
|
||||||
<label className="admin-form-label">Měsíc</label>
|
|
||||||
<AdminDatePicker
|
|
||||||
mode="month"
|
|
||||||
value={form.month}
|
value={form.month}
|
||||||
onChange={(val) => setForm({ ...form, month: val })}
|
onChange={(val) => setForm({ ...form, month: val })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Box sx={{ mb: 2 }}>
|
||||||
<label className="admin-form-label">
|
<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
|
Zaměstnanci
|
||||||
<button
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAllUsers}
|
onClick={toggleAllUsers}
|
||||||
style={{
|
variant="caption"
|
||||||
marginLeft: "0.75rem",
|
sx={{
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
color: "var(--accent-color)",
|
p: 0,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.8125rem",
|
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
padding: 0,
|
color: "primary.main",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{form.user_ids.length === users.length
|
{form.user_ids.length === users.length
|
||||||
? "Odznačit vše"
|
? "Odznačit vše"
|
||||||
: "Vybrat vše"}
|
: "Vybrat vše"}
|
||||||
</button>
|
</Typography>
|
||||||
</label>
|
</Box>
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "0.375rem",
|
gap: 0.25,
|
||||||
maxHeight: "200px",
|
maxHeight: 200,
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
padding: "0.75rem",
|
p: 1.5,
|
||||||
background: "var(--bg-tertiary)",
|
bgcolor: "action.hover",
|
||||||
borderRadius: "var(--border-radius-sm)",
|
borderRadius: 2,
|
||||||
border: "1px solid var(--border-color)",
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<label key={user.id} className="admin-form-checkbox">
|
<CheckboxField
|
||||||
<input
|
key={user.id}
|
||||||
type="checkbox"
|
label={user.name}
|
||||||
checked={form.user_ids.includes(String(user.id))}
|
checked={form.user_ids.includes(String(user.id))}
|
||||||
onChange={() => toggleUser(user.id)}
|
onChange={() => toggleUser(user.id)}
|
||||||
/>
|
/>
|
||||||
<span>{user.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
<small className="admin-form-hint">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Vybráno: {form.user_ids.length} z {users.length}
|
Vybráno: {form.user_ids.length} z {users.length}
|
||||||
</small>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<div className="admin-form-group">
|
sx={{
|
||||||
<label className="admin-form-label">Příchod</label>
|
display: "grid",
|
||||||
<AdminDatePicker
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
mode="time"
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Příchod">
|
||||||
|
<TimeField
|
||||||
value={form.arrival_time}
|
value={form.arrival_time}
|
||||||
onChange={(val) =>
|
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||||
setForm({ ...form, arrival_time: val })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Odchod">
|
||||||
<label className="admin-form-label">Odchod</label>
|
<TimeField
|
||||||
<AdminDatePicker
|
|
||||||
mode="time"
|
|
||||||
value={form.departure_time}
|
value={form.departure_time}
|
||||||
onChange={(val) =>
|
onChange={(val) => setForm({ ...form, departure_time: val })}
|
||||||
setForm({ ...form, departure_time: val })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<div className="admin-form-group">
|
sx={{
|
||||||
<label className="admin-form-label">Začátek pauzy</label>
|
display: "grid",
|
||||||
<AdminDatePicker
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
mode="time"
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Začátek pauzy">
|
||||||
|
<TimeField
|
||||||
value={form.break_start_time}
|
value={form.break_start_time}
|
||||||
onChange={(val) =>
|
onChange={(val) => setForm({ ...form, break_start_time: val })}
|
||||||
setForm({ ...form, break_start_time: val })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Konec pauzy">
|
||||||
<label className="admin-form-label">Konec pauzy</label>
|
<TimeField
|
||||||
<AdminDatePicker
|
|
||||||
mode="time"
|
|
||||||
value={form.break_end_time}
|
value={form.break_end_time}
|
||||||
onChange={(val) =>
|
onChange={(val) => setForm({ ...form, break_end_time: val })}
|
||||||
setForm({ ...form, break_end_time: val })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Modal>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Component, type ReactNode, type ErrorInfo } from "react";
|
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 {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -22,19 +25,25 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
className="admin-empty-state"
|
sx={{
|
||||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
minHeight: "60vh",
|
||||||
>
|
display: "flex",
|
||||||
<h2>Něco se pokazilo</h2>
|
flexDirection: "column",
|
||||||
<p>{this.state.error?.message}</p>
|
alignItems: "center",
|
||||||
<button
|
justifyContent: "center",
|
||||||
className="admin-btn admin-btn-primary"
|
gap: 2,
|
||||||
onClick={() => window.location.reload()}
|
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
|
Obnovit stránku
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.props.children;
|
return this.props.children;
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import { EmptyState, Button } from "../ui";
|
||||||
|
|
||||||
export default function Forbidden() {
|
export default function Forbidden() {
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
className="admin-empty-state"
|
sx={{
|
||||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
minHeight: "60vh",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<h2>403</h2>
|
<EmptyState
|
||||||
<p>Nemáte oprávnění pro přístup k této stránce.</p>
|
title="403"
|
||||||
<Link to="/" className="admin-btn admin-btn-primary">
|
description="Nemáte oprávnění pro přístup k této stránce."
|
||||||
|
action={
|
||||||
|
<Button component={Link} to="/">
|
||||||
Zpět na Dashboard
|
Zpět na Dashboard
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { useState, useCallback } from "react";
|
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";
|
import { useAlert } from "../context/AlertContext";
|
||||||
|
|
||||||
interface ConfirmationItem {
|
interface ConfirmationItem {
|
||||||
@@ -35,6 +40,8 @@ export default function OrderConfirmationModal({
|
|||||||
applyVat,
|
applyVat,
|
||||||
}: OrderConfirmationModalProps) {
|
}: OrderConfirmationModalProps) {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
const [step, setStep] = useState<"choose" | "edit">("choose");
|
const [step, setStep] = useState<"choose" | "edit">("choose");
|
||||||
const [lang, setLang] = useState<string>("cs");
|
const [lang, setLang] = useState<string>("cs");
|
||||||
const [applyVatState, setApplyVatState] = useState(applyVat);
|
const [applyVatState, setApplyVatState] = useState(applyVat);
|
||||||
@@ -103,135 +110,205 @@ export default function OrderConfirmationModal({
|
|||||||
}, [defaultVatRate]);
|
}, [defaultVatRate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{isOpen && (
|
isOpen={isOpen}
|
||||||
<motion.div
|
onClose={onClose}
|
||||||
className="admin-modal-overlay"
|
title={`Potvrzení objednávky ${orderNumber}`}
|
||||||
initial={{ opacity: 0 }}
|
maxWidth={step === "edit" ? "lg" : "md"}
|
||||||
animate={{ opacity: 1 }}
|
loading={loading}
|
||||||
exit={{ opacity: 0 }}
|
submitText={
|
||||||
transition={{ duration: 0.2 }}
|
step === "choose" ? "Použít položky z objednávky" : "Vygenerovat PDF"
|
||||||
>
|
|
||||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
|
||||||
<motion.div
|
|
||||||
className={
|
|
||||||
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
|
|
||||||
}
|
}
|
||||||
role="dialog"
|
submitDisabled={step === "edit" && items.length === 0}
|
||||||
aria-modal="true"
|
onSubmit={step === "choose" ? handleUseExisting : handleEditGenerate}
|
||||||
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 }}
|
|
||||||
>
|
>
|
||||||
<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" ? (
|
{step === "choose" ? (
|
||||||
<div className="admin-form">
|
<Box>
|
||||||
<div className="admin-form-group">
|
<Field label="Jazyk dokumentu">
|
||||||
<label className="admin-form-label">Jazyk dokumentu</label>
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
<div className="flex-row gap-2">
|
<Button
|
||||||
<button
|
size="small"
|
||||||
type="button"
|
variant={lang === "cs" ? "contained" : "outlined"}
|
||||||
|
color={lang === "cs" ? "primary" : "inherit"}
|
||||||
onClick={() => setLang("cs")}
|
onClick={() => setLang("cs")}
|
||||||
className={
|
|
||||||
lang === "cs"
|
|
||||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
||||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Čeština
|
Čeština
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="small"
|
||||||
|
variant={lang === "en" ? "contained" : "outlined"}
|
||||||
|
color={lang === "en" ? "primary" : "inherit"}
|
||||||
onClick={() => setLang("en")}
|
onClick={() => setLang("en")}
|
||||||
className={
|
|
||||||
lang === "en"
|
|
||||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
||||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
English
|
English
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Field label="DPH">
|
||||||
<label className="admin-form-label">DPH</label>
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
<div className="flex-row gap-2">
|
<Button
|
||||||
<button
|
size="small"
|
||||||
type="button"
|
variant={applyVatState ? "contained" : "outlined"}
|
||||||
|
color={applyVatState ? "primary" : "inherit"}
|
||||||
onClick={() => setApplyVatState(true)}
|
onClick={() => setApplyVatState(true)}
|
||||||
className={
|
|
||||||
applyVatState
|
|
||||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
||||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
S DPH
|
S DPH
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="small"
|
||||||
|
variant={!applyVatState ? "contained" : "outlined"}
|
||||||
|
color={!applyVatState ? "primary" : "inherit"}
|
||||||
onClick={() => setApplyVatState(false)}
|
onClick={() => setApplyVatState(false)}
|
||||||
className={
|
|
||||||
!applyVatState
|
|
||||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
|
||||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Bez DPH
|
Bez DPH
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Field label="Obsah potvrzení">
|
||||||
<label className="admin-form-label">Obsah potvrzení</label>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||||
<p
|
|
||||||
className="text-secondary"
|
|
||||||
style={{ marginBottom: "0.75rem" }}
|
|
||||||
>
|
|
||||||
Jak chcete připravit potvrzení objednávky?
|
Jak chcete připravit potvrzení objednávky?
|
||||||
</p>
|
</Typography>
|
||||||
<button
|
<Button
|
||||||
onClick={handleUseExisting}
|
fullWidth
|
||||||
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
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setItems(initialItems.length > 0 ? initialItems : []);
|
setItems(initialItems.length > 0 ? initialItems : []);
|
||||||
setStep("edit");
|
setStep("edit");
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="admin-btn admin-btn-secondary w-full"
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
>
|
>
|
||||||
Upravit položky
|
Upravit položky
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-form">
|
<Box>
|
||||||
<div className="admin-table-responsive">
|
<Box sx={{ mb: 1.5 }}>
|
||||||
<table className="admin-table">
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Popis</th>
|
<th>Popis</th>
|
||||||
@@ -246,18 +323,16 @@ export default function OrderConfirmationModal({
|
|||||||
{items.map((item, i) => (
|
{items.map((item, i) => (
|
||||||
<tr key={i}>
|
<tr key={i}>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
value={item.description}
|
value={item.description}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateItem(i, "description", e.target.value)
|
updateItem(i, "description", e.target.value)
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
sx={{ minWidth: 200 }}
|
||||||
style={{ minWidth: "200px" }}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={item.quantity}
|
value={item.quantity}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -267,24 +342,21 @@ export default function OrderConfirmationModal({
|
|||||||
Number(e.target.value) || 0,
|
Number(e.target.value) || 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
sx={{ width: 90 }}
|
||||||
style={{ width: "80px" }}
|
slotProps={{ htmlInput: { step: "0.001" } }}
|
||||||
step="0.001"
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
value={item.unit}
|
value={item.unit}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateItem(i, "unit", e.target.value)
|
updateItem(i, "unit", e.target.value)
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
sx={{ width: 70 }}
|
||||||
style={{ width: "60px" }}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={item.unit_price}
|
value={item.unit_price}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -294,13 +366,12 @@ export default function OrderConfirmationModal({
|
|||||||
Number(e.target.value) || 0,
|
Number(e.target.value) || 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
sx={{ width: 110 }}
|
||||||
style={{ width: "100px" }}
|
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||||
step="0.01"
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={item.vat_rate}
|
value={item.vat_rate}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -310,16 +381,17 @@ export default function OrderConfirmationModal({
|
|||||||
Number(e.target.value) || 0,
|
Number(e.target.value) || 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
sx={{ width: 80 }}
|
||||||
style={{ width: "70px" }}
|
slotProps={{ htmlInput: { step: "1" } }}
|
||||||
step="1"
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
onClick={() => removeItem(i)}
|
onClick={() => removeItem(i)}
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Odstranit"
|
title="Odstranit"
|
||||||
|
aria-label="Odstranit"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
@@ -332,65 +404,26 @@ export default function OrderConfirmationModal({
|
|||||||
<polyline points="3 6 5 6 21 6" />
|
<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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</IconButton>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
<button
|
)}
|
||||||
|
<Box sx={{ mt: 1.5 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
onClick={addItem}
|
onClick={addItem}
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
>
|
>
|
||||||
+ Přidat položku
|
+ Přidat položku
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Modal>
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState, type ChangeEvent, type FocusEvent } from "react";
|
||||||
import FormModal from "./FormModal";
|
import Box from "@mui/material/Box";
|
||||||
|
import { Modal, TextField, Button } from "../ui";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { PlanCategory } from "../lib/queries/plan";
|
import { PlanCategory } from "../lib/queries/plan";
|
||||||
@@ -91,23 +92,44 @@ export default function PlanCategoriesModal({
|
|||||||
const busy =
|
const busy =
|
||||||
createCat.isPending || updateCat.isPending || deleteCat.isPending;
|
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 (
|
return (
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
onSubmit={onClose}
|
||||||
title="Správa kategorií"
|
title="Správa kategorií"
|
||||||
hideFooter
|
submitText="Zavřít"
|
||||||
size="md"
|
hideCancel
|
||||||
|
maxWidth="md"
|
||||||
>
|
>
|
||||||
<div className="plan-cat-manager">
|
<Box>
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<div
|
<Box key={c.id} sx={{ ...rowSx, opacity: c.is_active ? 1 : 0.55 }}>
|
||||||
key={c.id}
|
<Box
|
||||||
className={`plan-cat-row${c.is_active ? "" : " plan-cat-row--inactive"}`}
|
component="input"
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="color"
|
type="color"
|
||||||
className="plan-cat-color"
|
sx={colorInputSx}
|
||||||
// Uncontrolled + patch on blur (like the rename input): the
|
// Uncontrolled + patch on blur (like the rename input): the
|
||||||
// native color picker fires onChange on every drag tick, which
|
// native color picker fires onChange on every drag tick, which
|
||||||
// would spam PATCH requests and the audit log. onBlur commits
|
// would spam PATCH requests and the audit log. onBlur commits
|
||||||
@@ -115,96 +137,102 @@ export default function PlanCategoriesModal({
|
|||||||
defaultValue={c.color}
|
defaultValue={c.color}
|
||||||
key={c.color}
|
key={c.color}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onBlur={(e) => {
|
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||||
if (e.target.value !== c.color) {
|
if (e.target.value !== c.color) {
|
||||||
patch(c.id, { color: e.target.value });
|
patch(c.id, { color: e.target.value });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
aria-label={`Barva – ${c.label}`}
|
aria-label={`Barva – ${c.label}`}
|
||||||
/>
|
/>
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
className="admin-form-input"
|
|
||||||
defaultValue={c.label}
|
defaultValue={c.label}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const v = e.target.value.trim();
|
const v = e.target.value.trim();
|
||||||
if (v && v !== c.label) patch(c.id, { label: v });
|
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 ? (
|
{confirmDeleteId === c.id ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="contained"
|
||||||
className="admin-btn admin-btn-danger admin-btn-sm"
|
color="error"
|
||||||
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => handleDelete(c.id)}
|
onClick={() => handleDelete(c.id)}
|
||||||
>
|
>
|
||||||
Opravdu smazat
|
Opravdu smazat
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outlined"
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
color="inherit"
|
||||||
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => setConfirmDeleteId(null)}
|
onClick={() => setConfirmDeleteId(null)}
|
||||||
>
|
>
|
||||||
Zrušit
|
Zrušit
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outlined"
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
color="inherit"
|
||||||
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => patch(c.id, { is_active: !c.is_active })}
|
onClick={() => patch(c.id, { is_active: !c.is_active })}
|
||||||
>
|
>
|
||||||
{c.is_active ? "Skrýt" : "Obnovit"}
|
{c.is_active ? "Skrýt" : "Obnovit"}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="contained"
|
||||||
className="admin-btn admin-btn-danger admin-btn-sm plan-cat-delete"
|
color="error"
|
||||||
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => setConfirmDeleteId(c.id)}
|
onClick={() => setConfirmDeleteId(c.id)}
|
||||||
aria-label={`Smazat – ${c.label}`}
|
aria-label={`Smazat – ${c.label}`}
|
||||||
title="Smazat kategorii"
|
title="Smazat kategorii"
|
||||||
>
|
>
|
||||||
Smazat
|
Smazat
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="plan-cat-row plan-cat-row--new">
|
<Box
|
||||||
<input
|
sx={{ ...rowSx, mt: 1, pt: 2, borderTop: 1, borderColor: "divider" }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="input"
|
||||||
type="color"
|
type="color"
|
||||||
className="plan-cat-color"
|
sx={colorInputSx}
|
||||||
value={newColor}
|
value={newColor}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => setNewColor(e.target.value)}
|
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setNewColor(e.target.value)
|
||||||
|
}
|
||||||
aria-label="Barva nové kategorie"
|
aria-label="Barva nové kategorie"
|
||||||
/>
|
/>
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Nová kategorie…"
|
placeholder="Nová kategorie…"
|
||||||
value={newLabel}
|
value={newLabel}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) => setNewLabel(e.target.value)}
|
onChange={(e) => setNewLabel(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="contained"
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
color="primary"
|
||||||
|
size="small"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
>
|
>
|
||||||
Přidat
|
Přidat
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</FormModal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import FormModal from "./FormModal";
|
import Box from "@mui/material/Box";
|
||||||
import FormField from "./FormField";
|
import Typography from "@mui/material/Typography";
|
||||||
import AdminDatePicker from "./AdminDatePicker";
|
import {
|
||||||
import ConfirmModal from "./ConfirmModal";
|
Modal,
|
||||||
|
ConfirmDialog,
|
||||||
|
Field,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
DateField,
|
||||||
|
CheckboxField,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
} from "../ui";
|
||||||
import useReducedMotion from "../hooks/useReducedMotion";
|
import useReducedMotion from "../hooks/useReducedMotion";
|
||||||
import {
|
import {
|
||||||
ResolvedCell,
|
ResolvedCell,
|
||||||
@@ -22,6 +31,7 @@ interface Project {
|
|||||||
export type PlanCellModalMode =
|
export type PlanCellModalMode =
|
||||||
| { kind: "closed" }
|
| { kind: "closed" }
|
||||||
| { kind: "create"; userId: number; date: string }
|
| { kind: "create"; userId: number; date: string }
|
||||||
|
| { kind: "create-override"; userId: number; date: string }
|
||||||
| {
|
| {
|
||||||
kind: "edit-range";
|
kind: "edit-range";
|
||||||
entryId: number;
|
entryId: number;
|
||||||
@@ -55,7 +65,8 @@ export type PlanCellModalMode =
|
|||||||
note: string;
|
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 {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -75,14 +86,45 @@ interface Props {
|
|||||||
date: string,
|
date: string,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onSwitchToEditRange: (entryId: number, userId: number, date: string) => 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) {
|
export default function PlanCellModal(props: Props) {
|
||||||
const { open, mode, onClose } = props;
|
const { open, mode, onClose } = props;
|
||||||
if (!open || mode.kind === "closed") return null;
|
if (!open || mode.kind === "closed") return null;
|
||||||
if (mode.kind === "view") return <ViewModal {...props} />;
|
if (mode.kind === "view") return <ViewModal {...props} />;
|
||||||
if (mode.kind === "day-in-range")
|
if (mode.kind === "day-in-range")
|
||||||
return <DayInRangeModal {...props} mode={mode} />;
|
return <DayInRangeModal {...props} mode={mode} />;
|
||||||
|
if (mode.kind === "day") return <DayPanel {...props} mode={mode} />;
|
||||||
return <EditForm {...props} />;
|
return <EditForm {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,21 +144,26 @@ function EditForm(props: Props) {
|
|||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
// Narrow mode to the kinds EditForm actually handles. The call site in
|
// Narrow mode to the kinds EditForm actually handles. The call site in
|
||||||
// PlanCellModal only passes "create" | "edit-range" | "edit-override" but
|
// PlanCellModal only passes "create" | "create-override" | "edit-range" |
|
||||||
// the parameter type is the full union, so we narrow explicitly here.
|
// "edit-override" but the parameter type is the full union, so we narrow
|
||||||
|
// explicitly here.
|
||||||
if (
|
if (
|
||||||
mode.kind !== "create" &&
|
mode.kind !== "create" &&
|
||||||
|
mode.kind !== "create-override" &&
|
||||||
mode.kind !== "edit-range" &&
|
mode.kind !== "edit-range" &&
|
||||||
mode.kind !== "edit-override"
|
mode.kind !== "edit-override"
|
||||||
) {
|
) {
|
||||||
return null;
|
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 activeCategories = props.categories.filter((c) => c.is_active);
|
||||||
|
|
||||||
const initial = (() => {
|
const initial = (() => {
|
||||||
if (mode.kind === "create") {
|
if (mode.kind === "create" || mode.kind === "create-override") {
|
||||||
return {
|
return {
|
||||||
date_from: mode.date,
|
date_from: mode.date,
|
||||||
date_to: mode.date,
|
date_to: mode.date,
|
||||||
@@ -161,6 +208,8 @@ function EditForm(props: Props) {
|
|||||||
const title =
|
const title =
|
||||||
mode.kind === "create"
|
mode.kind === "create"
|
||||||
? "Nový záznam plánu"
|
? "Nový záznam plánu"
|
||||||
|
: mode.kind === "create-override"
|
||||||
|
? "Nové přepsání dne"
|
||||||
: mode.kind === "edit-range"
|
: mode.kind === "edit-range"
|
||||||
? "Upravit rozsah"
|
? "Upravit rozsah"
|
||||||
: "Upravit přepsání dne";
|
: "Upravit přepsání dne";
|
||||||
@@ -178,6 +227,17 @@ function EditForm(props: Props) {
|
|||||||
category,
|
category,
|
||||||
note,
|
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") {
|
} else if (mode.kind === "edit-range") {
|
||||||
await onUpdateEntry(mode.entryId, {
|
await onUpdateEntry(mode.entryId, {
|
||||||
date_from: dateFrom,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen
|
isOpen
|
||||||
title={title}
|
title={title}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
submitText={isCreate ? "Vytvořit" : "Uložit"}
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
footerLeft={
|
>
|
||||||
mode.kind !== "create" ? (
|
{!isCreate && (
|
||||||
<button
|
<Box sx={{ mb: 2 }}>
|
||||||
type="button"
|
<Button
|
||||||
className="admin-btn admin-btn-secondary"
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
startIcon={TrashIcon}
|
||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmDelete(true)}
|
||||||
disabled={submitting}
|
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
|
Smazat
|
||||||
</button>
|
</Button>
|
||||||
) : null
|
</Box>
|
||||||
}
|
)}
|
||||||
>
|
<Field label="Datum od">
|
||||||
<FormField label="Datum od">
|
<DateField
|
||||||
<AdminDatePicker
|
|
||||||
value={dateFrom}
|
value={dateFrom}
|
||||||
onChange={setDateFrom}
|
onChange={setDateFrom}
|
||||||
disabled={isOverride}
|
disabled={isOverride}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
{!isOverride && (
|
{!isOverride && (
|
||||||
<FormField label="Rozsah dnů">
|
<Field label="Rozsah dnů">
|
||||||
<label className="admin-form-checkbox">
|
<CheckboxField
|
||||||
<input
|
label="Více dní"
|
||||||
type="checkbox"
|
|
||||||
checked={isRange}
|
checked={isRange}
|
||||||
onChange={(e) => setIsRange(e.target.checked)}
|
onChange={setIsRange}
|
||||||
/>
|
/>
|
||||||
<span>Více dní</span>
|
</Field>
|
||||||
</label>
|
|
||||||
</FormField>
|
|
||||||
)}
|
)}
|
||||||
{!isOverride && isRange && (
|
{!isOverride && isRange && (
|
||||||
<FormField label="Datum do">
|
<Field label="Datum do">
|
||||||
<AdminDatePicker value={dateTo} onChange={setDateTo} />
|
<DateField value={dateTo} onChange={setDateTo} />
|
||||||
</FormField>
|
</Field>
|
||||||
)}
|
)}
|
||||||
<FormField label="Projekt">
|
<Field label="Projekt">
|
||||||
<select
|
<Select
|
||||||
className="admin-form-select"
|
value={projectId === null ? "" : String(projectId)}
|
||||||
value={projectId ?? ""}
|
onChange={(val) => setProjectId(val ? Number(val) : null)}
|
||||||
onChange={(e) =>
|
options={[
|
||||||
setProjectId(e.target.value ? Number(e.target.value) : null)
|
{ value: "", label: "— bez projektu —" },
|
||||||
}
|
...projects.map((p) => ({
|
||||||
>
|
value: String(p.id),
|
||||||
<option value="">— bez projektu —</option>
|
label: p.name,
|
||||||
{projects.map((p) => (
|
})),
|
||||||
<option key={p.id} value={p.id}>
|
]}
|
||||||
{p.name}
|
/>
|
||||||
</option>
|
</Field>
|
||||||
))}
|
<Field label="Kategorie">
|
||||||
</select>
|
<Select
|
||||||
</FormField>
|
|
||||||
<FormField label="Kategorie">
|
|
||||||
<select
|
|
||||||
className="admin-form-select"
|
|
||||||
value={category}
|
value={category}
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
onChange={(val) => setCategory(val)}
|
||||||
>
|
options={[
|
||||||
{/* If the row's current category was retired/hidden, still show it
|
...(showRetiredCategory
|
||||||
(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. */}
|
value: category,
|
||||||
{category && !activeCategories.some((c) => c.key === category) && (
|
label: `${planCategoryLabel(
|
||||||
<option value={category}>
|
|
||||||
{planCategoryLabel(
|
|
||||||
category,
|
category,
|
||||||
Object.fromEntries(props.categories.map((x) => [x.key, x])),
|
Object.fromEntries(
|
||||||
)}{" "}
|
props.categories.map((x) => [x.key, x]),
|
||||||
(skrytá)
|
),
|
||||||
</option>
|
)} (skrytá)`,
|
||||||
)}
|
},
|
||||||
{activeCategories.map((c) => (
|
]
|
||||||
<option key={c.key} value={c.key}>
|
: []),
|
||||||
{c.label}
|
...activeCategories.map((c) => ({
|
||||||
</option>
|
value: c.key,
|
||||||
))}
|
label: c.label,
|
||||||
</select>
|
})),
|
||||||
</FormField>
|
]}
|
||||||
<FormField label="Text poznámky (volitelný)">
|
/>
|
||||||
<textarea
|
</Field>
|
||||||
className="admin-form-textarea"
|
<Field label="Text poznámky (volitelný)">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
maxLength={500}
|
inputProps={{ maxLength: 500 }}
|
||||||
rows={3}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</FormModal>
|
</Modal>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={confirmDelete}
|
isOpen={confirmDelete}
|
||||||
title="Smazat záznam plánu?"
|
title="Smazat záznam plánu?"
|
||||||
message="Tato akce je nevratná (záznam bude soft-delete)."
|
message="Tato akce je nevratná (záznam bude soft-delete)."
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
type="danger"
|
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
onClose={() => setConfirmDelete(false)}
|
onClose={() => setConfirmDelete(false)}
|
||||||
@@ -376,26 +423,26 @@ function DayInRangeModal(
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen
|
isOpen
|
||||||
title="Den je součástí rozsahu"
|
title="Den je součástí rozsahu"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
hideFooter
|
onSubmit={onClose}
|
||||||
size="md"
|
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{" "}
|
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
|
||||||
<strong>
|
<strong>
|
||||||
{mode.range.date_from} – {mode.range.date_to}
|
{mode.range.date_from} – {mode.range.date_to}
|
||||||
</strong>
|
</strong>
|
||||||
. Vyberte, co chcete udělat:
|
. Vyberte, co chcete udělat:
|
||||||
</p>
|
</Typography>
|
||||||
<ul className="plan-choices">
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{choices.map((c, i) => (
|
{choices.map((c, i) => (
|
||||||
<motion.li
|
<motion.div
|
||||||
key={c.key}
|
key={c.key}
|
||||||
className={`plan-choice plan-choice-${c.tone}`}
|
|
||||||
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
|
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
|
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
|
||||||
@@ -405,35 +452,42 @@ function DayInRangeModal(
|
|||||||
ease: [0.16, 1, 0.3, 1],
|
ease: [0.16, 1, 0.3, 1],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="plan-choice-body">
|
<Card
|
||||||
<div className="plan-choice-title">{c.title}</div>
|
variant="outlined"
|
||||||
<div className="plan-choice-desc">{c.desc}</div>
|
sx={{
|
||||||
</div>
|
borderColor:
|
||||||
<button
|
c.tone === "primary" ? "primary.main" : "divider",
|
||||||
type="button"
|
}}
|
||||||
className={
|
>
|
||||||
c.tone === "primary"
|
<Box
|
||||||
? "admin-btn admin-btn-primary"
|
sx={{
|
||||||
: "admin-btn admin-btn-secondary"
|
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}
|
onClick={c.onClick}
|
||||||
>
|
>
|
||||||
{c.cta}
|
{c.cta}
|
||||||
</button>
|
</Button>
|
||||||
</motion.li>
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</ul>
|
</Box>
|
||||||
<div className="plan-modal-actions plan-modal-actions--solo">
|
</Modal>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Zavřít — ponechat rozsah beze změny
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,29 +508,150 @@ function ViewModal(props: Props) {
|
|||||||
: fallback.name
|
: fallback.name
|
||||||
: null);
|
: null);
|
||||||
return (
|
return (
|
||||||
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
|
<Modal
|
||||||
<p>
|
isOpen
|
||||||
|
title="Detail plánu"
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onClose}
|
||||||
|
submitText="Zavřít"
|
||||||
|
hideCancel
|
||||||
|
>
|
||||||
|
<Typography sx={{ mb: 1 }}>
|
||||||
<strong>Datum:</strong> {formatDate(c.shift_date)}
|
<strong>Datum:</strong> {formatDate(c.shift_date)}
|
||||||
</p>
|
</Typography>
|
||||||
<p>
|
<Typography sx={{ mb: 1 }}>
|
||||||
<strong>Kategorie:</strong>{" "}
|
<strong>Kategorie:</strong>{" "}
|
||||||
{planCategoryLabel(
|
{planCategoryLabel(
|
||||||
c.category,
|
c.category,
|
||||||
Object.fromEntries(categories.map((x) => [x.key, x])),
|
Object.fromEntries(categories.map((x) => [x.key, x])),
|
||||||
)}
|
)}
|
||||||
</p>
|
</Typography>
|
||||||
<p>
|
<Typography sx={{ mb: 1 }}>
|
||||||
<strong>Projekt:</strong> {projectLabel || "—"}
|
<strong>Projekt:</strong> {projectLabel || "—"}
|
||||||
</p>
|
</Typography>
|
||||||
<p>
|
<Typography sx={{ mb: 1 }}>
|
||||||
<strong>Text:</strong> {c.note || "—"}
|
<strong>Text:</strong> {c.note || "—"}
|
||||||
</p>
|
</Typography>
|
||||||
{c.rangeFrom && c.rangeTo && (
|
{c.rangeFrom && c.rangeTo && (
|
||||||
<p>
|
<Typography sx={{ mb: 1 }}>
|
||||||
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} –{" "}
|
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} –{" "}
|
||||||
{formatDate(c.rangeTo)}
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
import {
|
import {
|
||||||
GridData,
|
GridData,
|
||||||
ResolvedCell,
|
ResolvedCell,
|
||||||
@@ -9,6 +11,368 @@ import {
|
|||||||
} from "../lib/queries/plan";
|
} from "../lib/queries/plan";
|
||||||
import type { Project } from "../lib/queries/projects";
|
import type { Project } from "../lib/queries/projects";
|
||||||
import PlanRangeChips from "./PlanRangeChips";
|
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 {
|
interface Props {
|
||||||
data: GridData | undefined;
|
data: GridData | undefined;
|
||||||
@@ -20,11 +384,7 @@ interface Props {
|
|||||||
// the value so back-to-back mutations on the same cell re-trigger
|
// 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).
|
// the animation (React needs a new key to re-mount the class).
|
||||||
pulseKey?: { userId: number; date: string; nonce: number } | null;
|
pulseKey?: { userId: number; date: string; nonce: number } | null;
|
||||||
onCellClick: (
|
onCellClick: (userId: number, date: string, cells: ResolvedCell[]) => void;
|
||||||
userId: number,
|
|
||||||
date: string,
|
|
||||||
cell: ResolvedCell | null,
|
|
||||||
) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full Czech weekday names, indexed by Date.getUTCDay() (0 = Sunday).
|
// Full Czech weekday names, indexed by Date.getUTCDay() (0 = Sunday).
|
||||||
@@ -107,12 +467,7 @@ export default function PlanGrid({
|
|||||||
const today = useMemo(() => todayIso(), []);
|
const today = useMemo(() => todayIso(), []);
|
||||||
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||||
|
|
||||||
if (!data)
|
if (!data) return <LoadingState />;
|
||||||
return (
|
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
const days = eachDay(data.date_from, data.date_to);
|
const days = eachDay(data.date_from, data.date_to);
|
||||||
const users = data.users;
|
const users = data.users;
|
||||||
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
||||||
@@ -124,7 +479,7 @@ export default function PlanGrid({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="plan-grid-wrap" data-pulse={pulseAttr}>
|
<PlanGridRoot data-pulse={pulseAttr}>
|
||||||
<table className="plan-grid">
|
<table className="plan-grid">
|
||||||
{/* The colgroup is what actually controls column width in a
|
{/* The colgroup is what actually controls column width in a
|
||||||
table — `min-width` on `<th>`/`<td>` is just a floor that
|
table — `min-width` on `<th>`/`<td>` is just a floor that
|
||||||
@@ -181,7 +536,8 @@ export default function PlanGrid({
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{users.map((u) => {
|
{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);
|
const past = isPastDate(date, today);
|
||||||
// Past-day cells are read-only in the UI: an empty past
|
// Past-day cells are read-only in the UI: an empty past
|
||||||
// cell is non-interactive (no create modal, no "+" hint),
|
// cell is non-interactive (no create modal, no "+" hint),
|
||||||
@@ -223,29 +579,51 @@ export default function PlanGrid({
|
|||||||
} as CSSProperties)
|
} as CSSProperties)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClick={() => onCellClick(u.id, date, cell)}
|
onClick={() => onCellClick(u.id, date, cellArr)}
|
||||||
aria-label={
|
aria-label={
|
||||||
cell
|
cell
|
||||||
|
? cellArr.length === 1
|
||||||
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
|
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
|
||||||
|
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
|
||||||
: isLocked
|
: isLocked
|
||||||
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
|
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
|
||||||
: `${u.full_name}, ${date}, prázdné — přidat záznam`
|
: `${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
|
<PlanRangeChips
|
||||||
cell={cell}
|
cell={c}
|
||||||
project={
|
project={
|
||||||
cell?.project_id
|
c.project_id
|
||||||
? (projects.find(
|
? (projects.find(
|
||||||
(p) => p.id === cell.project_id,
|
(p) => p.id === c.project_id,
|
||||||
) ?? null)
|
) ?? null)
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
readonly={!canEdit}
|
readonly={!canEdit}
|
||||||
categoryLabel={
|
categoryLabel={planCategoryLabel(
|
||||||
cell ? planCategoryLabel(cell.category, catMap) : ""
|
c.category,
|
||||||
}
|
catMap,
|
||||||
|
)}
|
||||||
|
showNote={cellArr.length === 1}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -255,6 +633,6 @@ export default function PlanGrid({
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</PlanGridRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface Props {
|
|||||||
project: Project | null;
|
project: Project | null;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
categoryLabel: string;
|
categoryLabel: string;
|
||||||
|
showNote?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlanRangeChips({
|
export default function PlanRangeChips({
|
||||||
@@ -13,6 +14,7 @@ export default function PlanRangeChips({
|
|||||||
project,
|
project,
|
||||||
readonly,
|
readonly,
|
||||||
categoryLabel,
|
categoryLabel,
|
||||||
|
showNote = true,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
void readonly;
|
void readonly;
|
||||||
if (!cell) return null;
|
if (!cell) return null;
|
||||||
@@ -36,7 +38,9 @@ export default function PlanRangeChips({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{cell.note && <div className="plan-cell-note">{cell.note}</div>}
|
{showNote && cell.note && (
|
||||||
|
<div className="plan-cell-note">{cell.note}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { projectFilesOptions } from "../lib/queries/projects";
|
||||||
import { useAlert } from "../context/AlertContext";
|
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";
|
import apiFetch from "../utils/api";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
@@ -122,7 +135,7 @@ function getFileIcon(type: string, extension?: string) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cfg = iconMap[ext] || {
|
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",
|
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 }) {
|
function SymlinkBadge({ target }: { target?: string }) {
|
||||||
return (
|
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
|
<svg
|
||||||
width="12"
|
width="12"
|
||||||
height="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="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" />
|
<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>
|
</svg>
|
||||||
</span>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,26 +190,53 @@ function FileNameCell({
|
|||||||
}) {
|
}) {
|
||||||
if (item.type === "folder") {
|
if (item.type === "folder") {
|
||||||
return (
|
return (
|
||||||
<span className="fm-name-cell">
|
<Box
|
||||||
<button
|
component="span"
|
||||||
|
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
className="fm-folder-link"
|
|
||||||
onClick={() => onFolderClick(item.name)}
|
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}
|
{item.name}
|
||||||
</button>
|
</Box>
|
||||||
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
||||||
{item.item_count !== undefined && (
|
{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 (
|
return (
|
||||||
<span className="fm-name-cell">
|
<Box
|
||||||
<span className="fm-file-name">{item.name}</span>
|
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} />}
|
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
||||||
</span>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,257 +518,58 @@ export default function ProjectFileManager({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (filesLoading && items.length === 0 && !errorMessage) {
|
if (filesLoading && items.length === 0 && !errorMessage) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-card">
|
<Card>
|
||||||
<div className="admin-card-body">
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
<h3 className="admin-card-title">Soubory</h3>
|
Soubory
|
||||||
<div className="fm-empty">
|
</Typography>
|
||||||
|
<EmptyState
|
||||||
|
icon={
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="var(--text-tertiary)"
|
stroke="currentColor"
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<line x1="12" y1="8" x2="12" y2="12" />
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>{errorMessage}</span>
|
}
|
||||||
</div>
|
title={errorMessage}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const columns: DataColumn<FileItem>[] = [
|
||||||
<div className="admin-card">
|
{
|
||||||
<div className="admin-card-body">
|
key: "icon",
|
||||||
<h3 className="admin-card-title">Soubory</h3>
|
header: "",
|
||||||
|
width: 30,
|
||||||
{/* Toolbar */}
|
align: "left",
|
||||||
<div className="fm-toolbar">
|
render: (item) => (
|
||||||
<div className="fm-breadcrumb">
|
<Box sx={{ textAlign: "center" }}>
|
||||||
{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" }}>
|
|
||||||
{getFileIcon(item.type, item.extension)}
|
{getFileIcon(item.type, item.extension)}
|
||||||
</td>
|
</Box>
|
||||||
<td>
|
),
|
||||||
{renamingItem === item.name ? (
|
},
|
||||||
<input
|
{
|
||||||
type="text"
|
key: "name",
|
||||||
|
header: "Název",
|
||||||
|
render: (item) =>
|
||||||
|
renamingItem === item.name ? (
|
||||||
|
<TextField
|
||||||
value={renameValue}
|
value={renameValue}
|
||||||
onChange={(e) => setRenameValue(e.target.value)}
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
className="admin-form-input"
|
|
||||||
style={{
|
|
||||||
fontSize: "11px",
|
|
||||||
padding: "3px 8px",
|
|
||||||
maxWidth: "300px",
|
|
||||||
}}
|
|
||||||
autoFocus
|
autoFocus
|
||||||
|
sx={{ maxWidth: 300 }}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -743,24 +593,49 @@ export default function ProjectFileManager({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FileNameCell
|
<FileNameCell item={item} onFolderClick={handleFolderClick} />
|
||||||
item={item}
|
),
|
||||||
onFolderClick={handleFolderClick}
|
},
|
||||||
/>
|
{
|
||||||
)}
|
key: "size",
|
||||||
</td>
|
header: "Velikost",
|
||||||
<td className="fm-meta">
|
width: 90,
|
||||||
{item.type === "file" ? item.size_formatted : "\u2014"}
|
render: (item) => (
|
||||||
</td>
|
<Typography variant="caption" color="text.secondary">
|
||||||
<td className="fm-meta">{item.modified || "\u2014"}</td>
|
{item.type === "file" ? item.size_formatted : "—"}
|
||||||
{canManage && (
|
</Typography>
|
||||||
<td style={{ textAlign: "right" }}>
|
),
|
||||||
<div className="fm-actions">
|
},
|
||||||
|
{
|
||||||
|
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" && (
|
{item.type === "file" && (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
size="small"
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Stáhnout"
|
title="Stáhnout"
|
||||||
|
aria-label="Stáhnout"
|
||||||
onClick={() => handleDownload(item)}
|
onClick={() => handleDownload(item)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -775,12 +650,12 @@ export default function ProjectFileManager({
|
|||||||
<polyline points="7 10 12 15 17 10" />
|
<polyline points="7 10 12 15 17 10" />
|
||||||
<line x1="12" y1="15" x2="12" y2="3" />
|
<line x1="12" y1="15" x2="12" y2="3" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
size="small"
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Přejmenovat"
|
title="Přejmenovat"
|
||||||
|
aria-label="Přejmenovat"
|
||||||
onClick={() => startRename(item)}
|
onClick={() => startRename(item)}
|
||||||
>
|
>
|
||||||
<svg
|
<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="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" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</IconButton>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
size="small"
|
||||||
className="admin-btn-icon danger"
|
color="error"
|
||||||
title="Smazat"
|
title="Smazat"
|
||||||
|
aria-label="Smazat"
|
||||||
onClick={() => setDeleteTarget(item)}
|
onClick={() => setDeleteTarget(item)}
|
||||||
>
|
>
|
||||||
<svg
|
<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="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
<path d="M10 11v6M14 11v6" />
|
<path d="M10 11v6M14 11v6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</Box>
|
||||||
</td>
|
),
|
||||||
)}
|
},
|
||||||
</tr>
|
]
|
||||||
))}
|
: []),
|
||||||
</tbody>
|
];
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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}
|
isOpen={deleteTarget !== null}
|
||||||
onClose={() => setDeleteTarget(null)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
onConfirm={handleDelete}
|
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." : ""}`}
|
message={`Opravdu chcete smazat "${deleteTarget?.name}"?${deleteTarget?.type === "folder" ? " Složka bude smazána včetně veškerého obsahu." : ""}`}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
type="danger"
|
confirmVariant="danger"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
|
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
|
||||||
import ReactQuill from "react-quill-new";
|
import ReactQuill from "react-quill-new";
|
||||||
import "react-quill-new/dist/quill.snow.css";
|
import "react-quill-new/dist/quill.snow.css";
|
||||||
|
import { RichEditorRoot } from "../ui/RichText";
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
"#000000",
|
"#000000",
|
||||||
@@ -104,8 +105,7 @@ export default function RichEditor({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<RichEditorRoot
|
||||||
className="admin-rich-editor"
|
|
||||||
style={{ "--re-min-height": minHeight } as React.CSSProperties}
|
style={{ "--re-min-height": minHeight } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<ReactQuill
|
<ReactQuill
|
||||||
@@ -118,6 +118,6 @@ export default function RichEditor({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</RichEditorRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
import AdminDatePicker from "./AdminDatePicker";
|
import Typography from "@mui/material/Typography";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
DateField,
|
||||||
|
TimeField,
|
||||||
|
Field,
|
||||||
|
Alert,
|
||||||
|
} from "../ui";
|
||||||
import {
|
import {
|
||||||
calcFormWorkMinutes,
|
calcFormWorkMinutes,
|
||||||
calcProjectMinutesTotal,
|
calcProjectMinutesTotal,
|
||||||
@@ -81,6 +91,19 @@ export interface ShiftFormModalProps {
|
|||||||
editingRecord: EditingRecord | null;
|
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 ----------
|
// ---------- ProjectTimeStatus ----------
|
||||||
|
|
||||||
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
||||||
@@ -101,30 +124,14 @@ function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
|||||||
|
|
||||||
const isMatch = remaining === 0;
|
const isMatch = remaining === 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<Box sx={{ mb: 1 }}>
|
||||||
style={{
|
<Alert severity={isMatch ? "success" : "error"}>
|
||||||
padding: "0.5rem 0.75rem",
|
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m |
|
||||||
marginBottom: "0.5rem",
|
Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m |
|
||||||
borderRadius: "6px",
|
Zbývá: {Math.floor(Math.abs(remaining) / 60)}h{" "}
|
||||||
fontSize: "0.8rem",
|
{Math.abs(remaining) % 60}m {remaining < 0 ? "(překročeno)" : ""}
|
||||||
background: isMatch
|
</Alert>
|
||||||
? "var(--success-bg, rgba(34,197,94,0.1))"
|
</Box>
|
||||||
: "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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,65 +145,65 @@ function ProjectLogRow({
|
|||||||
onRemove,
|
onRemove,
|
||||||
}: ProjectLogRowProps) {
|
}: ProjectLogRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-row gap-2 mb-2">
|
<Box
|
||||||
<select
|
sx={{
|
||||||
value={log.project_id}
|
display: "flex",
|
||||||
onChange={(e) => onUpdate(index, "project_id", e.target.value)}
|
alignItems: "center",
|
||||||
className="admin-form-select"
|
gap: 1,
|
||||||
style={{ flex: 3, marginBottom: 0 }}
|
mb: 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="">— Projekt —</option>
|
<Box sx={{ flex: 3, minWidth: 0 }}>
|
||||||
{projectList.map((p) => (
|
<Select
|
||||||
<option key={p.id} value={p.id}>
|
value={String(log.project_id)}
|
||||||
{p.project_number} – {p.name}
|
onChange={(val) => onUpdate(index, "project_id", val)}
|
||||||
</option>
|
options={[
|
||||||
))}
|
{ value: "", label: "— Projekt —" },
|
||||||
</select>
|
...projectList.map((p) => ({
|
||||||
<input
|
value: String(p.id),
|
||||||
|
label: `${p.project_number} – ${p.name}`,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ width: 70, flexShrink: 0 }}>
|
||||||
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
|
||||||
max="24"
|
|
||||||
value={log.hours}
|
value={log.hours}
|
||||||
onChange={(e) => onUpdate(index, "hours", e.target.value)}
|
onChange={(e) => onUpdate(index, "hours", e.target.value)}
|
||||||
className="admin-form-input"
|
|
||||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
|
||||||
placeholder="h"
|
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
|
h
|
||||||
</span>
|
</Typography>
|
||||||
<input
|
<Box sx={{ width: 70, flexShrink: 0 }}>
|
||||||
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
value={log.minutes}
|
value={log.minutes}
|
||||||
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
|
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
|
||||||
className="admin-form-input"
|
|
||||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
|
||||||
placeholder="m"
|
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
|
m
|
||||||
</span>
|
</Typography>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
size="small"
|
||||||
onClick={() => onRemove(index)}
|
onClick={() => onRemove(index)}
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
aria-label="Odebrat"
|
||||||
style={{ padding: "0.375rem", flexShrink: 0 }}
|
|
||||||
title="Odebrat"
|
title="Odebrat"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<svg
|
{RemoveIcon}
|
||||||
width="16"
|
</IconButton>
|
||||||
height="16"
|
</Box>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +223,6 @@ export default function ShiftFormModal({
|
|||||||
onShiftDateChange,
|
onShiftDateChange,
|
||||||
editingRecord,
|
editingRecord,
|
||||||
}: ShiftFormModalProps) {
|
}: ShiftFormModalProps) {
|
||||||
useModalLock(isOpen);
|
|
||||||
const isCreate = mode === "create";
|
const isCreate = mode === "create";
|
||||||
const isWorkType = form.leave_type === "work";
|
const isWorkType = form.leave_type === "work";
|
||||||
|
|
||||||
@@ -247,224 +253,181 @@ export default function ShiftFormModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{isOpen && (
|
isOpen={isOpen}
|
||||||
<motion.div
|
onClose={onClose}
|
||||||
className="admin-modal-overlay"
|
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
||||||
initial={{ opacity: 0 }}
|
subtitle={
|
||||||
animate={{ opacity: 1 }}
|
!isCreate && editingRecord
|
||||||
exit={{ opacity: 0 }}
|
? `${editingRecord.user_name} — ${formatDate(editingRecord.shift_date)}`
|
||||||
transition={{ duration: 0.2 }}
|
: undefined
|
||||||
|
}
|
||||||
|
maxWidth="lg"
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
submitText="Uložit"
|
||||||
|
cancelText="Zrušit"
|
||||||
>
|
>
|
||||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
{isCreate ? (
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-modal admin-modal-lg"
|
sx={{
|
||||||
role="dialog"
|
display: "grid",
|
||||||
aria-modal="true"
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
aria-labelledby="shift-form-modal-title"
|
gap: 2,
|
||||||
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",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{editingRecord.user_name} —{" "}
|
<Field label="Zaměstnanec" required>
|
||||||
{formatDate(editingRecord.shift_date)}
|
<Select
|
||||||
</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
|
|
||||||
value={form.user_id}
|
value={form.user_id}
|
||||||
onChange={(e) => updateField("user_id", e.target.value)}
|
onChange={(val) => updateField("user_id", val)}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "", label: "Vyberte zaměstnance" },
|
||||||
<option value="">Vyberte zaměstnance</option>
|
...users.map((user) => ({
|
||||||
{users.map((user) => (
|
value: String(user.id),
|
||||||
<option key={user.id} value={user.id}>
|
label: user.name,
|
||||||
{user.name}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
/>
|
||||||
</select>
|
</Field>
|
||||||
</div>
|
<Field label="Datum směny" required>
|
||||||
<div className="admin-form-group">
|
<DateField
|
||||||
<label className="admin-form-label required">
|
|
||||||
Datum směny
|
|
||||||
</label>
|
|
||||||
<AdminDatePicker
|
|
||||||
mode="date"
|
|
||||||
value={form.shift_date}
|
value={form.shift_date}
|
||||||
onChange={(val) => onShiftDateChange(val)}
|
onChange={(val) => onShiftDateChange(val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-form-group">
|
<Field label="Datum směny">
|
||||||
<label className="admin-form-label">Datum směny</label>
|
<DateField
|
||||||
<AdminDatePicker
|
|
||||||
mode="date"
|
|
||||||
value={form.shift_date}
|
value={form.shift_date}
|
||||||
onChange={(val) => updateField("shift_date", val)}
|
onChange={(val) => updateField("shift_date", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Field label="Typ záznamu">
|
||||||
<label className="admin-form-label">Typ záznamu</label>
|
<Select
|
||||||
<select
|
|
||||||
value={form.leave_type}
|
value={form.leave_type}
|
||||||
onChange={(e) => updateField("leave_type", e.target.value)}
|
onChange={(val) => updateField("leave_type", val)}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "work", label: "Práce" },
|
||||||
<option value="work">Práce</option>
|
{ value: "vacation", label: "Dovolená" },
|
||||||
<option value="vacation">Dovolená</option>
|
{ value: "sick", label: "Nemoc" },
|
||||||
<option value="sick">Nemoc</option>
|
{ value: "unpaid", label: "Neplacené volno" },
|
||||||
<option value="unpaid">Neplacené volno</option>
|
]}
|
||||||
</select>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
{!isWorkType && (
|
{!isWorkType && (
|
||||||
<div className="admin-form-group">
|
<Field
|
||||||
<label className="admin-form-label">Počet hodin</label>
|
label="Počet hodin"
|
||||||
<input
|
hint={isCreate ? "8 hodin = celý den" : undefined}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={form.leave_hours}
|
value={form.leave_hours}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField("leave_hours", parseFloat(e.target.value))
|
updateField("leave_hours", parseFloat(e.target.value))
|
||||||
}
|
}
|
||||||
min="0.5"
|
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||||
max="24"
|
|
||||||
step="0.5"
|
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
{isCreate && (
|
</Field>
|
||||||
<small className="admin-form-hint">
|
|
||||||
8 hodin = celý den
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isWorkType && (
|
{isWorkType && (
|
||||||
<>
|
<>
|
||||||
<div className="admin-form-row">
|
{/* Arrival */}
|
||||||
<div className="admin-form-group">
|
<Box
|
||||||
<label className="admin-form-label">
|
sx={{
|
||||||
Příchod - datum
|
display: "grid",
|
||||||
</label>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<AdminDatePicker
|
gap: 2,
|
||||||
mode="date"
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Příchod - datum">
|
||||||
|
<DateField
|
||||||
value={form.arrival_date}
|
value={form.arrival_date}
|
||||||
onChange={(val) => updateField("arrival_date", val)}
|
onChange={(val) => updateField("arrival_date", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Příchod - čas">
|
||||||
<label className="admin-form-label">
|
<TimeField
|
||||||
Příchod - čas
|
|
||||||
</label>
|
|
||||||
<AdminDatePicker
|
|
||||||
mode="time"
|
|
||||||
value={form.arrival_time}
|
value={form.arrival_time}
|
||||||
onChange={(val) => updateField("arrival_time", val)}
|
onChange={(val) => updateField("arrival_time", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
{/* Break start */}
|
||||||
<div className="admin-form-group">
|
<Box
|
||||||
<label className="admin-form-label">
|
sx={{
|
||||||
Začátek pauzy - datum
|
display: "grid",
|
||||||
</label>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<AdminDatePicker
|
gap: 2,
|
||||||
mode="date"
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Začátek pauzy - datum">
|
||||||
|
<DateField
|
||||||
value={form.break_start_date}
|
value={form.break_start_date}
|
||||||
onChange={(val) =>
|
onChange={(val) => updateField("break_start_date", val)}
|
||||||
updateField("break_start_date", val)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Začátek pauzy - čas">
|
||||||
<label className="admin-form-label">
|
<TimeField
|
||||||
Začátek pauzy - čas
|
|
||||||
</label>
|
|
||||||
<AdminDatePicker
|
|
||||||
mode="time"
|
|
||||||
value={form.break_start_time}
|
value={form.break_start_time}
|
||||||
onChange={(val) =>
|
onChange={(val) => updateField("break_start_time", val)}
|
||||||
updateField("break_start_time", val)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
{/* Break end */}
|
||||||
<div className="admin-form-group">
|
<Box
|
||||||
<label className="admin-form-label">
|
sx={{
|
||||||
Konec pauzy - datum
|
display: "grid",
|
||||||
</label>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<AdminDatePicker
|
gap: 2,
|
||||||
mode="date"
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Konec pauzy - datum">
|
||||||
|
<DateField
|
||||||
value={form.break_end_date}
|
value={form.break_end_date}
|
||||||
onChange={(val) => updateField("break_end_date", val)}
|
onChange={(val) => updateField("break_end_date", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Konec pauzy - čas">
|
||||||
<label className="admin-form-label">
|
<TimeField
|
||||||
Konec pauzy - čas
|
|
||||||
</label>
|
|
||||||
<AdminDatePicker
|
|
||||||
mode="time"
|
|
||||||
value={form.break_end_time}
|
value={form.break_end_time}
|
||||||
onChange={(val) => updateField("break_end_time", val)}
|
onChange={(val) => updateField("break_end_time", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
{/* Departure */}
|
||||||
<div className="admin-form-group">
|
<Box
|
||||||
<label className="admin-form-label">
|
sx={{
|
||||||
Odchod - datum
|
display: "grid",
|
||||||
</label>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<AdminDatePicker
|
gap: 2,
|
||||||
mode="date"
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Odchod - datum">
|
||||||
|
<DateField
|
||||||
value={form.departure_date}
|
value={form.departure_date}
|
||||||
onChange={(val) => updateField("departure_date", val)}
|
onChange={(val) => updateField("departure_date", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Odchod - čas">
|
||||||
<label className="admin-form-label">Odchod - čas</label>
|
<TimeField
|
||||||
<AdminDatePicker
|
|
||||||
mode="time"
|
|
||||||
value={form.departure_time}
|
value={form.departure_time}
|
||||||
onChange={(val) => updateField("departure_time", val)}
|
onChange={(val) => updateField("departure_time", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isWorkType && projectList.length > 0 && (
|
{isWorkType && projectList.length > 0 && (
|
||||||
<div className="admin-form-group">
|
<Field label="Projekty">
|
||||||
<label className="admin-form-label">Projekty</label>
|
|
||||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||||
{projectLogs.map((log, i) => (
|
{projectLogs.map((log, i) => (
|
||||||
<ProjectLogRow
|
<ProjectLogRow
|
||||||
@@ -476,47 +439,25 @@ export default function ShiftFormModal({
|
|||||||
onRemove={removeProjectLog}
|
onRemove={removeProjectLog}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
onClick={addProjectLog}
|
onClick={addProjectLog}
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
>
|
>
|
||||||
+ Přidat projekt
|
+ Přidat projekt
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Field label="Poznámka">
|
||||||
<label className="admin-form-label">Poznámka</label>
|
<TextField
|
||||||
<textarea
|
multiline
|
||||||
|
minRows={3}
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => updateField("notes", e.target.value)}
|
onChange={(e) => updateField("notes", e.target.value)}
|
||||||
className="admin-form-textarea"
|
|
||||||
rows={3}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Modal>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 {
|
import {
|
||||||
ENTITY_TYPE_LABELS,
|
ENTITY_TYPE_LABELS,
|
||||||
getActivityIconClass,
|
getActivityIconClass,
|
||||||
@@ -18,6 +22,16 @@ interface DashActivityFeedProps {
|
|||||||
activities: Activity[] | null;
|
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) {
|
function getActivityIcon(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "create":
|
case "create":
|
||||||
@@ -103,37 +117,95 @@ export default function DashActivityFeed({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-card dash-activity-card">
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
<div className="admin-card-header flex-between">
|
<Box
|
||||||
<h2 className="admin-card-title">Audit log</h2>
|
sx={{
|
||||||
<Link
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Audit log</Typography>
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
to="/audit-log"
|
to="/audit-log"
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
Detail →
|
Detail →
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
|
||||||
{activities.map((act) => (
|
{activities.length === 0 ? (
|
||||||
<div key={act.id} className="dash-activity-row">
|
<EmptyState title="Žádná aktivita" />
|
||||||
<div
|
) : (
|
||||||
className={`dash-activity-icon ${getActivityIconClass(act.action)}`}
|
<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)}
|
{getActivityIcon(act.action)}
|
||||||
</div>
|
</Box>
|
||||||
<div className="dash-activity-main">
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="dash-activity-text">{act.description}</div>
|
<Typography
|
||||||
<div className="dash-activity-sub">
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 500 }}
|
||||||
|
noWrap
|
||||||
|
title={act.description}
|
||||||
|
>
|
||||||
|
{act.description}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" noWrap>
|
||||||
{act.username || "Systém"} ·{" "}
|
{act.username || "Systém"} ·{" "}
|
||||||
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
|
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
<div className="dash-activity-time admin-mono">
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{formatActivityTime(act.created_at)}
|
{formatActivityTime(act.created_at)}
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import {
|
import Box from "@mui/material/Box";
|
||||||
LEAVE_TYPE_LABELS,
|
import Typography from "@mui/material/Typography";
|
||||||
STATUS_DOT_CLASS,
|
import Avatar from "@mui/material/Avatar";
|
||||||
STATUS_LABELS,
|
import { Card, Button, StatusChip, EmptyState } from "../../ui";
|
||||||
} from "../../utils/dashboardHelpers";
|
import { LEAVE_TYPE_LABELS, STATUS_LABELS } from "../../utils/dashboardHelpers";
|
||||||
|
|
||||||
interface AttendanceUser {
|
interface AttendanceUser {
|
||||||
user_id: number | string;
|
user_id: number | string;
|
||||||
@@ -22,6 +22,19 @@ interface DashAttendanceTodayProps {
|
|||||||
attendance: AttendanceData | null;
|
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({
|
export default function DashAttendanceToday({
|
||||||
attendance,
|
attendance,
|
||||||
}: DashAttendanceTodayProps) {
|
}: DashAttendanceTodayProps) {
|
||||||
@@ -30,42 +43,96 @@ export default function DashAttendanceToday({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-card dash-attendance-card">
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
<div className="admin-card-header flex-between">
|
<Box
|
||||||
<h2 className="admin-card-title">Docházka dnes</h2>
|
sx={{
|
||||||
<Link
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Docházka dnes</Typography>
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
to="/attendance/admin"
|
to="/attendance/admin"
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
Detail →
|
Detail →
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
|
||||||
{attendance.users.map((u, i) => (
|
{attendance.users.length === 0 ? (
|
||||||
<div key={`${u.user_id}-${i}`} className="dash-presence-row">
|
<EmptyState title="Žádná docházka" />
|
||||||
<div
|
) : (
|
||||||
className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}
|
<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 || "?"}
|
{u.initials || "?"}
|
||||||
</div>
|
</Avatar>
|
||||||
<div className="dash-presence-name">{u.name}</div>
|
<Typography
|
||||||
<div className="dash-presence-end">
|
variant="body2"
|
||||||
<span
|
sx={{ fontWeight: 500, flex: 1, minWidth: 0 }}
|
||||||
className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}
|
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"
|
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
|
||||||
: STATUS_LABELS[u.status]}
|
: STATUS_LABELS[u.status]
|
||||||
</span>
|
}
|
||||||
|
/>
|
||||||
{u.arrived_at && (
|
{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}
|
{u.arrived_at}
|
||||||
</span>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { motion } from "framer-motion";
|
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";
|
import { formatCurrency } from "../../utils/formatters";
|
||||||
|
|
||||||
interface KpiCard {
|
interface KpiCard {
|
||||||
@@ -108,11 +111,13 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const KPI_CLASS_MAP: Record<number, string> = {
|
// Maps the legacy KPI color tokens to the StatCard color palette
|
||||||
4: "admin-kpi-4",
|
// (danger → error; the rest are 1:1).
|
||||||
3: "admin-kpi-3",
|
const KPI_COLOR_MAP: Record<string, StatCardColor> = {
|
||||||
2: "admin-kpi-2",
|
success: "success",
|
||||||
1: "admin-kpi-1",
|
info: "info",
|
||||||
|
warning: "warning",
|
||||||
|
danger: "error",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||||
@@ -121,36 +126,50 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "admin-kpi-4";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Box
|
||||||
className={`admin-kpi-grid ${kpiClass}`}
|
component={motion.div}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
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) => (
|
{kpiCards.map((kpi) => (
|
||||||
<div key={kpi.label} className={`admin-stat-card ${kpi.color}`}>
|
<StatCard
|
||||||
<div className="admin-stat-label">{kpi.label}</div>
|
key={kpi.label}
|
||||||
<div className="admin-stat-value admin-mono">
|
color={KPI_COLOR_MAP[kpi.color] ?? "default"}
|
||||||
|
label={kpi.label}
|
||||||
|
value={
|
||||||
|
<>
|
||||||
{kpi.value}
|
{kpi.value}
|
||||||
{kpi.sub && (
|
{kpi.sub && (
|
||||||
<small
|
<Typography
|
||||||
className="text-muted"
|
component="span"
|
||||||
style={{
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
fontSize: "0.75em",
|
fontSize: "0.75em",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
marginLeft: "0.25rem",
|
ml: 0.5,
|
||||||
|
fontFamily: "inherit",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{kpi.sub}
|
{kpi.sub}
|
||||||
</small>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
|
}
|
||||||
</div>
|
footer={kpi.footer ?? undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useState, useRef } from "react";
|
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 { useAuth } from "../../context/AuthContext";
|
||||||
import { useAlert } from "../../context/AlertContext";
|
import { useAlert } from "../../context/AlertContext";
|
||||||
import useModalLock from "../../hooks/useModalLock";
|
|
||||||
import apiFetch from "../../utils/api";
|
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";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -37,6 +46,34 @@ interface ProfileFormData {
|
|||||||
last_name: string;
|
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({
|
export default function DashProfile({
|
||||||
totpEnabled,
|
totpEnabled,
|
||||||
totpLoading,
|
totpLoading,
|
||||||
@@ -61,6 +98,10 @@ export default function DashProfile({
|
|||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const totpSetupRef = useRef<HTMLInputElement>(null);
|
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 [showModal, setShowModal] = useState(false);
|
||||||
const [formData, setFormData] = useState<ProfileFormData>({
|
const [formData, setFormData] = useState<ProfileFormData>({
|
||||||
username: "",
|
username: "",
|
||||||
@@ -71,8 +112,6 @@ export default function DashProfile({
|
|||||||
last_name: "",
|
last_name: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
useModalLock(showModal);
|
|
||||||
|
|
||||||
const openEditModal = () => {
|
const openEditModal = () => {
|
||||||
const nameParts = (user?.fullName || "").split(" ");
|
const nameParts = (user?.fullName || "").split(" ");
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -135,81 +174,96 @@ export default function DashProfile({
|
|||||||
return totpEnabled ? "Aktivní" : "Neaktivní";
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.15 }}
|
transition={{ duration: 0.25, delay: 0.15 }}
|
||||||
>
|
>
|
||||||
<div className="admin-card-header flex-between">
|
<Card>
|
||||||
<h2 className="admin-card-title">Váš účet</h2>
|
<Box
|
||||||
<button
|
sx={{
|
||||||
onClick={openEditModal}
|
display: "flex",
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
alignItems: "center",
|
||||||
>
|
justifyContent: "space-between",
|
||||||
<svg
|
mb: 2,
|
||||||
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",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-between">
|
<Typography variant="h6">Váš účet</Typography>
|
||||||
<div className="flex-row-gap">
|
<Button
|
||||||
<div
|
onClick={openEditModal}
|
||||||
style={{
|
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,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
background: totpEnabled
|
flexShrink: 0,
|
||||||
? "var(--success-light)"
|
},
|
||||||
: "rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)",
|
iconBadgeSx(totpEnabled ? "success" : "default"),
|
||||||
color: totpEnabled
|
]}
|
||||||
? "var(--success)"
|
|
||||||
: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="18"
|
width="18"
|
||||||
@@ -222,217 +276,164 @@ export default function DashProfile({
|
|||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</Box>
|
||||||
<div>
|
<Box>
|
||||||
<div style={{ fontWeight: 500, fontSize: "0.875rem" }}>
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
Dvoufaktorové ověření (2FA)
|
Dvoufaktorové ověření (2FA)
|
||||||
</div>
|
</Typography>
|
||||||
<div
|
<Typography
|
||||||
className={totpEnabled ? "text-success" : "text-secondary"}
|
variant="caption"
|
||||||
style={{ fontSize: "0.75rem" }}
|
sx={{
|
||||||
|
color: totpEnabled ? "success.main" : "text.secondary",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{getTotpStatusText()}
|
{getTotpStatusText()}
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
{!totpLoading &&
|
{!totpLoading &&
|
||||||
(totpEnabled ? (
|
(totpEnabled ? (
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableCode("");
|
setDisableCode("");
|
||||||
setShow2FADisable(true);
|
setShow2FADisable(true);
|
||||||
}}
|
}}
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
Deaktivovat
|
Deaktivovat
|
||||||
</button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button
|
||||||
onClick={onStart2FASetup}
|
onClick={onStart2FASetup}
|
||||||
disabled={totpSubmitting}
|
disabled={totpSubmitting}
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
{totpSubmitting ? "Generuji..." : "Aktivovat"}
|
{totpSubmitting ? "Generuji..." : "Aktivovat"}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Edit Profile Modal */}
|
{/* Edit Profile Modal */}
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{showModal && (
|
isOpen={showModal}
|
||||||
<motion.div
|
onClose={() => setShowModal(false)}
|
||||||
className="admin-modal-overlay"
|
title="Upravit profil"
|
||||||
initial={{ opacity: 0 }}
|
maxWidth="sm"
|
||||||
animate={{ opacity: 1 }}
|
onSubmit={handleSubmit}
|
||||||
exit={{ opacity: 0 }}
|
submitText="Uložit změny"
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
>
|
||||||
<div
|
<Box
|
||||||
className="admin-modal-backdrop"
|
sx={{
|
||||||
onClick={() => setShowModal(false)}
|
display: "grid",
|
||||||
/>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<motion.div
|
gap: 2,
|
||||||
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">
|
<Field label="Jméno">
|
||||||
<h2 className="admin-modal-title">Upravit profil</h2>
|
<TextField
|
||||||
</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"
|
|
||||||
value={formData.first_name}
|
value={formData.first_name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({ ...formData, first_name: e.target.value })
|
||||||
...formData,
|
|
||||||
first_name: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Příjmení">
|
||||||
<label className="admin-form-label">Příjmení</label>
|
<TextField
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.last_name}
|
value={formData.last_name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({ ...formData, last_name: e.target.value })
|
||||||
...formData,
|
|
||||||
last_name: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-form-group">
|
<Field label="Uživatelské jméno">
|
||||||
<label className="admin-form-label">
|
<TextField
|
||||||
Uživatelské jméno
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.username}
|
value={formData.username}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, username: e.target.value })
|
setFormData({ ...formData, username: e.target.value })
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="E-mail">
|
||||||
<label className="admin-form-label">E-mail</label>
|
<TextField
|
||||||
<input
|
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, email: e.target.value })
|
setFormData({ ...formData, email: e.target.value })
|
||||||
}
|
}
|
||||||
required
|
required
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Aktuální heslo">
|
||||||
<label className="admin-form-label">Aktuální heslo</label>
|
<TextField
|
||||||
<input
|
|
||||||
type="password"
|
type="password"
|
||||||
value={formData.current_password}
|
value={formData.current_password}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({ ...formData, current_password: e.target.value })
|
||||||
...formData,
|
|
||||||
current_password: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Pro změnu hesla, zadejte aktuální heslo"
|
placeholder="Pro změnu hesla, zadejte aktuální heslo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Nové heslo">
|
||||||
<label className="admin-form-label">
|
<TextField
|
||||||
Nové heslo
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
type="password"
|
||||||
value={formData.new_password}
|
value={formData.new_password}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({ ...formData, new_password: e.target.value })
|
||||||
...formData,
|
|
||||||
new_password: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Pro změnu hesla, zadejte nové heslo"
|
placeholder="Pro změnu hesla, zadejte nové heslo"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Modal>
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* 2FA Setup Modal */}
|
{/* 2FA Setup Dialog — bespoke, multi-step: setup → backup codes.
|
||||||
<AnimatePresence>
|
On the backup-codes step the footer + close are hidden and
|
||||||
{show2FASetup && (
|
backdrop/ESC are locked; the single deliberate exit lives in the body
|
||||||
<motion.div
|
and also clears the codes (so unsaved codes can't be silently lost). */}
|
||||||
className="admin-modal-overlay"
|
<Dialog
|
||||||
initial={{ opacity: 0 }}
|
open={show2FASetup}
|
||||||
animate={{ opacity: 1 }}
|
onClose={
|
||||||
exit={{ opacity: 0 }}
|
backupCodes
|
||||||
transition={{ duration: 0.2 }}
|
? undefined
|
||||||
>
|
: () => {
|
||||||
<div
|
|
||||||
className="admin-modal-backdrop"
|
|
||||||
onClick={() => {
|
|
||||||
if (!backupCodes) {
|
|
||||||
setShow2FASetup(false);
|
setShow2FASetup(false);
|
||||||
|
setTotpCode("");
|
||||||
}
|
}
|
||||||
}}
|
}
|
||||||
/>
|
fullWidth
|
||||||
<motion.div
|
maxWidth="sm"
|
||||||
className="admin-modal"
|
disableScrollLock
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
slotProps={{ paper: { sx: { borderRadius: 3 } } }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
>
|
>
|
||||||
<div className="admin-modal-header">
|
<DialogTitle>
|
||||||
<h2 className="admin-modal-title">
|
|
||||||
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
|
{backupCodes ? "Záložní kódy" : "Nastavení 2FA"}
|
||||||
</h2>
|
</DialogTitle>
|
||||||
</div>
|
<DialogContent>
|
||||||
<div className="admin-modal-body">
|
|
||||||
{backupCodes ? (
|
{backupCodes ? (
|
||||||
<div>
|
<Box>
|
||||||
<div className="admin-role-locked-notice mb-4">
|
<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
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
height="16"
|
height="16"
|
||||||
@@ -445,169 +446,134 @@ export default function DashProfile({
|
|||||||
<line x1="12" y1="9" x2="12" y2="13" />
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
Uložte si tyto kódy na bezpečné místo. Každý kód lze
|
</Box>
|
||||||
použít pouze jednou. Po zavření tohoto okna je již
|
<span>
|
||||||
neuvidíte.
|
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít
|
||||||
</div>
|
pouze jednou. Po zavření tohoto okna je již neuvidíte.
|
||||||
<div
|
</span>
|
||||||
style={{
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(2, 1fr)",
|
gridTemplateColumns: "repeat(2, 1fr)",
|
||||||
gap: "0.5rem",
|
gap: 1,
|
||||||
padding: "1rem",
|
p: 2,
|
||||||
background: "var(--bg-secondary)",
|
bgcolor: "action.hover",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: 2,
|
||||||
fontFamily: "monospace",
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
fontSize: "1rem",
|
fontSize: "1rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{backupCodes.map((code) => (
|
{backupCodes.map((code) => (
|
||||||
<div
|
<Box
|
||||||
key={code}
|
key={code}
|
||||||
style={{
|
sx={{
|
||||||
padding: "0.25rem 0.5rem",
|
px: 1,
|
||||||
|
py: 0.5,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
color: "var(--text-primary)",
|
color: "text.primary",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{code}
|
{code}
|
||||||
</div>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
<div style={{ marginTop: "0.75rem" }}>
|
<Box sx={{ mt: 1.5 }}>
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard?.writeText(
|
navigator.clipboard?.writeText(backupCodes.join("\n"));
|
||||||
backupCodes.join("\n"),
|
|
||||||
);
|
|
||||||
alert.success("Kódy zkopírovány");
|
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
|
Kopírovat kódy
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<Box>
|
||||||
<p
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
className="text-secondary"
|
Naskenujte QR kód v autentizační aplikaci (Google Authenticator,
|
||||||
style={{ fontSize: "0.875rem", marginBottom: "1rem" }}
|
Authy, Microsoft Authenticator apod.)
|
||||||
>
|
</Typography>
|
||||||
Naskenujte QR kód v autentizační aplikaci (Google
|
|
||||||
Authenticator, Authy, Microsoft Authenticator apod.)
|
|
||||||
</p>
|
|
||||||
{totpQrUri && (
|
{totpQrUri && (
|
||||||
<div
|
<Box sx={{ textAlign: "center", mb: 2 }}>
|
||||||
style={{ textAlign: "center", marginBottom: "1rem" }}
|
<Box
|
||||||
>
|
component="img"
|
||||||
<img
|
|
||||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(totpQrUri)}`}
|
||||||
alt="TOTP QR Code"
|
alt="TOTP QR Code"
|
||||||
style={{
|
sx={{
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 200,
|
height: 200,
|
||||||
borderRadius: "0.5rem",
|
borderRadius: 2,
|
||||||
border: "1px solid var(--border-color)",
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{totpSecret && (
|
{totpSecret && (
|
||||||
<div className="mb-4">
|
<Box sx={{ mb: 2 }}>
|
||||||
<label
|
<Typography
|
||||||
className="admin-form-label"
|
component="label"
|
||||||
style={{ fontSize: "0.75rem" }}
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ display: "block", mb: 0.5 }}
|
||||||
>
|
>
|
||||||
Nebo zadejte klíč ručně:
|
Nebo zadejte klíč ručně:
|
||||||
</label>
|
</Typography>
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
padding: "0.5rem 0.75rem",
|
|
||||||
background: "var(--bg-secondary)",
|
|
||||||
borderRadius: "0.375rem",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
wordBreak: "break-all",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
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>
|
<span>{totpSecret}</span>
|
||||||
<button
|
<IconButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard?.writeText(totpSecret);
|
navigator.clipboard?.writeText(totpSecret);
|
||||||
alert.success("Klíč zkopírován");
|
alert.success("Klíč zkopírován");
|
||||||
}}
|
}}
|
||||||
className="admin-btn-icon"
|
size="small"
|
||||||
title="Kopírovat"
|
title="Kopírovat"
|
||||||
aria-label="Kopírovat"
|
aria-label="Kopírovat"
|
||||||
style={{ flexShrink: 0 }}
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<svg
|
{CopyIcon}
|
||||||
width="14"
|
</IconButton>
|
||||||
height="14"
|
</Box>
|
||||||
viewBox="0 0 24 24"
|
</Box>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
<div className="admin-form-group">
|
<Field label="Ověřovací kód z aplikace">
|
||||||
<label className="admin-form-label">
|
<TextField
|
||||||
Ověřovací kód z aplikace
|
inputRef={totpSetupRef}
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={totpSetupRef}
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
|
||||||
maxLength={6}
|
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setTotpCode(e.target.value.replace(/\D/g, ""))
|
setTotpCode(e.target.value.replace(/\D/g, ""))
|
||||||
}
|
}
|
||||||
placeholder="000000"
|
placeholder="000000"
|
||||||
className="admin-form-input"
|
slotProps={{ htmlInput: { maxLength: 6, pattern: "[0-9]*" } }}
|
||||||
style={{
|
sx={{
|
||||||
|
"& input": {
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
fontSize: "1.25rem",
|
fontSize: "1.25rem",
|
||||||
letterSpacing: "0.4rem",
|
letterSpacing: "0.4rem",
|
||||||
fontFamily: "monospace",
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && totpCode.length === 6) {
|
if (e.key === "Enter" && totpCode.length === 6) {
|
||||||
@@ -615,128 +581,86 @@ export default function DashProfile({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</DialogContent>
|
||||||
<div className="admin-modal-footer">
|
{/* 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 ? (
|
{backupCodes ? (
|
||||||
<button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShow2FASetup(false);
|
setShow2FASetup(false);
|
||||||
setBackupCodes(null);
|
setBackupCodes(null);
|
||||||
}}
|
}}
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
>
|
||||||
Rozumím, uložil jsem si kódy
|
Rozumím, uložil jsem si kódy
|
||||||
</button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
onClick={() => setShow2FASetup(false)}
|
onClick={() => {
|
||||||
className="admin-btn admin-btn-secondary"
|
setShow2FASetup(false);
|
||||||
|
setTotpCode("");
|
||||||
|
}}
|
||||||
|
variant="text"
|
||||||
|
color="inherit"
|
||||||
disabled={totpSubmitting}
|
disabled={totpSubmitting}
|
||||||
>
|
>
|
||||||
Zrušit
|
Zrušit
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={onConfirm2FA}
|
onClick={onConfirm2FA}
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
disabled={totpSubmitting || totpCode.length !== 6}
|
disabled={totpSubmitting || totpCode.length !== 6}
|
||||||
>
|
>
|
||||||
{totpSubmitting ? "Ověřuji..." : "Aktivovat 2FA"}
|
{totpSubmitting ? "Ukládám…" : "Aktivovat 2FA"}
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</DialogActions>
|
||||||
</motion.div>
|
</Dialog>
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* 2FA Disable Modal */}
|
{/* 2FA Disable Modal */}
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{show2FADisable && (
|
isOpen={show2FADisable}
|
||||||
<motion.div
|
onClose={() => setShow2FADisable(false)}
|
||||||
className="admin-modal-overlay"
|
title="Deaktivovat 2FA"
|
||||||
initial={{ opacity: 0 }}
|
maxWidth="sm"
|
||||||
animate={{ opacity: 1 }}
|
loading={totpSubmitting}
|
||||||
exit={{ opacity: 0 }}
|
onSubmit={onDisable2FA}
|
||||||
transition={{ duration: 0.2 }}
|
submitDisabled={disableCode.length !== 6}
|
||||||
>
|
submitText="Deaktivovat 2FA"
|
||||||
<div
|
cancelText="Zrušit"
|
||||||
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",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
|
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z
|
||||||
autentizační aplikace.
|
autentizační aplikace.
|
||||||
</p>
|
</Typography>
|
||||||
<div className="admin-form-group">
|
<Field label="Ověřovací kód">
|
||||||
<label className="admin-form-label">Ověřovací kód</label>
|
<TextField
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
|
||||||
maxLength={6}
|
|
||||||
value={disableCode}
|
value={disableCode}
|
||||||
onChange={(e) =>
|
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ""))}
|
||||||
setDisableCode(e.target.value.replace(/\D/g, ""))
|
|
||||||
}
|
|
||||||
placeholder="000000"
|
placeholder="000000"
|
||||||
className="admin-form-input"
|
autoFocus
|
||||||
style={{
|
slotProps={{ htmlInput: { maxLength: 6, pattern: "[0-9]*" } }}
|
||||||
|
sx={{
|
||||||
|
"& input": {
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
fontSize: "1.25rem",
|
fontSize: "1.25rem",
|
||||||
letterSpacing: "0.4rem",
|
letterSpacing: "0.4rem",
|
||||||
fontFamily: "monospace",
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && disableCode.length === 6) {
|
if (e.key === "Enter" && disableCode.length === 6) {
|
||||||
onDisable2FA();
|
onDisable2FA();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
autoFocus
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Modal>
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
import { useAuth } from "../../context/AuthContext";
|
import { useAuth } from "../../context/AuthContext";
|
||||||
import { useAlert } from "../../context/AlertContext";
|
import { useAlert } from "../../context/AlertContext";
|
||||||
import { formatKm, todayLocalStr } from "../../utils/formatters";
|
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 apiFetch from "../../utils/api";
|
||||||
import useModalLock from "../../hooks/useModalLock";
|
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -46,6 +46,14 @@ interface DashQuickActionsProps {
|
|||||||
onPunch: () => void;
|
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({
|
export default function DashQuickActions({
|
||||||
dashData,
|
dashData,
|
||||||
punching,
|
punching,
|
||||||
@@ -69,8 +77,6 @@ export default function DashQuickActions({
|
|||||||
});
|
});
|
||||||
const [tripErrors, setTripErrors] = useState<TripErrors>({});
|
const [tripErrors, setTripErrors] = useState<TripErrors>({});
|
||||||
|
|
||||||
useModalLock(showTripModal);
|
|
||||||
|
|
||||||
const openTripModal = async () => {
|
const openTripModal = async () => {
|
||||||
setTripForm({
|
setTripForm({
|
||||||
vehicle_id: "",
|
vehicle_id: "",
|
||||||
@@ -286,303 +292,192 @@ export default function DashQuickActions({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<Box
|
||||||
className="dash-quick-actions"
|
component={motion.div}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
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) =>
|
{quickActions.map((action) => {
|
||||||
action.onClick ? (
|
const color = ACTION_COLOR[action.color] ?? "primary";
|
||||||
<button
|
const common = {
|
||||||
|
variant: "contained" as const,
|
||||||
|
color,
|
||||||
|
startIcon: action.icon,
|
||||||
|
sx: { py: 1.5, justifyContent: "flex-start" },
|
||||||
|
};
|
||||||
|
return action.onClick ? (
|
||||||
|
<Button
|
||||||
key={action.label}
|
key={action.label}
|
||||||
onClick={action.onClick}
|
onClick={action.onClick}
|
||||||
disabled={action.disabled}
|
disabled={action.disabled}
|
||||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
{...common}
|
||||||
>
|
>
|
||||||
{action.icon}
|
{action.label}
|
||||||
<span>{action.label}</span>
|
</Button>
|
||||||
</button>
|
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Button
|
||||||
key={action.label}
|
key={action.label}
|
||||||
|
component={RouterLink}
|
||||||
to={action.path!}
|
to={action.path!}
|
||||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
{...common}
|
||||||
>
|
>
|
||||||
{action.icon}
|
{action.label}
|
||||||
<span>{action.label}</span>
|
</Button>
|
||||||
</Link>
|
);
|
||||||
),
|
})}
|
||||||
)}
|
</Box>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{showTripModal && (
|
isOpen={showTripModal}
|
||||||
<motion.div
|
onClose={() => setShowTripModal(false)}
|
||||||
className="admin-modal-overlay"
|
title="Přidat jízdu"
|
||||||
initial={{ opacity: 0 }}
|
maxWidth="md"
|
||||||
animate={{ opacity: 1 }}
|
onSubmit={handleTripSubmit}
|
||||||
exit={{ opacity: 0 }}
|
submitText="Uložit"
|
||||||
transition={{ duration: 0.2 }}
|
loading={tripSubmitting}
|
||||||
>
|
>
|
||||||
<div
|
<Box
|
||||||
className="admin-modal-backdrop"
|
sx={{
|
||||||
onClick={() => setShowTripModal(false)}
|
display: "grid",
|
||||||
/>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<motion.div
|
gap: 2,
|
||||||
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 }}
|
|
||||||
>
|
>
|
||||||
<div className="admin-modal-header">
|
<Field label="Vozidlo" required error={tripErrors.vehicle_id}>
|
||||||
<h2 className="admin-modal-title">Přidat jízdu</h2>
|
<Select
|
||||||
</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
|
|
||||||
value={tripForm.vehicle_id}
|
value={tripForm.vehicle_id}
|
||||||
onChange={(e) => {
|
onChange={(val) => {
|
||||||
handleTripVehicleChange(e.target.value);
|
handleTripVehicleChange(val);
|
||||||
setTripErrors((prev) => ({
|
setTripErrors((prev) => ({ ...prev, vehicle_id: undefined }));
|
||||||
...prev,
|
|
||||||
vehicle_id: undefined,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "", label: "Vyberte vozidlo" },
|
||||||
<option value="">Vyberte vozidlo</option>
|
...tripVehicles.map((v) => ({
|
||||||
{tripVehicles.map((v) => (
|
value: String(v.id),
|
||||||
<option key={v.id} value={v.id}>
|
label: `${v.spz} - ${v.name}`,
|
||||||
{v.spz} - {v.name}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
/>
|
||||||
</select>
|
</Field>
|
||||||
{tripErrors.vehicle_id && (
|
<Field label="Datum jízdy" required error={tripErrors.trip_date}>
|
||||||
<span className="admin-form-error">
|
<DateField
|
||||||
{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"
|
|
||||||
value={tripForm.trip_date}
|
value={tripForm.trip_date}
|
||||||
onChange={(val: string) => {
|
onChange={(val) => {
|
||||||
setTripForm((prev) => ({ ...prev, trip_date: val }));
|
setTripForm((prev) => ({ ...prev, trip_date: val }));
|
||||||
setTripErrors((prev) => ({
|
setTripErrors((prev) => ({ ...prev, trip_date: undefined }));
|
||||||
...prev,
|
|
||||||
trip_date: undefined,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{tripErrors.trip_date && (
|
</Field>
|
||||||
<span className="admin-form-error">
|
</Box>
|
||||||
{tripErrors.trip_date}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-form-row admin-form-row-3">
|
<Box
|
||||||
<div
|
sx={{
|
||||||
className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`}
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<label className="admin-form-label required">
|
<Field label="Počáteční stav km" required error={tripErrors.start_km}>
|
||||||
Počáteční stav km
|
<TextField
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={tripForm.start_km}
|
value={tripForm.start_km}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTripForm((prev) => ({
|
setTripForm((prev) => ({ ...prev, start_km: e.target.value }));
|
||||||
...prev,
|
setTripErrors((prev) => ({ ...prev, start_km: undefined }));
|
||||||
start_km: e.target.value,
|
|
||||||
}));
|
|
||||||
setTripErrors((prev) => ({
|
|
||||||
...prev,
|
|
||||||
start_km: undefined,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
{tripErrors.start_km && (
|
</Field>
|
||||||
<span className="admin-form-error">
|
<Field label="Konečný stav km" required error={tripErrors.end_km}>
|
||||||
{tripErrors.start_km}
|
<TextField
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`}
|
|
||||||
>
|
|
||||||
<label className="admin-form-label required">
|
|
||||||
Konečný stav km
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={tripForm.end_km}
|
value={tripForm.end_km}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTripForm((prev) => ({
|
setTripForm((prev) => ({ ...prev, end_km: e.target.value }));
|
||||||
...prev,
|
setTripErrors((prev) => ({ ...prev, end_km: undefined }));
|
||||||
end_km: e.target.value,
|
|
||||||
}));
|
|
||||||
setTripErrors((prev) => ({
|
|
||||||
...prev,
|
|
||||||
end_km: undefined,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
{tripErrors.end_km && (
|
</Field>
|
||||||
<span className="admin-form-error">
|
<Field label="Vzdálenost">
|
||||||
{tripErrors.end_km}
|
<TextField
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="admin-form-group">
|
|
||||||
<label className="admin-form-label">Vzdálenost</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={`${formatKm(tripDistance())} km`}
|
value={`${formatKm(tripDistance())} km`}
|
||||||
className="admin-form-input"
|
InputProps={{ readOnly: true }}
|
||||||
readOnly
|
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<div
|
sx={{
|
||||||
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<label className="admin-form-label required">
|
<Field label="Místo odjezdu" required error={tripErrors.route_from}>
|
||||||
Místo odjezdu
|
<TextField
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tripForm.route_from}
|
value={tripForm.route_from}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTripForm((prev) => ({
|
setTripForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
route_from: e.target.value,
|
route_from: e.target.value,
|
||||||
}));
|
}));
|
||||||
setTripErrors((prev) => ({
|
setTripErrors((prev) => ({ ...prev, route_from: undefined }));
|
||||||
...prev,
|
|
||||||
route_from: undefined,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Např. Praha"
|
placeholder="Např. Praha"
|
||||||
/>
|
/>
|
||||||
{tripErrors.route_from && (
|
</Field>
|
||||||
<span className="admin-form-error">
|
<Field label="Místo příjezdu" required error={tripErrors.route_to}>
|
||||||
{tripErrors.route_from}
|
<TextField
|
||||||
</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"
|
|
||||||
value={tripForm.route_to}
|
value={tripForm.route_to}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTripForm((prev) => ({
|
setTripForm((prev) => ({ ...prev, route_to: e.target.value }));
|
||||||
...prev,
|
setTripErrors((prev) => ({ ...prev, route_to: undefined }));
|
||||||
route_to: e.target.value,
|
|
||||||
}));
|
|
||||||
setTripErrors((prev) => ({
|
|
||||||
...prev,
|
|
||||||
route_to: undefined,
|
|
||||||
}));
|
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Např. Brno"
|
placeholder="Např. Brno"
|
||||||
/>
|
/>
|
||||||
{tripErrors.route_to && (
|
</Field>
|
||||||
<span className="admin-form-error">
|
</Box>
|
||||||
{tripErrors.route_to}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Field label="Typ jízdy">
|
||||||
<label className="admin-form-label">Typ jízdy</label>
|
<Select
|
||||||
<select
|
value={String(tripForm.is_business)}
|
||||||
value={tripForm.is_business}
|
onChange={(val) =>
|
||||||
onChange={(e) =>
|
setTripForm((prev) => ({ ...prev, is_business: parseInt(val) }))
|
||||||
setTripForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
is_business: parseInt(e.target.value),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "1", label: "Služební" },
|
||||||
<option value={1}>Služební</option>
|
{ value: "0", label: "Soukromá" },
|
||||||
<option value={0}>Soukromá</option>
|
]}
|
||||||
</select>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Field label="Poznámky">
|
||||||
<label className="admin-form-label">Poznámky</label>
|
<TextField
|
||||||
<textarea
|
multiline
|
||||||
|
minRows={2}
|
||||||
value={tripForm.notes}
|
value={tripForm.notes}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setTripForm((prev) => ({
|
setTripForm((prev) => ({ ...prev, notes: e.target.value }))
|
||||||
...prev,
|
|
||||||
notes: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
className="admin-form-textarea"
|
|
||||||
rows={2}
|
|
||||||
placeholder="Volitelné poznámky..."
|
placeholder="Volitelné poznámky..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Modal>
|
||||||
</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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { motion } 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 CircularProgress from "@mui/material/CircularProgress";
|
||||||
import { useAlert } from "../../context/AlertContext";
|
import { useAlert } from "../../context/AlertContext";
|
||||||
import ConfirmModal from "../ConfirmModal";
|
import { Card, Button, StatusChip, ConfirmDialog } from "../../ui";
|
||||||
import useModalLock from "../../hooks/useModalLock";
|
|
||||||
import apiFetch from "../../utils/api";
|
import apiFetch from "../../utils/api";
|
||||||
import { formatSessionDate } from "../../utils/dashboardHelpers";
|
import { formatSessionDate } from "../../utils/dashboardHelpers";
|
||||||
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
|
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
|
||||||
@@ -76,8 +79,6 @@ export default function DashSessions() {
|
|||||||
const [deleteAllModal, setDeleteAllModal] = useState(false);
|
const [deleteAllModal, setDeleteAllModal] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
useModalLock(deleteAllModal);
|
|
||||||
|
|
||||||
const handleDeleteSession = async () => {
|
const handleDeleteSession = async () => {
|
||||||
if (!deleteModal.session) {
|
if (!deleteModal.session) {
|
||||||
return;
|
return;
|
||||||
@@ -127,85 +128,114 @@ export default function DashSessions() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.15 }}
|
transition={{ duration: 0.25, delay: 0.15 }}
|
||||||
>
|
>
|
||||||
<div
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
className="admin-card-header"
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
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 && (
|
{sessions.filter((s) => !s.is_current).length > 0 && (
|
||||||
<button
|
<Button
|
||||||
onClick={() => setDeleteAllModal(true)}
|
onClick={() => setDeleteAllModal(true)}
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
Odhlásit ostatní
|
Odhlásit ostatní
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
|
||||||
{sessionsLoading ? (
|
{sessionsLoading ? (
|
||||||
<div className="admin-loading">
|
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
|
||||||
<div className="admin-spinner" />
|
<CircularProgress size={28} />
|
||||||
</div>
|
</Box>
|
||||||
) : (
|
) : sessions.length === 0 ? (
|
||||||
<>
|
<Typography
|
||||||
{sessions.length === 0 && (
|
variant="body2"
|
||||||
<div
|
color="text.secondary"
|
||||||
className="text-secondary"
|
sx={{ textAlign: "center", py: 3 }}
|
||||||
style={{
|
|
||||||
padding: "1.5rem",
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Žádné aktivní relace
|
Žádné aktivní relace
|
||||||
</div>
|
</Typography>
|
||||||
)}
|
) : (
|
||||||
{sessions.length > 0 && (
|
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
<div className="dash-sessions-list">
|
|
||||||
{sessions.map((session) => (
|
{sessions.map((session) => (
|
||||||
<div
|
<Box
|
||||||
key={session.id}
|
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)}
|
{getDeviceIcon(session.device_info?.icon)}
|
||||||
</div>
|
</Box>
|
||||||
<div className="dash-session-info">
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="dash-session-device">
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||||
{session.device_info?.browser} na{" "}
|
{session.device_info?.browser} na{" "}
|
||||||
{session.device_info?.os}
|
{session.device_info?.os}
|
||||||
|
</Typography>
|
||||||
{session.is_current && (
|
{session.is_current && (
|
||||||
<span
|
<StatusChip color="success" label="Aktuální" />
|
||||||
className="admin-badge admin-badge-success"
|
|
||||||
style={{ marginLeft: "0.5rem" }}
|
|
||||||
>
|
|
||||||
Aktuální
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
<div className="dash-session-meta">
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.75,
|
||||||
|
color: "text.secondary",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span>{session.ip_address}</span>
|
<span>{session.ip_address}</span>
|
||||||
<span className="dash-session-meta-separator">|</span>
|
<span>|</span>
|
||||||
<span>{formatSessionDate(session.created_at)}</span>
|
<span>{formatSessionDate(session.created_at)}</span>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
<div className="dash-session-actions">
|
<Box sx={{ flexShrink: 0 }}>
|
||||||
{!session.is_current && (
|
{!session.is_current && (
|
||||||
<button
|
<IconButton
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setDeleteModal({ isOpen: true, session })
|
setDeleteModal({ isOpen: true, session })
|
||||||
}
|
}
|
||||||
className="admin-btn-icon danger"
|
color="error"
|
||||||
|
size="small"
|
||||||
title="Ukončit relaci"
|
title="Ukončit relaci"
|
||||||
aria-label="Ukončit relaci"
|
aria-label="Ukončit relaci"
|
||||||
>
|
>
|
||||||
@@ -221,19 +251,17 @@ export default function DashSessions() {
|
|||||||
<polyline points="16 17 21 12 16 7" />
|
<polyline points="16 17 21 12 16 7" />
|
||||||
<line x1="21" y1="12" x2="9" y2="12" />
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</Card>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteModal.isOpen}
|
isOpen={deleteModal.isOpen}
|
||||||
onClose={() => setDeleteModal({ isOpen: false, session: null })}
|
onClose={() => setDeleteModal({ isOpen: false, session: null })}
|
||||||
onConfirm={handleDeleteSession}
|
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.`}
|
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"
|
confirmText="Ukončit"
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
type="danger"
|
confirmVariant="danger"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteAllModal}
|
isOpen={deleteAllModal}
|
||||||
onClose={() => setDeleteAllModal(false)}
|
onClose={() => setDeleteAllModal(false)}
|
||||||
onConfirm={handleDeleteAllSessions}
|
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."
|
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
|
||||||
confirmText="Odhlásit vše"
|
confirmText="Odhlásit vše"
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
type="warning"
|
confirmVariant="primary"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
114
src/admin/components/dashboard/DashTodayPlan.tsx
Normal file
114
src/admin/components/dashboard/DashTodayPlan.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} Kč
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useState, useRef, useEffect, useCallback, useId } from "react";
|
import { useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 {
|
interface ItemPickerProps {
|
||||||
value: number | null;
|
value: number | null;
|
||||||
@@ -9,220 +16,88 @@ interface ItemPickerProps {
|
|||||||
itemName?: string;
|
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({
|
export default function ItemPicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
itemName,
|
itemName,
|
||||||
}: ItemPickerProps) {
|
}: ItemPickerProps) {
|
||||||
const [search, setSearch] = useState(itemName ?? "");
|
const [inputValue, setInputValue] = useState(itemName ?? "");
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
|
|
||||||
const items = data?.data ?? [];
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const { data, isFetching } = useQuery(
|
||||||
const activeIndexRef = useRef<number>(-1);
|
warehouseItemListOptions({ search: inputValue, perPage: 20 }),
|
||||||
const listboxId = useId();
|
);
|
||||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
const options: ItemOption[] = data?.data ?? [];
|
||||||
|
|
||||||
const [dropdownStyle, setDropdownStyle] = useState<{
|
// Show the selected value even when it isn't in the current search results
|
||||||
position: "fixed";
|
// (e.g. prefilled via `itemName`) by synthesizing a fallback option.
|
||||||
top: number;
|
const selectedOption: ItemOption | null =
|
||||||
left: number;
|
value == null
|
||||||
width: number;
|
? null
|
||||||
zIndex: number;
|
: (options.find((o) => o.id === value) ?? {
|
||||||
} | null>(null);
|
id: value,
|
||||||
|
name: itemName ?? "",
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
<div
|
<Autocomplete<ItemOption>
|
||||||
ref={containerRef}
|
value={selectedOption}
|
||||||
style={{ position: "relative" }}
|
inputValue={inputValue}
|
||||||
role="combobox"
|
onInputChange={(_, newInput) => setInputValue(newInput)}
|
||||||
aria-haspopup="listbox"
|
onChange={(_, opt) => {
|
||||||
aria-expanded={open}
|
if (opt) onChange(opt.id);
|
||||||
aria-owns={listboxId}
|
}}
|
||||||
>
|
options={options}
|
||||||
<input
|
loading={isFetching}
|
||||||
type="text"
|
filterOptions={(x) => x}
|
||||||
className="admin-form-input"
|
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..."
|
placeholder="Hledat položku..."
|
||||||
value={search}
|
slotProps={{
|
||||||
role="searchbox"
|
input: {
|
||||||
aria-autocomplete="list"
|
...params.InputProps,
|
||||||
aria-controls={listboxId}
|
endAdornment: (
|
||||||
aria-activedescendant={activeId}
|
<>
|
||||||
onChange={(e) => {
|
{isFetching ? (
|
||||||
setSearch(e.target.value);
|
<CircularProgress color="inherit" size={18} />
|
||||||
setOpen(true);
|
) : 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import { Select } from "../../ui";
|
||||||
import { warehouseReservationListOptions } from "../../lib/queries/warehouse";
|
import { warehouseReservationListOptions } from "../../lib/queries/warehouse";
|
||||||
|
|
||||||
interface ReservationPickerProps {
|
interface ReservationPickerProps {
|
||||||
@@ -26,11 +28,9 @@ export default function ReservationPicker({
|
|||||||
const reservations = result?.data ?? [];
|
const reservations = result?.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<Select
|
||||||
className="admin-form-select"
|
value={value == null ? "" : String(value)}
|
||||||
value={value ?? ""}
|
onChange={(val) => {
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
if (!val) {
|
if (!val) {
|
||||||
onChange(null, 0);
|
onChange(null, 0);
|
||||||
return;
|
return;
|
||||||
@@ -42,13 +42,13 @@ export default function ReservationPicker({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="">— žádná rezervace —</option>
|
<MenuItem value="">— žádná rezervace —</MenuItem>
|
||||||
{reservations.map((r) => (
|
{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á:{" "}
|
R{r.id} — {r.project?.name ?? `Projekt #${r.project_id}`} — zbývá:{" "}
|
||||||
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
|
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
|
||||||
</option>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,11 @@ export type ViewMode = "week" | "month";
|
|||||||
export type ModalMode =
|
export type ModalMode =
|
||||||
| { kind: "closed" }
|
| { kind: "closed" }
|
||||||
| { kind: "create"; userId: number; date: string }
|
| { kind: "create"; userId: number; date: string }
|
||||||
|
| { kind: "create-override"; userId: number; date: string }
|
||||||
| { kind: "edit-range"; entryId: number; userId: number; date: string }
|
| { kind: "edit-range"; entryId: number; userId: number; date: string }
|
||||||
| { kind: "edit-override"; overrideId: 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-in-range"; userId: number; date: string; entryId: number }
|
||||||
|
| { kind: "day"; userId: number; date: string }
|
||||||
| { kind: "view"; userId: number; date: string };
|
| { kind: "view"; userId: number; date: string };
|
||||||
|
|
||||||
function isoDate(d: Date): string {
|
function isoDate(d: Date): string {
|
||||||
@@ -150,16 +152,16 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
key: readonly unknown[],
|
key: readonly unknown[],
|
||||||
userId: number,
|
userId: number,
|
||||||
dates: string[],
|
dates: string[],
|
||||||
mutator: (prev: ResolvedCell | null) => ResolvedCell | null,
|
mutator: (prev: ResolvedCell[]) => ResolvedCell[],
|
||||||
): Record<string, ResolvedCell | null> | null {
|
): Record<string, ResolvedCell[]> | null {
|
||||||
const prev = snapshotGrid(key);
|
const prev = snapshotGrid(key);
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
const userPrev = prev.cells[userId] ?? {};
|
const userPrev = prev.cells[userId] ?? {};
|
||||||
const rolled: Record<string, ResolvedCell | null> = {};
|
const rolled: Record<string, ResolvedCell[]> = {};
|
||||||
const userNext: Record<string, ResolvedCell | null> = { ...userPrev };
|
const userNext: Record<string, ResolvedCell[]> = { ...userPrev };
|
||||||
for (const date of dates) {
|
for (const date of dates) {
|
||||||
rolled[date] = userPrev[date] ?? null;
|
rolled[date] = userPrev[date] ?? [];
|
||||||
userNext[date] = mutator(rolled[date]);
|
userNext[date] = mutator(rolled[date]).slice(0, 3);
|
||||||
}
|
}
|
||||||
qc.setQueryData(key, {
|
qc.setQueryData(key, {
|
||||||
...prev,
|
...prev,
|
||||||
@@ -244,7 +246,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// the ResolvedCell mirrors the request body.
|
// the ResolvedCell mirrors the request body.
|
||||||
const days = eachDay(body.date_from, body.date_to);
|
const days = eachDay(body.date_from, body.date_to);
|
||||||
const id = data && typeof data.id === "number" ? data.id : null;
|
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({
|
makeEntryCell({
|
||||||
userId: body.user_id,
|
userId: body.user_id,
|
||||||
date: days[0],
|
date: days[0],
|
||||||
@@ -255,7 +257,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
rangeTo: body.date_to,
|
rangeTo: body.date_to,
|
||||||
entryId: id,
|
entryId: id,
|
||||||
}),
|
}),
|
||||||
);
|
...prev,
|
||||||
|
]);
|
||||||
// Stash the rollback on the mutation object so PlanWork can call
|
// Stash the rollback on the mutation object so PlanWork can call
|
||||||
// it from onError.
|
// it from onError.
|
||||||
(createEntry as any)._rolled = rolled;
|
(createEntry as any)._rolled = rolled;
|
||||||
@@ -294,8 +297,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
let ownerUserId: number | null = null;
|
let ownerUserId: number | null = null;
|
||||||
if (grid) {
|
if (grid) {
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const cell of Object.values(byDate)) {
|
for (const cells of Object.values(byDate)) {
|
||||||
if (cell && cell.entryId === id) {
|
if (cells.some((c) => c.entryId === id)) {
|
||||||
ownerUserId = Number(uidStr);
|
ownerUserId = Number(uidStr);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -304,20 +307,27 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ownerUserId !== null) {
|
if (ownerUserId !== null) {
|
||||||
const rolled = patchCells(currentGridKey, ownerUserId, days, (prev) =>
|
const rolled = patchCells(
|
||||||
makeEntryCell({
|
currentGridKey,
|
||||||
|
ownerUserId,
|
||||||
|
days,
|
||||||
|
(prev) => {
|
||||||
|
const existing = prev.find((c) => c.entryId === id) ?? null;
|
||||||
|
const updated = makeEntryCell({
|
||||||
userId: ownerUserId!,
|
userId: ownerUserId!,
|
||||||
date: days[0],
|
date: days[0],
|
||||||
projectId:
|
projectId:
|
||||||
body.project_id === undefined
|
body.project_id === undefined
|
||||||
? (prev?.project_id ?? null)
|
? (existing?.project_id ?? null)
|
||||||
: body.project_id,
|
: body.project_id,
|
||||||
category: body.category ?? prev?.category ?? "work",
|
category: body.category ?? existing?.category ?? "work",
|
||||||
note: body.note ?? prev?.note ?? "",
|
note: body.note ?? existing?.note ?? "",
|
||||||
rangeFrom: body.date_from,
|
rangeFrom: body.date_from,
|
||||||
rangeTo: body.date_to,
|
rangeTo: body.date_to,
|
||||||
entryId: id,
|
entryId: id,
|
||||||
}),
|
});
|
||||||
|
return [updated, ...prev.filter((c) => c.entryId !== id)];
|
||||||
|
},
|
||||||
);
|
);
|
||||||
(updateEntry as any)._rolled = rolled;
|
(updateEntry as any)._rolled = rolled;
|
||||||
}
|
}
|
||||||
@@ -338,26 +348,29 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||||
if (grid) {
|
if (grid) {
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [, cell] of Object.entries(byDate)) {
|
let range: { from: string; to: string } | null = null;
|
||||||
if (
|
for (const cells of Object.values(byDate)) {
|
||||||
cell &&
|
const hit = cells.find(
|
||||||
cell.entryId === vars.id &&
|
(c) => c.entryId === vars.id && c.rangeFrom && c.rangeTo,
|
||||||
cell.rangeFrom &&
|
);
|
||||||
cell.rangeTo
|
if (hit) {
|
||||||
) {
|
range = { from: hit.rangeFrom!, to: hit.rangeTo! };
|
||||||
const days = eachDay(cell.rangeFrom, cell.rangeTo);
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (range) {
|
||||||
|
const days = eachDay(range.from, range.to);
|
||||||
const rolled = patchCells(
|
const rolled = patchCells(
|
||||||
currentGridKey,
|
currentGridKey,
|
||||||
Number(uidStr),
|
Number(uidStr),
|
||||||
days,
|
days,
|
||||||
() => null,
|
(prev) => prev.filter((c) => c.entryId !== vars.id),
|
||||||
);
|
);
|
||||||
(deleteEntry as any)._rolled = rolled;
|
(deleteEntry as any)._rolled = rolled;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -375,7 +388,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
currentGridKey,
|
currentGridKey,
|
||||||
vars.user_id,
|
vars.user_id,
|
||||||
[vars.shift_date],
|
[vars.shift_date],
|
||||||
() =>
|
(prev) => [
|
||||||
makeOverrideCell({
|
makeOverrideCell({
|
||||||
userId: vars.user_id,
|
userId: vars.user_id,
|
||||||
date: vars.shift_date,
|
date: vars.shift_date,
|
||||||
@@ -384,6 +397,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
note: vars.note,
|
note: vars.note,
|
||||||
overrideId: id,
|
overrideId: id,
|
||||||
}),
|
}),
|
||||||
|
...prev,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
(createOverride as any)._rolled = rolled;
|
(createOverride as any)._rolled = rolled;
|
||||||
invalidate();
|
invalidate();
|
||||||
@@ -411,21 +426,30 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||||
if (grid) {
|
if (grid) {
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [date, cell] of Object.entries(byDate)) {
|
for (const [date, cells] of Object.entries(byDate)) {
|
||||||
if (cell && cell.overrideId === vars.id) {
|
if (cells.some((c) => c.overrideId === vars.id)) {
|
||||||
const rolled = patchCells(
|
const rolled = patchCells(
|
||||||
currentGridKey,
|
currentGridKey,
|
||||||
Number(uidStr),
|
Number(uidStr),
|
||||||
[date],
|
[date],
|
||||||
(prev) =>
|
(prev) => {
|
||||||
makeOverrideCell({
|
const existing =
|
||||||
|
prev.find((c) => c.overrideId === vars.id) ?? null;
|
||||||
|
const updated = makeOverrideCell({
|
||||||
userId: Number(uidStr),
|
userId: Number(uidStr),
|
||||||
date,
|
date,
|
||||||
projectId: vars.body.project_id ?? prev?.project_id ?? null,
|
projectId:
|
||||||
category: vars.body.category ?? prev?.category ?? "work",
|
vars.body.project_id ?? existing?.project_id ?? null,
|
||||||
note: vars.body.note ?? prev?.note ?? "",
|
category:
|
||||||
|
vars.body.category ?? existing?.category ?? "work",
|
||||||
|
note: vars.body.note ?? existing?.note ?? "",
|
||||||
overrideId: vars.id,
|
overrideId: vars.id,
|
||||||
}),
|
});
|
||||||
|
return [
|
||||||
|
updated,
|
||||||
|
...prev.filter((c) => c.overrideId !== vars.id),
|
||||||
|
];
|
||||||
|
},
|
||||||
);
|
);
|
||||||
(updateOverride as any)._rolled = rolled;
|
(updateOverride as any)._rolled = rolled;
|
||||||
break;
|
break;
|
||||||
@@ -446,13 +470,13 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||||
if (grid) {
|
if (grid) {
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [date, cell] of Object.entries(byDate)) {
|
for (const [date, cells] of Object.entries(byDate)) {
|
||||||
if (cell && cell.overrideId === vars.id) {
|
if (cells.some((c) => c.overrideId === vars.id)) {
|
||||||
const rolled = patchCells(
|
const rolled = patchCells(
|
||||||
currentGridKey,
|
currentGridKey,
|
||||||
Number(uidStr),
|
Number(uidStr),
|
||||||
[date],
|
[date],
|
||||||
() => null,
|
(prev) => prev.filter((c) => c.overrideId !== vars.id),
|
||||||
);
|
);
|
||||||
(deleteOverride as any)._rolled = rolled;
|
(deleteOverride as any)._rolled = rolled;
|
||||||
break;
|
break;
|
||||||
@@ -472,15 +496,15 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// directly without the "weak type" mismatch a `{ _rolled? }` param causes.
|
// directly without the "weak type" mismatch a `{ _rolled? }` param causes.
|
||||||
function rollbackMutation(mutation: unknown, userId: number) {
|
function rollbackMutation(mutation: unknown, userId: number) {
|
||||||
const stash = mutation as {
|
const stash = mutation as {
|
||||||
_rolled?: Record<string, ResolvedCell | null> | null;
|
_rolled?: Record<string, ResolvedCell[]> | null;
|
||||||
};
|
};
|
||||||
if (!stash._rolled) return;
|
if (!stash._rolled) return;
|
||||||
const prev = qc.getQueryData<GridData>(currentGridKey as any);
|
const prev = qc.getQueryData<GridData>(currentGridKey as any);
|
||||||
if (!prev) return;
|
if (!prev) return;
|
||||||
const userPrev = prev.cells[userId] ?? {};
|
const userPrev = prev.cells[userId] ?? {};
|
||||||
const userNext = { ...userPrev };
|
const userNext = { ...userPrev };
|
||||||
for (const [date, cell] of Object.entries(stash._rolled)) {
|
for (const [date, cells] of Object.entries(stash._rolled)) {
|
||||||
userNext[date] = cell;
|
userNext[date] = cells;
|
||||||
}
|
}
|
||||||
qc.setQueryData(currentGridKey, {
|
qc.setQueryData(currentGridKey, {
|
||||||
...prev,
|
...prev,
|
||||||
@@ -518,10 +542,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(
|
export function getCell(
|
||||||
grid: GridData | undefined,
|
grid: GridData | undefined,
|
||||||
userId: number,
|
userId: number,
|
||||||
date: string,
|
date: string,
|
||||||
): ResolvedCell | null {
|
): ResolvedCell | null {
|
||||||
return grid?.cells?.[userId]?.[date] ?? null;
|
return getCells(grid, userId, date)[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -82,3 +82,12 @@ export const orderDetailOptions = (id: string | undefined) =>
|
|||||||
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
|
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const orderNextNumberOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["orders", "next-number"],
|
||||||
|
queryFn: () =>
|
||||||
|
jsonQuery<{ next_number?: string; number?: string }>(
|
||||||
|
"/api/admin/orders/next-number",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export interface GridData {
|
|||||||
date_from: string;
|
date_from: string;
|
||||||
date_to: string;
|
date_to: string;
|
||||||
users: PlanUser[];
|
users: PlanUser[];
|
||||||
cells: Record<number, Record<string, ResolvedCell | null>>;
|
cells: Record<number, Record<string, ResolvedCell[]>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const planKeys = {
|
export const planKeys = {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
@@ -1,16 +1,29 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
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 BulkAttendanceModal from "../components/BulkAttendanceModal";
|
||||||
import ShiftFormModal from "../components/ShiftFormModal";
|
import ShiftFormModal from "../components/ShiftFormModal";
|
||||||
import AttendanceShiftTable from "../components/AttendanceShiftTable";
|
import AttendanceShiftTable from "../components/AttendanceShiftTable";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
import useModalLock from "../hooks/useModalLock";
|
||||||
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
|
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
|
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 {
|
interface UserTotalData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,23 +48,57 @@ interface UserTotalData {
|
|||||||
worked_holiday_hours?: number;
|
worked_holiday_hours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFundBarBackground(data: UserTotalData) {
|
/** Fond "used" total mirrors the Fond footer line below:
|
||||||
// fondUsed mirrors the Fond "used" line in the IIFE below:
|
* real_worked + vacation + worked_holiday + free_holiday. */
|
||||||
// real_worked + vacation + worked_holiday + free_holiday
|
function getFondUsed(data: UserTotalData): number {
|
||||||
// (delta is fondUsed - fund; 0 means exactly at the fund).
|
|
||||||
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
||||||
const fondUsed =
|
return (
|
||||||
realWorked +
|
realWorked +
|
||||||
(data.vacation_hours ?? 0) +
|
(data.vacation_hours ?? 0) +
|
||||||
(data.worked_holiday_hours ?? 0) +
|
(data.worked_holiday_hours ?? 0) +
|
||||||
(data.svatek ?? 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)";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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() {
|
export default function AttendanceAdmin() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
@@ -111,279 +158,238 @@ export default function AttendanceAdmin() {
|
|||||||
Object.keys(data.user_totals).length === 0;
|
Object.keys(data.user_totals).length === 0;
|
||||||
|
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasTotals = Object.keys(data.user_totals).length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-page-header"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "flex",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
alignItems: "flex-start",
|
||||||
transition={{ duration: 0.25 }}
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<Typography variant="h4">Správa docházky</Typography>
|
||||||
<h1 className="admin-page-title">Správa docházky</h1>
|
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||||
</div>
|
|
||||||
<div className="admin-page-actions">
|
|
||||||
{hasData && (
|
{hasData && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
startIcon={PrintIcon}
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
title="Tisk docházky"
|
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
|
Tisk
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button variant="outlined" color="inherit" onClick={openBulkModal}>
|
||||||
onClick={openBulkModal}
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
>
|
|
||||||
Vyplnit měsíc
|
Vyplnit měsíc
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button startIcon={AddIcon} onClick={openCreateModal}>
|
||||||
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áznam
|
Přidat záznam
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</motion.div>
|
</Box>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<motion.div
|
<FilterBar>
|
||||||
className="admin-card mb-6"
|
<Box sx={{ flex: "0 0 180px" }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Box>
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
<Box sx={{ flex: "0 0 220px" }}>
|
||||||
>
|
<Select
|
||||||
<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
|
|
||||||
value={filterUserId}
|
value={filterUserId}
|
||||||
onChange={(e) => setFilterUserId(e.target.value)}
|
onChange={setFilterUserId}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "", label: "Všichni zaměstnanci" },
|
||||||
<option value="">Všichni</option>
|
...data.users.map((user) => ({
|
||||||
{data.users.map((user) => (
|
value: String(user.id),
|
||||||
<option key={user.id} value={user.id}>
|
label: user.name,
|
||||||
{user.name}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
/>
|
||||||
</select>
|
</Box>
|
||||||
</FormField>
|
</FilterBar>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* User Totals */}
|
{/* User Totals (KPI cards) */}
|
||||||
{Object.keys(data.user_totals).length > 0 && (
|
{hasTotals && (
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-grid admin-grid-3 mb-6"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "grid",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
gridTemplateColumns: {
|
||||||
transition={{ duration: 0.25, delay: 0.09 }}
|
xs: "1fr",
|
||||||
|
sm: "repeat(2, 1fr)",
|
||||||
|
lg: "repeat(3, 1fr)",
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
||||||
const ut = userData as UserTotalData;
|
const ut = userData as UserTotalData;
|
||||||
|
const balance = data.leave_balances[uid];
|
||||||
|
const statColor: StatCardColor = ut.working ? "success" : "default";
|
||||||
return (
|
return (
|
||||||
<div key={uid} className="admin-card" style={{ display: "flex" }}>
|
<StatCard
|
||||||
<div
|
key={uid}
|
||||||
className="admin-card-body"
|
label={ut.name}
|
||||||
style={{
|
value={
|
||||||
display: "flex",
|
<Box
|
||||||
flexDirection: "column",
|
component="span"
|
||||||
flex: 1,
|
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)}
|
{formatMinutes(ut.minutes)}
|
||||||
</div>
|
<StatusChip
|
||||||
<div className="admin-stat-label">odpracováno</div>
|
color={ut.working ? "success" : "default"}
|
||||||
<div
|
label={ut.working ? "✓" : "✗"}
|
||||||
style={{
|
/>
|
||||||
marginTop: "0.5rem",
|
</Box>
|
||||||
marginBottom: "0.75rem",
|
}
|
||||||
|
color={statColor}
|
||||||
|
footer={
|
||||||
|
<Box>
|
||||||
|
{/* Leave-type badges */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: "0.4rem",
|
gap: 0.5,
|
||||||
minHeight: "2rem",
|
mb: 1,
|
||||||
|
minHeight: "1.75rem",
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ut.vacation_hours > 0 && (
|
{ut.vacation_hours > 0 && (
|
||||||
<span className="attendance-leave-badge badge-vacation">
|
<StatusChip
|
||||||
Dov: {ut.vacation_hours}h
|
color="info"
|
||||||
</span>
|
label={`Dov: ${ut.vacation_hours}h`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{ut.sick_hours > 0 && (
|
{ut.sick_hours > 0 && (
|
||||||
<span className="attendance-leave-badge badge-sick">
|
<StatusChip
|
||||||
Nem: {ut.sick_hours}h
|
color="error"
|
||||||
</span>
|
label={`Nem: ${ut.sick_hours}h`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{ut.svatek !== undefined && ut.svatek > 0 && (
|
{ut.svatek !== undefined && ut.svatek > 0 && (
|
||||||
<span className="attendance-leave-badge badge-holiday">
|
<StatusChip
|
||||||
Sv: {Math.round(ut.svatek)}h
|
color="warning"
|
||||||
</span>
|
label={`Sv: ${Math.round(ut.svatek)}h`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{ut.unpaid_hours > 0 && (
|
{ut.unpaid_hours > 0 && (
|
||||||
<span className="attendance-leave-badge badge-unpaid">
|
<StatusChip
|
||||||
Nep: {ut.unpaid_hours}h
|
color="default"
|
||||||
</span>
|
label={`Nep: ${ut.unpaid_hours}h`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
|
{/* Fond usage */}
|
||||||
{ut.fund !== null &&
|
{ut.fund !== null &&
|
||||||
(() => {
|
(() => {
|
||||||
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday
|
// Fond "used" = real_worked + vacation +
|
||||||
// (everything the user actually got paid for this month:
|
// worked_holiday + free_holiday (everything the user
|
||||||
// raw worked time with sub-day remainders, plus
|
// actually got paid for this month).
|
||||||
// vacation days, plus hours worked on holidays, plus
|
const fondUsed = getFondUsed(ut);
|
||||||
// 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);
|
|
||||||
const fundVal = ut.fund ?? 0;
|
const fundVal = ut.fund ?? 0;
|
||||||
const delta = fondUsed - fundVal;
|
const delta = fondUsed - fundVal;
|
||||||
|
const pct = Math.min(
|
||||||
|
100,
|
||||||
|
(fondUsed / (ut.fund || 1)) * 100,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<Box>
|
||||||
className="kpi-card-footer"
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
marginTop: "auto",
|
|
||||||
paddingTop: "0.75rem",
|
|
||||||
borderTop: "1px solid var(--border-color)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-secondary"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
fontSize: "0.8rem",
|
mb: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
>
|
||||||
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
||||||
{formatHoursDecimal(fundVal * 60)}h
|
{formatHoursDecimal(fundVal * 60)}h
|
||||||
</span>
|
</Typography>
|
||||||
{delta > 0 && (
|
{delta > 0 && (
|
||||||
<span className="text-warning fw-600">
|
<Typography
|
||||||
+{formatHoursDecimal(delta * 60)}h
|
variant="caption"
|
||||||
</span>
|
sx={{
|
||||||
)}
|
fontWeight: 600,
|
||||||
{delta <= 0 && delta < 0 && (
|
color: "warning.main",
|
||||||
<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",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
+{formatHoursDecimal(delta * 60)}h
|
||||||
style={{
|
</Typography>
|
||||||
height: "100%",
|
)}
|
||||||
width: `${Math.min(
|
{delta < 0 && (
|
||||||
100,
|
<Typography
|
||||||
(fondUsed / (ut.fund || 1)) * 100,
|
variant="caption"
|
||||||
)}%`,
|
sx={{
|
||||||
background: getFundBarBackground(ut),
|
fontWeight: 600,
|
||||||
borderRadius: "2px",
|
color: "error.main",
|
||||||
transition: "width 0.3s ease",
|
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<ProgressBar
|
||||||
|
value={pct}
|
||||||
|
color={getFundBarColor(ut)}
|
||||||
|
height={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<div
|
|
||||||
className="text-secondary"
|
{/* Remaining vacation */}
|
||||||
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
|
<Box sx={{ mt: 1.5 }}>
|
||||||
>
|
{balance ? (
|
||||||
{data.leave_balances[uid] ? (
|
<Typography variant="caption" color="text.secondary">
|
||||||
<>
|
|
||||||
Zbývá dovolené:{" "}
|
Zbývá dovolené:{" "}
|
||||||
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
|
{balance.vacation_remaining.toFixed(1)}h /{" "}
|
||||||
h / {data.leave_balances[uid].vacation_total}h
|
{balance.vacation_total}h
|
||||||
</>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ visibility: "hidden" }}>—</span>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ visibility: "hidden" }}
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</motion.div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Records Table */}
|
{/* Records Table */}
|
||||||
<motion.div
|
<Card>
|
||||||
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">
|
|
||||||
<AttendanceShiftTable
|
<AttendanceShiftTable
|
||||||
records={data.records}
|
records={data.records}
|
||||||
onEdit={openEditModal}
|
onEdit={openEditModal}
|
||||||
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<BulkAttendanceModal
|
<BulkAttendanceModal
|
||||||
@@ -435,7 +441,7 @@ export default function AttendanceAdmin() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => setDeleteConfirm({ show: false, record: null })}
|
onClose={() => setDeleteConfirm({ show: false, record: null })}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
@@ -444,6 +450,6 @@ export default function AttendanceAdmin() {
|
|||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,28 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { userListOptions } from "../lib/queries/users";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
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 { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { todayLocalStr } from "../utils/formatters";
|
import { todayLocalStr } from "../utils/formatters";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DateField,
|
||||||
|
Field,
|
||||||
|
LoadingState,
|
||||||
|
PageEnter,
|
||||||
|
PageHeader,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
TimeField,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
interface CreateForm {
|
interface CreateForm {
|
||||||
@@ -102,89 +114,74 @@ export default function AttendanceCreate() {
|
|||||||
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<PageHeader
|
||||||
className="admin-page-header"
|
title="Přidat záznam docházky"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
actions={
|
||||||
animate={{ opacity: 1, y: 0 }}
|
<Button
|
||||||
transition={{ duration: 0.25 }}
|
variant="outlined"
|
||||||
>
|
color="inherit"
|
||||||
<div>
|
component={RouterLink}
|
||||||
<h1 className="admin-page-title">Přidat záznam docházky</h1>
|
|
||||||
</div>
|
|
||||||
<div className="admin-page-actions">
|
|
||||||
<Link
|
|
||||||
to="/attendance/admin"
|
to="/attendance/admin"
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
>
|
>
|
||||||
← Zpět na správu
|
← Zpět na správu
|
||||||
</Link>
|
</Button>
|
||||||
</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 })
|
|
||||||
}
|
}
|
||||||
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>
|
<Card sx={{ maxWidth: 640 }}>
|
||||||
<select
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
value={form.leave_type}
|
{/* Employee + Shift date */}
|
||||||
onChange={(e) =>
|
<Box
|
||||||
setForm({ ...form, leave_type: e.target.value })
|
sx={{
|
||||||
}
|
display: "grid",
|
||||||
className="admin-form-select"
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="work">Práce</option>
|
<Field label="Zaměstnanec" required>
|
||||||
<option value="vacation">Dovolená</option>
|
<Select
|
||||||
<option value="sick">Nemoc</option>
|
value={form.user_id}
|
||||||
<option value="unpaid">Neplacené volno</option>
|
onChange={(val) => setForm({ ...form, user_id: val })}
|
||||||
</select>
|
options={[
|
||||||
</FormField>
|
{ 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 && (
|
{!isWorkType && (
|
||||||
<FormField label="Počet hodin">
|
<Field label="Počet hodin" hint="Výchozí 8 hodin pro celý den">
|
||||||
<input
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={form.leave_hours}
|
value={form.leave_hours}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -193,132 +190,142 @@ export default function AttendanceCreate() {
|
|||||||
leave_hours: parseFloat(e.target.value),
|
leave_hours: parseFloat(e.target.value),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
min="0.5"
|
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||||
max="24"
|
|
||||||
step="0.5"
|
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
<small className="admin-form-hint">
|
</Field>
|
||||||
Výchozí 8 hodin pro celý den
|
|
||||||
</small>
|
|
||||||
</FormField>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Work-type time fields */}
|
||||||
{isWorkType && (
|
{isWorkType && (
|
||||||
<>
|
<>
|
||||||
<div className="admin-form-row">
|
{/* Arrival */}
|
||||||
<FormField label="Příchod - datum">
|
<Box
|
||||||
<AdminDatePicker
|
sx={{
|
||||||
mode="date"
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Příchod - datum">
|
||||||
|
<DateField
|
||||||
value={form.arrival_date}
|
value={form.arrival_date}
|
||||||
onChange={(val: string) =>
|
onChange={(val) => setForm({ ...form, arrival_date: val })}
|
||||||
setForm({ ...form, arrival_date: val })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Příchod - čas">
|
<Field label="Příchod - čas">
|
||||||
<AdminDatePicker
|
<TimeField
|
||||||
mode="time"
|
|
||||||
value={form.arrival_time}
|
value={form.arrival_time}
|
||||||
onChange={(val: string) =>
|
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||||
setForm({ ...form, arrival_time: val })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
{/* Break start */}
|
||||||
<FormField label="Začátek pauzy - datum">
|
<Box
|
||||||
<AdminDatePicker
|
sx={{
|
||||||
mode="date"
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Začátek pauzy - datum">
|
||||||
|
<DateField
|
||||||
value={form.break_start_date}
|
value={form.break_start_date}
|
||||||
onChange={(val: string) =>
|
onChange={(val) =>
|
||||||
setForm({ ...form, break_start_date: val })
|
setForm({ ...form, break_start_date: val })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Začátek pauzy - čas">
|
<Field label="Začátek pauzy - čas">
|
||||||
<AdminDatePicker
|
<TimeField
|
||||||
mode="time"
|
|
||||||
value={form.break_start_time}
|
value={form.break_start_time}
|
||||||
onChange={(val: string) =>
|
onChange={(val) =>
|
||||||
setForm({ ...form, break_start_time: val })
|
setForm({ ...form, break_start_time: val })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
{/* Break end */}
|
||||||
<FormField label="Konec pauzy - datum">
|
<Box
|
||||||
<AdminDatePicker
|
sx={{
|
||||||
mode="date"
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Konec pauzy - datum">
|
||||||
|
<DateField
|
||||||
value={form.break_end_date}
|
value={form.break_end_date}
|
||||||
onChange={(val: string) =>
|
onChange={(val) =>
|
||||||
setForm({ ...form, break_end_date: val })
|
setForm({ ...form, break_end_date: val })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Konec pauzy - čas">
|
<Field label="Konec pauzy - čas">
|
||||||
<AdminDatePicker
|
<TimeField
|
||||||
mode="time"
|
|
||||||
value={form.break_end_time}
|
value={form.break_end_time}
|
||||||
onChange={(val: string) =>
|
onChange={(val) =>
|
||||||
setForm({ ...form, break_end_time: val })
|
setForm({ ...form, break_end_time: val })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
{/* Departure */}
|
||||||
<FormField label="Odchod - datum">
|
<Box
|
||||||
<AdminDatePicker
|
sx={{
|
||||||
mode="date"
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Odchod - datum">
|
||||||
|
<DateField
|
||||||
value={form.departure_date}
|
value={form.departure_date}
|
||||||
onChange={(val: string) =>
|
onChange={(val) =>
|
||||||
setForm({ ...form, departure_date: val })
|
setForm({ ...form, departure_date: val })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Odchod - čas">
|
<Field label="Odchod - čas">
|
||||||
<AdminDatePicker
|
<TimeField
|
||||||
mode="time"
|
|
||||||
value={form.departure_time}
|
value={form.departure_time}
|
||||||
onChange={(val: string) =>
|
onChange={(val) =>
|
||||||
setForm({ ...form, departure_time: val })
|
setForm({ ...form, departure_time: val })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField label="Poznámka">
|
{/* Notes */}
|
||||||
<textarea
|
<Field label="Poznámka">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
className="admin-form-textarea"
|
|
||||||
rows={3}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<div className="admin-form-actions">
|
{/* Actions */}
|
||||||
<Link
|
<Box sx={{ display: "flex", gap: 1.5, justifyContent: "flex-end" }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
component={RouterLink}
|
||||||
to="/attendance/admin"
|
to="/attendance/admin"
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
>
|
>
|
||||||
Zrušit
|
Zrušit
|
||||||
</Link>
|
</Button>
|
||||||
<button
|
<Button type="submit" disabled={submitting}>
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
|
||||||
{submitting ? "Ukládám..." : "Uložit"}
|
{submitting ? "Ukládám..." : "Uložit"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</form>
|
</Box>
|
||||||
</div>
|
</Card>
|
||||||
</motion.div>
|
</PageEnter>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useMemo, useRef } from "react";
|
import { useState, useMemo, useRef } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
|
||||||
import { companySettingsOptions } from "../lib/queries/settings";
|
import { companySettingsOptions } from "../lib/queries/settings";
|
||||||
|
import { iconBadgeSx } from "../theme";
|
||||||
import {
|
import {
|
||||||
attendanceHistoryOptions,
|
attendanceHistoryOptions,
|
||||||
type AttendanceRecord,
|
type AttendanceRecord,
|
||||||
@@ -25,7 +26,20 @@ import {
|
|||||||
normalizeDateStr,
|
normalizeDateStr,
|
||||||
formatHoursDecimal,
|
formatHoursDecimal,
|
||||||
} from "../utils/attendanceHelpers";
|
} 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 = [
|
const MONTH_NAMES = [
|
||||||
"Leden",
|
"Leden",
|
||||||
@@ -42,6 +56,21 @@ const MONTH_NAMES = [
|
|||||||
"Prosinec",
|
"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 => {
|
const formatBreakRange = (record: AttendanceRecord): string => {
|
||||||
if (record.break_start && record.break_end) {
|
if (record.break_start && record.break_end) {
|
||||||
return `${formatTime(record.break_start)} - ${formatTime(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) => {
|
const renderProjectCell = (record: AttendanceRecord) => {
|
||||||
if (record.project_logs && record.project_logs.length > 0) {
|
if (record.project_logs && record.project_logs.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
|
|
||||||
>
|
|
||||||
{record.project_logs.map((log, i) => {
|
{record.project_logs.map((log, i) => {
|
||||||
let h: number,
|
let h: number,
|
||||||
m: number,
|
m: number,
|
||||||
@@ -145,31 +172,28 @@ const renderProjectCell = (record: AttendanceRecord) => {
|
|||||||
m = mins % 60;
|
m = mins % 60;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusChip
|
||||||
key={log.id || i}
|
key={log.id || i}
|
||||||
className="admin-badge"
|
color={isActive ? "info" : "default"}
|
||||||
style={{
|
label={`${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`}
|
||||||
fontSize: "0.7rem",
|
sx={{ 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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (record.project_name) {
|
if (record.project_name) {
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusChip
|
||||||
className="admin-badge admin-badge-wrap"
|
color="default"
|
||||||
style={{ fontSize: "0.75rem" }}
|
label={record.project_name}
|
||||||
>
|
sx={{
|
||||||
{record.project_name}
|
fontSize: "0.75rem",
|
||||||
</span>
|
height: "auto",
|
||||||
|
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return "—";
|
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 (
|
return (
|
||||||
<div>
|
<StatusChip
|
||||||
<motion.div
|
color={leaveTypeChipColor(leaveType)}
|
||||||
className="admin-page-header"
|
label={getLeaveTypeName(leaveType)}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
/>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
);
|
||||||
transition={{ duration: 0.25 }}
|
},
|
||||||
>
|
},
|
||||||
<div>
|
{
|
||||||
<h1 className="admin-page-title">Historie docházky</h1>
|
key: "arrival",
|
||||||
<p className="admin-page-subtitle">{computed.monthName}</p>
|
header: "Příchod",
|
||||||
</div>
|
width: "10%",
|
||||||
<div className="admin-page-actions">
|
mono: true,
|
||||||
{records.length > 0 && (
|
render: (record) => {
|
||||||
<button
|
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}
|
onClick={handlePrint}
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
title="Tisk docházky"
|
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
|
Tisk
|
||||||
</button>
|
</Button>
|
||||||
)}
|
) : undefined
|
||||||
</div>
|
}
|
||||||
</motion.div>
|
/>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<motion.div
|
<FilterBar>
|
||||||
className="admin-card mb-6"
|
<Box sx={{ flex: "0 0 180px" }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Box>
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
</FilterBar>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Monthly Fund Card */}
|
{/* Monthly Fund Card */}
|
||||||
<motion.div
|
<Card sx={{ mt: 3 }}>
|
||||||
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">
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<div className="admin-loading">
|
<LoadingState />
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Box
|
||||||
{computed.monthlyFund && (
|
sx={{
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "1rem",
|
gap: 2,
|
||||||
flexWrap: "wrap",
|
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
|
<svg
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
@@ -468,230 +560,102 @@ export default function AttendanceHistory() {
|
|||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</Box>
|
||||||
<div style={{ flex: 1, minWidth: "200px" }}>
|
<Box sx={{ flex: 1, minWidth: "200px" }}>
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "baseline",
|
alignItems: "baseline",
|
||||||
marginBottom: "0.375rem",
|
mb: 0.75,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<Typography sx={{ fontWeight: 600, fontSize: "1rem" }}>
|
||||||
style={{
|
Fond: {fund.worked}h / {fund.fund}h
|
||||||
fontWeight: 600,
|
</Typography>
|
||||||
fontSize: "1rem",
|
<Typography
|
||||||
color: "var(--text-primary)",
|
variant="body2"
|
||||||
}}
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: "0.8125rem" }}
|
||||||
>
|
>
|
||||||
Fond: {computed.monthlyFund.worked}h /{" "}
|
{fund.business_days} prac. dnů
|
||||||
{computed.monthlyFund.fund}h
|
{fund.holiday_count > 0 && ` + ${fund.holiday_count} svátky`}
|
||||||
</span>
|
</Typography>
|
||||||
<span
|
</Box>
|
||||||
className="text-secondary"
|
<ProgressBar value={progressValue} color={progressColor} />
|
||||||
style={{ fontSize: "0.8125rem" }}
|
<Box
|
||||||
>
|
sx={{
|
||||||
{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={{
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: "0.4rem",
|
gap: 0.5,
|
||||||
marginTop: "0.5rem",
|
mt: 1,
|
||||||
minHeight: "1.5rem",
|
minHeight: "1.5rem",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
gap: "0.4rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{computed.totalMinutes > 0 && (
|
{computed.totalMinutes > 0 && (
|
||||||
<span
|
<StatusChip
|
||||||
className="attendance-leave-badge"
|
color="info"
|
||||||
|
label={`Práce: ${formatHoursDecimal(computed.totalMinutes)}h`}
|
||||||
title="Odpracováno (reálně)"
|
title="Odpracováno (reálně)"
|
||||||
>
|
/>
|
||||||
Práce: {formatHoursDecimal(computed.totalMinutes)}h
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{computed.vacationHours > 0 && (
|
{computed.vacationHours > 0 && (
|
||||||
<span className="attendance-leave-badge badge-vacation">
|
<StatusChip
|
||||||
Dov: {computed.vacationHours}h
|
color="success"
|
||||||
</span>
|
label={`Dov: ${computed.vacationHours}h`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{computed.sickHours > 0 && (
|
{computed.sickHours > 0 && (
|
||||||
<span className="attendance-leave-badge badge-sick">
|
<StatusChip
|
||||||
Nem: {computed.sickHours}h
|
color="error"
|
||||||
</span>
|
label={`Nem: ${computed.sickHours}h`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{computed.freeHolidayHours > 0 && (
|
{computed.freeHolidayHours > 0 && (
|
||||||
<span className="attendance-leave-badge badge-holiday">
|
<StatusChip
|
||||||
Sv: {Math.round(computed.freeHolidayHours)}h
|
color="warning"
|
||||||
</span>
|
label={`Sv: ${Math.round(computed.freeHolidayHours)}h`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{computed.unpaidHours > 0 && (
|
{computed.unpaidHours > 0 && (
|
||||||
<span className="attendance-leave-badge badge-unpaid">
|
<StatusChip
|
||||||
Nep: {computed.unpaidHours}h
|
color="default"
|
||||||
</span>
|
label={`Nep: ${computed.unpaidHours}h`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
<span
|
<Typography
|
||||||
style={{
|
variant="caption"
|
||||||
fontSize: "0.75rem",
|
sx={{ fontWeight: 600, color: deltaColor }}
|
||||||
color: "var(--text-muted)",
|
|
||||||
}}
|
|
||||||
className={
|
|
||||||
computed.delta > 0
|
|
||||||
? "text-warning fw-600"
|
|
||||||
: computed.delta < 0
|
|
||||||
? "text-danger fw-600"
|
|
||||||
: "text-success fw-600"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{computed.delta > 0
|
{computed.delta > 0
|
||||||
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
|
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
|
||||||
: computed.delta < 0
|
: computed.delta < 0
|
||||||
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
|
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
|
||||||
: "Fond splněn"}
|
: "Fond splněn"}
|
||||||
</span>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{!computed.monthlyFund && (
|
</Card>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Records Table */}
|
{/* Records Table */}
|
||||||
<motion.div
|
<Card sx={{ mt: 3 }}>
|
||||||
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">
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<div className="admin-loading">
|
<LoadingState />
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<DataTable<AttendanceRecord>
|
||||||
{records.length === 0 && (
|
columns={columns}
|
||||||
<div className="admin-empty-state">
|
rows={records}
|
||||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
rowKey={(record) => record.id}
|
||||||
</div>
|
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{records.length > 0 && (
|
</Card>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Hidden Print Content */}
|
{/* Hidden Print Content */}
|
||||||
{records.length > 0 && (
|
{records.length > 0 && (
|
||||||
@@ -896,6 +860,24 @@ export default function AttendanceHistory() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,47 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { useNavigate, useParams, Link } from "react-router-dom";
|
import { useNavigate, useParams, Link as RouterLink } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
import "leaflet/dist/leaflet.css";
|
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 { formatDate, formatTime } from "../utils/attendanceHelpers";
|
||||||
import {
|
import {
|
||||||
attendanceLocationOptions,
|
attendanceLocationOptions,
|
||||||
type LocationRecord,
|
type LocationRecord,
|
||||||
} from "../lib/queries/attendance";
|
} 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() {
|
export default function AttendanceLocation() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -167,127 +197,164 @@ export default function AttendanceLocation() {
|
|||||||
const month = shiftDateStr.substring(0, 7);
|
const month = shiftDateStr.substring(0, 7);
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
{/* Header */}
|
||||||
className="admin-page-header"
|
<PageHeader
|
||||||
initial={{ opacity: 0, y: 12 }}
|
title="Poloha záznamu"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
actions={
|
||||||
transition={{ duration: 0.25 }}
|
<Button
|
||||||
>
|
component={RouterLink}
|
||||||
<div>
|
|
||||||
<h1 className="admin-page-title">Poloha záznamu</h1>
|
|
||||||
</div>
|
|
||||||
<div className="admin-page-actions">
|
|
||||||
<Link
|
|
||||||
to={`/attendance/admin?month=${month}`}
|
to={`/attendance/admin?month=${month}`}
|
||||||
className="admin-btn admin-btn-secondary"
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
startIcon={BackIcon}
|
||||||
>
|
>
|
||||||
← Zpět na správu
|
Zpět na správu
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
}
|
||||||
</motion.div>
|
/>
|
||||||
|
|
||||||
<motion.div
|
{/* Info + map card */}
|
||||||
className="admin-card"
|
<Card sx={{ mb: 3 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
|
||||||
>
|
|
||||||
<div className="admin-card-header">
|
|
||||||
<h2 className="admin-card-title">
|
|
||||||
{record.user_name} — {formatDate(record.shift_date)}
|
{record.user_name} — {formatDate(record.shift_date)}
|
||||||
</h2>
|
</Typography>
|
||||||
</div>
|
|
||||||
<div className="admin-card-body">
|
|
||||||
{hasAnyLocation && (
|
|
||||||
<div ref={mapRef} className="attendance-location-map" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="attendance-location-grid">
|
{/* Leaflet map */}
|
||||||
{/* Arrival */}
|
{hasAnyLocation && <LocationMap ref={mapRef} />}
|
||||||
<div
|
|
||||||
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
|
{/* 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
|
{record.arrival_time
|
||||||
? formatDatetimeLocal(record.arrival_time)
|
? formatDatetimeLocal(record.arrival_time)
|
||||||
: "—"}
|
: "—"}
|
||||||
</div>
|
</Typography>
|
||||||
{hasArrivalLocation ? (
|
{hasArrivalLocation ? (
|
||||||
<>
|
<>
|
||||||
<div className="attendance-location-address">
|
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||||
{record.arrival_address || <em>Adresa nezjištěna</em>}
|
{record.arrival_address || <em>Adresa nezjištěna</em>}
|
||||||
</div>
|
</Typography>
|
||||||
<div className="attendance-location-coords">
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "text.secondary",
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
GPS: {record.arrival_lat}, {record.arrival_lng}
|
GPS: {record.arrival_lat}, {record.arrival_lng}
|
||||||
{record.arrival_accuracy &&
|
{record.arrival_accuracy &&
|
||||||
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
||||||
</div>
|
</Typography>
|
||||||
<a
|
<Button
|
||||||
|
component="a"
|
||||||
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
|
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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
|
Otevřít v Google Maps
|
||||||
</a>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="attendance-location-address">
|
<Typography variant="body2" color="text.secondary">
|
||||||
<em>Poloha nebyla zaznamenána</em>
|
<em>Poloha nebyla zaznamenána</em>
|
||||||
</div>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
{/* Departure */}
|
{/* Departure */}
|
||||||
{(hasDepartureLocation || record.departure_time) && (
|
{(hasDepartureLocation || record.departure_time) && (
|
||||||
<div
|
<Box
|
||||||
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
|
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
|
{record.departure_time
|
||||||
? formatDatetimeLocal(record.departure_time)
|
? formatDatetimeLocal(record.departure_time)
|
||||||
: "—"}
|
: "—"}
|
||||||
</div>
|
</Typography>
|
||||||
{hasDepartureLocation ? (
|
{hasDepartureLocation ? (
|
||||||
<>
|
<>
|
||||||
<div className="attendance-location-address">
|
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||||
{record.departure_address || <em>Adresa nezjištěna</em>}
|
{record.departure_address || <em>Adresa nezjištěna</em>}
|
||||||
</div>
|
</Typography>
|
||||||
<div className="attendance-location-coords">
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "'DM Mono', Menlo, monospace",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "text.secondary",
|
||||||
|
mb: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
GPS: {record.departure_lat}, {record.departure_lng}
|
GPS: {record.departure_lat}, {record.departure_lng}
|
||||||
{record.departure_accuracy &&
|
{record.departure_accuracy &&
|
||||||
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
|
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
|
||||||
</div>
|
</Typography>
|
||||||
<a
|
<Button
|
||||||
|
component="a"
|
||||||
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
|
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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
|
Otevřít v Google Maps
|
||||||
</a>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="attendance-location-address">
|
<Typography variant="body2" color="text.secondary">
|
||||||
<em>Poloha nebyla zaznamenána</em>
|
<em>Poloha nebyla zaznamenána</em>
|
||||||
</div>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Card>
|
||||||
</motion.div>
|
</PageEnter>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,31 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||||
import { motion } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
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 { czechPlural } from "../utils/formatters";
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
|
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";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -30,19 +44,23 @@ const ACTION_LABELS: Record<string, string> = {
|
|||||||
access_denied: "Přístup odepřen",
|
access_denied: "Přístup odepřen",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_BADGE_CLASS: Record<string, string> = {
|
// Maps legacy admin-badge-* CSS class suffixes → StatusChip color
|
||||||
create: "admin-badge-success",
|
const ACTION_CHIP_COLOR: Record<
|
||||||
update: "admin-badge-info",
|
string,
|
||||||
delete: "admin-badge-danger",
|
"success" | "info" | "warning" | "error" | "default"
|
||||||
login: "admin-badge-secondary",
|
> = {
|
||||||
login_failed: "admin-badge-danger",
|
create: "success",
|
||||||
logout: "admin-badge-secondary",
|
update: "info",
|
||||||
view: "admin-badge-info",
|
delete: "error",
|
||||||
activate: "admin-badge-success",
|
login: "default",
|
||||||
deactivate: "admin-badge-warning",
|
login_failed: "error",
|
||||||
password_change: "admin-badge-info",
|
logout: "default",
|
||||||
permission_change: "admin-badge-warning",
|
view: "info",
|
||||||
access_denied: "admin-badge-danger",
|
activate: "success",
|
||||||
|
deactivate: "warning",
|
||||||
|
password_change: "info",
|
||||||
|
permission_change: "warning",
|
||||||
|
access_denied: "error",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
|
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 }),
|
([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 {
|
interface AuditLogEntry {
|
||||||
id: number;
|
id: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -82,11 +123,16 @@ export default function AuditLog() {
|
|||||||
date_to: "",
|
date_to: "",
|
||||||
});
|
});
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(50);
|
const [perPage] = useState(50);
|
||||||
const [showCleanup, setShowCleanup] = useState(false);
|
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: [
|
queryKey: [
|
||||||
"audit-log",
|
"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 ?? [];
|
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
||||||
@@ -154,16 +204,8 @@ export default function AuditLog() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
|
||||||
setPage(newPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePerPageChange = (newPerPage: number) => {
|
|
||||||
setPage(1);
|
|
||||||
setPerPage(newPerPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCleanup = async () => {
|
const handleCleanup = async () => {
|
||||||
|
const cleanupDays = Number(cleanupDaysStr);
|
||||||
try {
|
try {
|
||||||
// Backend requires this literal confirmation token when wiping everything.
|
// Backend requires this literal confirmation token when wiping everything.
|
||||||
await cleanupMutation.mutateAsync({
|
await cleanupMutation.mutateAsync({
|
||||||
@@ -181,229 +223,156 @@ export default function AuditLog() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isPending && logs.length === 0) {
|
if (isPending && logs.length === 0) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<PageHeader
|
||||||
className="admin-page-header"
|
title="Audit log"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
subtitle={
|
||||||
animate={{ opacity: 1, y: 0 }}
|
pagination
|
||||||
transition={{ duration: 0.25 }}
|
? `${pagination.total} ${czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}`
|
||||||
>
|
: undefined
|
||||||
<div>
|
}
|
||||||
<h1 className="admin-page-title">Audit log</h1>
|
actions={
|
||||||
{pagination && (
|
<Button
|
||||||
<p className="admin-page-subtitle">
|
variant="outlined"
|
||||||
{pagination.total}{" "}
|
startIcon={TrashIcon}
|
||||||
{czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
onClick={() => setShowCleanup(true)}
|
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
|
Vyčistit
|
||||||
</button>
|
</Button>
|
||||||
</motion.div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormModal
|
{/* Cleanup Modal */}
|
||||||
|
<Modal
|
||||||
isOpen={showCleanup}
|
isOpen={showCleanup}
|
||||||
onClose={() => !cleanupMutation.isPending && setShowCleanup(false)}
|
onClose={() => !cleanupMutation.isPending && setShowCleanup(false)}
|
||||||
onSubmit={handleCleanup}
|
onSubmit={handleCleanup}
|
||||||
title="Vyčistit audit log"
|
title="Vyčistit audit log"
|
||||||
submitLabel="Smazat"
|
submitText="Smazat"
|
||||||
loading={cleanupMutation.isPending}
|
loading={cleanupMutation.isPending}
|
||||||
>
|
>
|
||||||
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
<div style={{ margin: "0.75rem 0", maxWidth: "200px" }}>
|
Smazat záznamy starší než:
|
||||||
<select
|
</Typography>
|
||||||
className="admin-form-select"
|
<Box sx={{ maxWidth: 200, mb: 1 }}>
|
||||||
value={cleanupDays}
|
<Select
|
||||||
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
|
value={cleanupDaysStr}
|
||||||
>
|
onChange={setCleanupDaysStr}
|
||||||
<option value={30}>30 dní</option>
|
options={CLEANUP_DAYS_OPTIONS}
|
||||||
<option value={60}>60 dní</option>
|
/>
|
||||||
<option value={90}>90 dní</option>
|
</Box>
|
||||||
<option value={180}>180 dní</option>
|
<Typography variant="body2" sx={{ opacity: 0.6, fontSize: "0.75rem" }}>
|
||||||
<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 }}
|
|
||||||
>
|
|
||||||
Tato akce je nevratná.
|
Tato akce je nevratná.
|
||||||
</p>
|
</Typography>
|
||||||
</FormModal>
|
</Modal>
|
||||||
|
|
||||||
<motion.div
|
<FilterBar>
|
||||||
className="admin-card mb-4"
|
<Box sx={{ flex: "1 1 320px" }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<TextField
|
||||||
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..."
|
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={(e) => handleFilterChange("search", e.target.value)}
|
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||||
|
placeholder="Popis, uživatel..."
|
||||||
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Box>
|
||||||
<FormField label="Akce">
|
<Box sx={{ flex: "0 0 160px" }}>
|
||||||
<select
|
<Select
|
||||||
className="admin-form-select"
|
|
||||||
value={filters.action}
|
value={filters.action}
|
||||||
onChange={(e) => handleFilterChange("action", e.target.value)}
|
onChange={(val) => handleFilterChange("action", val)}
|
||||||
>
|
options={[{ value: "", label: "Všechny akce" }, ...ACTION_OPTIONS]}
|
||||||
<option value="">Všechny</option>
|
/>
|
||||||
{ACTION_OPTIONS.map((opt) => (
|
</Box>
|
||||||
<option key={opt.value} value={opt.value}>
|
<Box sx={{ flex: "0 0 220px" }}>
|
||||||
{opt.label}
|
<Select
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Typ entity">
|
|
||||||
<select
|
|
||||||
className="admin-form-select"
|
|
||||||
value={filters.entity_type}
|
value={filters.entity_type}
|
||||||
onChange={(e) =>
|
onChange={(val) => handleFilterChange("entity_type", val)}
|
||||||
handleFilterChange("entity_type", e.target.value)
|
options={[
|
||||||
}
|
{ value: "", label: "Všechny entity" },
|
||||||
>
|
...ENTITY_OPTIONS,
|
||||||
<option value="">Všechny</option>
|
]}
|
||||||
{ENTITY_OPTIONS.map((opt) => (
|
/>
|
||||||
<option key={opt.value} value={opt.value}>
|
</Box>
|
||||||
{opt.label}
|
<Box sx={{ flex: "0 0 150px" }}>
|
||||||
</option>
|
<DateField
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Od">
|
|
||||||
<AdminDatePicker
|
|
||||||
mode="date"
|
|
||||||
value={filters.date_from}
|
value={filters.date_from}
|
||||||
onChange={(val: string) => handleFilterChange("date_from", val)}
|
onChange={(val) => handleFilterChange("date_from", val)}
|
||||||
|
label="Od"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Box>
|
||||||
<FormField label="Do">
|
<Box sx={{ flex: "0 0 150px" }}>
|
||||||
<AdminDatePicker
|
<DateField
|
||||||
mode="date"
|
|
||||||
value={filters.date_to}
|
value={filters.date_to}
|
||||||
onChange={(val: string) => handleFilterChange("date_to", val)}
|
onChange={(val) => handleFilterChange("date_to", val)}
|
||||||
|
label="Do"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Box>
|
||||||
</div>
|
</FilterBar>
|
||||||
</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>
|
|
||||||
|
|
||||||
|
<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={pagination}
|
page={page}
|
||||||
onPageChange={handlePageChange}
|
pageCount={pagination?.total_pages ?? 1}
|
||||||
onPerPageChange={handlePerPageChange}
|
onChange={setPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
</motion.div>
|
</PageEnter>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,27 @@
|
|||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
import { dashboardOptions } from "../lib/queries/dashboard";
|
import { dashboardOptions } from "../lib/queries/dashboard";
|
||||||
import { require2FAOptions } from "../lib/queries/settings";
|
import { require2FAOptions } from "../lib/queries/settings";
|
||||||
import { getCzechDate } from "../utils/dashboardHelpers";
|
import { getCzechDate } from "../utils/dashboardHelpers";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import { Card, Button, StatusChip, PageEnter } from "../ui";
|
||||||
|
import { iconBadgeSx } from "../theme";
|
||||||
import DashKpiCards from "../components/dashboard/DashKpiCards";
|
import DashKpiCards from "../components/dashboard/DashKpiCards";
|
||||||
import DashQuickActions from "../components/dashboard/DashQuickActions";
|
import DashQuickActions from "../components/dashboard/DashQuickActions";
|
||||||
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
|
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
|
||||||
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
|
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
|
||||||
import DashProfile from "../components/dashboard/DashProfile";
|
import DashProfile from "../components/dashboard/DashProfile";
|
||||||
import DashSessions from "../components/dashboard/DashSessions";
|
import DashSessions from "../components/dashboard/DashSessions";
|
||||||
|
import DashTodayPlan, {
|
||||||
|
type TodayPlan,
|
||||||
|
} from "../components/dashboard/DashTodayPlan";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -66,6 +72,7 @@ interface DashData {
|
|||||||
pending_orders?: number;
|
pending_orders?: number;
|
||||||
unpaid_invoices?: number;
|
unpaid_invoices?: number;
|
||||||
pending_leave_requests?: number;
|
pending_leave_requests?: number;
|
||||||
|
today_plan?: TodayPlan[];
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +109,6 @@ export default function Dashboard() {
|
|||||||
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
||||||
const [disableCode, setDisableCode] = useState("");
|
const [disableCode, setDisableCode] = useState("");
|
||||||
|
|
||||||
useModalLock(show2FASetup);
|
|
||||||
useModalLock(show2FADisable);
|
|
||||||
|
|
||||||
// Punch (prichod/odchod) primo z dashboardu
|
// Punch (prichod/odchod) primo z dashboardu
|
||||||
const handleQuickPunch = useCallback(() => {
|
const handleQuickPunch = useCallback(() => {
|
||||||
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
|
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
|
||||||
@@ -221,57 +225,51 @@ export default function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dash">
|
<PageEnter>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<Box sx={{ mb: 3 }}>
|
||||||
className="admin-page-header"
|
<Typography variant="h4">
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25 }}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h1 className="admin-page-title">
|
|
||||||
Vítejte zpět, {user?.fullName || user?.username}
|
Vítejte zpět, {user?.fullName || user?.username}
|
||||||
</h1>
|
</Typography>
|
||||||
<p className="admin-page-subtitle">{getCzechDate()}</p>
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||||
</div>
|
{getCzechDate()}
|
||||||
</motion.div>
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* 2FA Required Banner */}
|
{/* 2FA Required Banner */}
|
||||||
{user?.require2FA && !user?.totpEnabled && (
|
{user?.require2FA && !user?.totpEnabled && (
|
||||||
<motion.div
|
<Card
|
||||||
className="admin-card"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
mb: 3,
|
||||||
animate={{ opacity: 1, y: 0 }}
|
border: 2,
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
borderColor: "error.main",
|
||||||
style={{
|
backgroundColor:
|
||||||
border: "2px solid var(--danger)",
|
"rgba(var(--mui-palette-error-mainChannel) / 0.12)",
|
||||||
background: "var(--danger-light)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Box
|
||||||
className="admin-card-body"
|
sx={{
|
||||||
style={{
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: "1rem",
|
gap: 2,
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-row-gap">
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={[
|
||||||
|
{
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
background: "var(--danger-light)",
|
|
||||||
color: "var(--danger)",
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
},
|
||||||
|
iconBadgeSx("error"),
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
@@ -285,35 +283,33 @@ export default function Dashboard() {
|
|||||||
<line x1="12" y1="9" x2="12" y2="13" />
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</Box>
|
||||||
<div>
|
<Box>
|
||||||
<div className="fw-600">Dvoufaktorové ověření je povinné</div>
|
<Typography sx={{ fontWeight: 600 }}>
|
||||||
<div
|
Dvoufaktorové ověření je povinné
|
||||||
className="text-secondary"
|
</Typography>
|
||||||
style={{ fontSize: "0.875rem" }}
|
<Typography variant="body2" color="text.secondary">
|
||||||
>
|
|
||||||
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
|
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
|
||||||
nemáte přístup k ostatním sekcím systému.
|
nemáte přístup k ostatním sekcím systému.
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
<button
|
<Button
|
||||||
onClick={handleStart2FASetup}
|
onClick={handleStart2FASetup}
|
||||||
disabled={totpSubmitting}
|
disabled={totpSubmitting}
|
||||||
className="admin-btn admin-btn-primary"
|
sx={{ flexShrink: 0 }}
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
>
|
>
|
||||||
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
|
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</motion.div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading spinner */}
|
{/* Loading spinner */}
|
||||||
{dashLoading && (
|
{dashLoading && (
|
||||||
<div className="admin-loading">
|
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
|
||||||
<div className="admin-spinner" />
|
<CircularProgress />
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* KPI cards — only show if user has any admin-level permissions */}
|
{/* KPI cards — only show if user has any admin-level permissions */}
|
||||||
@@ -325,6 +321,13 @@ export default function Dashboard() {
|
|||||||
<DashKpiCards dashData={dashData ?? null} />
|
<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 */}
|
{/* Quick actions */}
|
||||||
{!dashLoading && (
|
{!dashLoading && (
|
||||||
<DashQuickActions
|
<DashQuickActions
|
||||||
@@ -336,11 +339,14 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{/* Main content grid */}
|
{/* Main content grid */}
|
||||||
{!dashLoading && (
|
{!dashLoading && (
|
||||||
<motion.div
|
<Box
|
||||||
className="dash-main-grid"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "grid",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr 1fr" },
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
gap: 3,
|
||||||
|
alignItems: "start",
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{hasPermission("settings.audit") && (
|
{hasPermission("settings.audit") && (
|
||||||
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
|
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
|
||||||
@@ -351,86 +357,146 @@ export default function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pravy sloupec: projekty + nabidky */}
|
{/* Pravy sloupec: projekty + nabidky */}
|
||||||
<div className="dash-right-col">
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
|
||||||
{dashData?.projects && (
|
{dashData?.projects && (
|
||||||
<div className="admin-card">
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
<div className="admin-card-header flex-between">
|
<Box
|
||||||
<h2 className="admin-card-title">Aktivní projekty</h2>
|
sx={{
|
||||||
<Link
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Aktivní projekty</Typography>
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
to="/projects"
|
to="/projects"
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
Vše →
|
Vše →
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
|
||||||
{dashData.projects.active_projects.length === 0 && (
|
{dashData.projects.active_projects.length === 0 && (
|
||||||
<div className="dash-empty-row">Žádné aktivní projekty</div>
|
<Typography
|
||||||
)}
|
variant="body2"
|
||||||
{dashData.projects.active_projects.map(
|
color="text.secondary"
|
||||||
(p: {
|
sx={{ py: 1 }}
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
customer_name: string | null;
|
|
||||||
}) => (
|
|
||||||
<Link
|
|
||||||
key={p.id}
|
|
||||||
to={`/projects/${p.id}`}
|
|
||||||
className="dash-project-row"
|
|
||||||
>
|
>
|
||||||
<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 && (
|
{p.customer_name && (
|
||||||
<div className="dash-project-customer">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{p.customer_name}
|
{p.customer_name}
|
||||||
</div>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Box>
|
||||||
),
|
))}
|
||||||
)}
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{dashData?.offers && (
|
{dashData?.offers && (
|
||||||
<div className="admin-card">
|
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
<div className="admin-card-header flex-between">
|
<Box
|
||||||
<h2 className="admin-card-title">Nabídky</h2>
|
sx={{
|
||||||
<Link
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6">Nabídky</Typography>
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
to="/offers"
|
to="/offers"
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
size="small"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
Zobrazit →
|
Zobrazit →
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
<Box
|
||||||
<div className="dash-stat-row">
|
sx={{
|
||||||
<span>Otevřené</span>
|
display: "flex",
|
||||||
<span className="admin-badge admin-badge-info">
|
alignItems: "center",
|
||||||
{dashData.offers.open_count}
|
justifyContent: "space-between",
|
||||||
</span>
|
py: 1,
|
||||||
</div>
|
borderBottom: 1,
|
||||||
<div className="dash-stat-row">
|
borderColor: "divider",
|
||||||
<span>Převedené na objednávku</span>
|
}}
|
||||||
<span className="admin-badge admin-badge-success">
|
>
|
||||||
{dashData.offers.converted_count}
|
<Typography variant="body2">Otevřené</Typography>
|
||||||
</span>
|
<StatusChip color="info" label={dashData.offers.open_count} />
|
||||||
</div>
|
</Box>
|
||||||
<div className="dash-stat-row">
|
<Box
|
||||||
<span>Zneplatněné</span>
|
sx={{
|
||||||
<span className="admin-badge admin-badge-warning">
|
display: "flex",
|
||||||
{dashData.offers.expired_count}
|
alignItems: "center",
|
||||||
</span>
|
justifyContent: "space-between",
|
||||||
</div>
|
py: 1,
|
||||||
</div>
|
borderBottom: 1,
|
||||||
</div>
|
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>
|
</Box>
|
||||||
</motion.div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Profile + Sessions */}
|
{/* Profile + Sessions */}
|
||||||
{!dashLoading && (
|
{!dashLoading && (
|
||||||
<div className="dash-bottom">
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", lg: "1fr 1fr" },
|
||||||
|
gap: 3,
|
||||||
|
alignItems: "start",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DashProfile
|
<DashProfile
|
||||||
totpEnabled={totpEnabled}
|
totpEnabled={totpEnabled}
|
||||||
totpLoading={totpLoading}
|
totpLoading={totpLoading}
|
||||||
@@ -452,8 +518,8 @@ export default function Dashboard() {
|
|||||||
setDisableCode={setDisableCode}
|
setDisableCode={setDisableCode}
|
||||||
/>
|
/>
|
||||||
<DashSessions />
|
<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
@@ -2,18 +2,34 @@ import { useState } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
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 { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
||||||
import { czechPlural } from "../utils/formatters";
|
import { czechPlural } from "../utils/formatters";
|
||||||
import {
|
import {
|
||||||
leavePendingOptions,
|
leavePendingOptions,
|
||||||
leaveProcessedOptions,
|
leaveProcessedOptions,
|
||||||
} from "../lib/queries/leave";
|
} from "../lib/queries/leave";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import FormModal from "../components/FormModal";
|
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
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";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -23,12 +39,6 @@ const leaveTypeLabels: Record<string, string> = {
|
|||||||
unpaid: "Neplacené volno",
|
unpaid: "Neplacené volno",
|
||||||
};
|
};
|
||||||
|
|
||||||
const leaveTypeClasses: Record<string, string> = {
|
|
||||||
vacation: "badge-vacation",
|
|
||||||
sick: "badge-sick",
|
|
||||||
unpaid: "badge-unpaid",
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
pending: "Čeká na schválení",
|
pending: "Čeká na schválení",
|
||||||
approved: "Schváleno",
|
approved: "Schváleno",
|
||||||
@@ -36,11 +46,23 @@ const statusLabels: Record<string, string> = {
|
|||||||
cancelled: "Zrušeno",
|
cancelled: "Zrušeno",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusClasses: Record<string, string> = {
|
// Leave type → StatusChip color
|
||||||
pending: "badge-pending",
|
const leaveTypeColor: Record<
|
||||||
approved: "badge-approved",
|
string,
|
||||||
rejected: "badge-rejected",
|
"info" | "error" | "default" | "success" | "warning"
|
||||||
cancelled: "badge-cancelled",
|
> = {
|
||||||
|
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 {
|
interface RawLeaveRequest {
|
||||||
@@ -192,290 +214,231 @@ export default function LeaveApproval() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const tabs: TabDef[] = [
|
||||||
<div>
|
{ value: "pending", label: `Ke schválení (${pendingCount})` },
|
||||||
<motion.div
|
{ value: "processed", label: "Vyřízené" },
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Tabs */}
|
const processedColumns: DataColumn<LeaveRequest>[] = [
|
||||||
<motion.div
|
{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
key: "employee_name",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
header: "Zaměstnanec",
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
width: "16%",
|
||||||
>
|
bold: true,
|
||||||
<div className="admin-tabs mb-6">
|
render: (req) => req.employee_name,
|
||||||
<button
|
},
|
||||||
className={`admin-tab ${activeTab === "pending" ? "active" : ""}`}
|
{
|
||||||
onClick={() => setActiveTab("pending")}
|
key: "leave_type",
|
||||||
>
|
header: "Typ",
|
||||||
Ke schválení
|
width: "13%",
|
||||||
{pendingCount > 0 && (
|
render: (req) => (
|
||||||
<span
|
<StatusChip
|
||||||
className="admin-badge badge-pending"
|
label={leaveTypeLabels[req.leave_type] || req.leave_type}
|
||||||
style={{
|
color={leaveTypeColor[req.leave_type] ?? "default"}
|
||||||
marginLeft: "0.5rem",
|
/>
|
||||||
fontSize: "0.7rem",
|
),
|
||||||
padding: "0.15rem 0.5rem",
|
},
|
||||||
}}
|
{
|
||||||
>
|
key: "date_from",
|
||||||
{pendingCount}
|
header: "Od",
|
||||||
</span>
|
width: "10%",
|
||||||
)}
|
mono: true,
|
||||||
</button>
|
render: (req) => formatDate(req.date_from),
|
||||||
<button
|
},
|
||||||
className={`admin-tab ${activeTab === "processed" ? "active" : ""}`}
|
{
|
||||||
onClick={() => setActiveTab("processed")}
|
key: "date_to",
|
||||||
>
|
header: "Do",
|
||||||
Vyřízené
|
width: "10%",
|
||||||
</button>
|
mono: true,
|
||||||
</div>
|
render: (req) => formatDate(req.date_to),
|
||||||
</motion.div>
|
},
|
||||||
|
{
|
||||||
|
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 */}
|
{/* Pending Tab */}
|
||||||
{activeTab === "pending" && (
|
<TabPanel value="pending" current={activeTab}>
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
|
||||||
>
|
|
||||||
{pendingRequests.length === 0 ? (
|
{pendingRequests.length === 0 ? (
|
||||||
<div className="admin-card">
|
<Card>
|
||||||
<div className="admin-card-body">
|
<EmptyState title="Žádné čekající žádosti" />
|
||||||
<div className="admin-empty-state">
|
</Card>
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<div
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pendingRequests.map((req) => (
|
{pendingRequests.map((req) => (
|
||||||
<div key={req.id} className="admin-card">
|
<Card key={req.id}>
|
||||||
<div
|
<Box
|
||||||
className="admin-card-body"
|
sx={{
|
||||||
style={{ padding: "1.25rem" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: "1rem",
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<Box sx={{ flex: 1 }}>
|
||||||
<div className="flex-row-gap mb-2">
|
<Box
|
||||||
<strong style={{ fontSize: "1rem" }}>
|
sx={{
|
||||||
{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={{
|
|
||||||
display: "flex",
|
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",
|
flexWrap: "wrap",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
|
color: "text.secondary",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<Box component="span">
|
||||||
<strong>{formatDate(req.date_from)}</strong> —{" "}
|
<strong>{formatDate(req.date_from)}</strong> —{" "}
|
||||||
<strong>{formatDate(req.date_to)}</strong>
|
<strong>{formatDate(req.date_to)}</strong>
|
||||||
</span>
|
</Box>
|
||||||
<span>
|
<Box component="span">
|
||||||
{req.total_days}{" "}
|
{req.total_days}{" "}
|
||||||
{czechPlural(req.total_days, "den", "dny", "dnů")} (
|
{czechPlural(req.total_days, "den", "dny", "dnů")} (
|
||||||
{req.total_hours}h)
|
{req.total_hours}h)
|
||||||
</span>
|
</Box>
|
||||||
<span className="text-muted">
|
<Box component="span" sx={{ color: "text.disabled" }}>
|
||||||
Podáno: {formatDatetime(req.created_at)}
|
Podáno: {formatDatetime(req.created_at)}
|
||||||
</span>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
{req.notes && (
|
{req.notes && (
|
||||||
<div
|
<Box
|
||||||
className="text-secondary"
|
sx={{
|
||||||
style={{
|
mt: 1,
|
||||||
marginTop: "0.5rem",
|
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
fontStyle: "italic",
|
fontStyle: "italic",
|
||||||
|
color: "text.secondary",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{req.notes}
|
{req.notes}
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
<div
|
<Box sx={{ display: "flex", gap: 1, flexShrink: 0 }}>
|
||||||
style={{
|
<Button
|
||||||
display: "flex",
|
size="small"
|
||||||
gap: "0.5rem",
|
color="success"
|
||||||
flexShrink: 0,
|
variant="outlined"
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setApproveModal({ open: true, request: req })
|
setApproveModal({ open: true, request: req })
|
||||||
}
|
}
|
||||||
className="admin-btn admin-btn-sm"
|
|
||||||
style={{
|
|
||||||
background: "var(--success-light)",
|
|
||||||
color: "var(--success)",
|
|
||||||
border: "none",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Schválit
|
Schválit
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
variant="outlined"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setRejectModal({ open: true, request: req })
|
setRejectModal({ open: true, request: req })
|
||||||
}
|
}
|
||||||
className="admin-btn admin-btn-sm"
|
|
||||||
style={{
|
|
||||||
background: "var(--danger-light)",
|
|
||||||
color: "var(--danger)",
|
|
||||||
border: "none",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Zamítnout
|
Zamítnout
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
{/* Processed Tab */}
|
{/* Processed Tab */}
|
||||||
{activeTab === "processed" && (
|
<TabPanel value="processed" current={activeTab}>
|
||||||
<motion.div
|
<Card>
|
||||||
className="admin-card"
|
<DataTable<LeaveRequest>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
columns={processedColumns}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
rows={processedRequests}
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
rowKey={(req) => req.id}
|
||||||
>
|
empty={<EmptyState title="Zatím žádné vyřízené žádosti" />}
|
||||||
<div className="admin-card-body">
|
/>
|
||||||
{processedRequests.length === 0 ? (
|
</Card>
|
||||||
<div className="admin-empty-state">
|
</TabPanel>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Approve Confirmation */}
|
{/* Approve Confirmation */}
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={approveModal.open}
|
isOpen={approveModal.open}
|
||||||
onClose={() => setApproveModal({ open: false, request: null })}
|
onClose={() => setApproveModal({ open: false, request: null })}
|
||||||
onConfirm={handleApprove}
|
onConfirm={handleApprove}
|
||||||
@@ -486,12 +449,11 @@ export default function LeaveApproval() {
|
|||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
confirmText="Schválit"
|
confirmText="Schválit"
|
||||||
type="info"
|
|
||||||
loading={approveMutation.isPending}
|
loading={approveMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Reject Modal */}
|
{/* Reject Modal */}
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={rejectModal.open && !!rejectModal.request}
|
isOpen={rejectModal.open && !!rejectModal.request}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setRejectModal({ open: false, request: null });
|
setRejectModal({ open: false, request: null });
|
||||||
@@ -499,31 +461,31 @@ export default function LeaveApproval() {
|
|||||||
}}
|
}}
|
||||||
onSubmit={handleReject}
|
onSubmit={handleReject}
|
||||||
title="Zamítnout žádost"
|
title="Zamítnout žádost"
|
||||||
submitLabel="Zamítnout"
|
submitText="Zamítnout"
|
||||||
loading={rejectMutation.isPending}
|
loading={rejectMutation.isPending}
|
||||||
>
|
>
|
||||||
{rejectModal.request && (
|
{rejectModal.request && (
|
||||||
<>
|
<>
|
||||||
<p className="text-secondary mb-4">
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
{rejectModal.request.employee_name} —{" "}
|
{rejectModal.request.employee_name} —{" "}
|
||||||
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
|
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
|
||||||
{formatDate(rejectModal.request.date_from)} —{" "}
|
{formatDate(rejectModal.request.date_from)} —{" "}
|
||||||
{formatDate(rejectModal.request.date_to)} (
|
{formatDate(rejectModal.request.date_to)} (
|
||||||
{rejectModal.request.total_days} dnů)
|
{rejectModal.request.total_days} dnů)
|
||||||
</p>
|
</Typography>
|
||||||
<FormField label="Důvod zamítnutí" required>
|
<Field label="Důvod zamítnutí" required>
|
||||||
<textarea
|
<TextField
|
||||||
value={rejectNote}
|
value={rejectNote}
|
||||||
onChange={(e) => setRejectNote(e.target.value)}
|
onChange={(e) => setRejectNote(e.target.value)}
|
||||||
placeholder="Uveďte důvod zamítnutí..."
|
placeholder="Uveďte důvod zamítnutí..."
|
||||||
className="admin-form-textarea"
|
multiline
|
||||||
rows={3}
|
rows={3}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</FormModal>
|
</Modal>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,23 @@ import { useState } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { motion } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
|
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
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";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -24,19 +35,41 @@ const statusLabels: Record<string, string> = {
|
|||||||
cancelled: "Zrušeno",
|
cancelled: "Zrušeno",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusClasses: Record<string, string> = {
|
// Leave type → StatusChip color
|
||||||
pending: "badge-pending",
|
const leaveTypeColor: Record<
|
||||||
approved: "badge-approved",
|
string,
|
||||||
rejected: "badge-rejected",
|
"info" | "error" | "default" | "success" | "warning"
|
||||||
cancelled: "badge-cancelled",
|
> = {
|
||||||
|
vacation: "info",
|
||||||
|
sick: "error",
|
||||||
|
unpaid: "default",
|
||||||
};
|
};
|
||||||
|
|
||||||
const leaveTypeClasses: Record<string, string> = {
|
// Status → StatusChip color
|
||||||
vacation: "badge-vacation",
|
const statusColor: Record<string, "warning" | "success" | "error" | "default"> =
|
||||||
sick: "badge-sick",
|
{
|
||||||
unpaid: "badge-unpaid",
|
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() {
|
export default function LeaveRequests() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
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) =>
|
const truncate = (text: string) =>
|
||||||
text.length > 40 ? `${text.substring(0, 40)}...` : text;
|
text.length > 40 ? `${text.substring(0, 40)}...` : text;
|
||||||
if (req.status === "rejected" && req.reviewer_note) {
|
if (req.status === "rejected" && req.reviewer_note) {
|
||||||
return (
|
return (
|
||||||
<span
|
<Box
|
||||||
style={{ color: "var(--danger)", fontSize: "0.875rem" }}
|
component="span"
|
||||||
title={req.reviewer_note}
|
title={req.reviewer_note}
|
||||||
|
sx={{
|
||||||
|
color: "error.main",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
display: "block",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{truncate(req.reviewer_note)}
|
{truncate(req.reviewer_note)}
|
||||||
</span>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (req.notes) {
|
if (req.notes) {
|
||||||
return (
|
return (
|
||||||
<span
|
<Box
|
||||||
className="text-secondary"
|
component="span"
|
||||||
style={{ fontSize: "0.875rem" }}
|
|
||||||
title={req.notes}
|
title={req.notes}
|
||||||
|
sx={{
|
||||||
|
color: "text.secondary",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
display: "block",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{truncate(req.notes)}
|
{truncate(req.notes)}
|
||||||
</span>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span className="text-muted">—</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-loading">
|
<Box component="span" sx={{ color: "text.disabled" }}>
|
||||||
<div className="admin-spinner" />
|
—
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
},
|
||||||
return (
|
{
|
||||||
<div>
|
key: "created_at",
|
||||||
<motion.div
|
header: "Podáno",
|
||||||
className="admin-page-header"
|
width: "13%",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
mono: true,
|
||||||
animate={{ opacity: 1, y: 0 }}
|
render: (req) => formatDatetime(req.created_at),
|
||||||
transition={{ duration: 0.25 }}
|
},
|
||||||
>
|
{
|
||||||
<div>
|
key: "actions",
|
||||||
<h1 className="admin-page-title">Moje žádosti</h1>
|
header: "Akce",
|
||||||
<p className="admin-page-subtitle">Přehled žádostí o nepřítomnost</p>
|
width: "8%",
|
||||||
</div>
|
align: "right",
|
||||||
</motion.div>
|
render: (req) =>
|
||||||
|
req.status === "pending" ? (
|
||||||
<motion.div
|
<Button
|
||||||
className="admin-card"
|
size="small"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
variant="outlined"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
startIcon={CancelIcon}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
onClick={() => setCancelModal({ open: true, id: req.id })}
|
||||||
>
|
|
||||||
<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"
|
|
||||||
>
|
>
|
||||||
Zrušit
|
Zrušit
|
||||||
</button>
|
</Button>
|
||||||
)}
|
) : null,
|
||||||
</div>
|
},
|
||||||
</td>
|
];
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<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}
|
isOpen={cancelModal.open}
|
||||||
onClose={() => setCancelModal({ open: false, id: null })}
|
onClose={() => setCancelModal({ open: false, id: null })}
|
||||||
onConfirm={handleCancel}
|
onConfirm={handleCancel}
|
||||||
title="Zrušit žádost"
|
title="Zrušit žádost"
|
||||||
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
|
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
|
||||||
confirmText="Zrušit žádost"
|
confirmText="Zrušit žádost"
|
||||||
type="warning"
|
|
||||||
loading={cancelMutation.isPending}
|
loading={cancelMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
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 { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useTheme } from "../../context/ThemeContext";
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
@@ -8,7 +14,13 @@ import {
|
|||||||
shouldShowSessionExpiredAlert,
|
shouldShowSessionExpiredAlert,
|
||||||
shouldShowLogoutAlert,
|
shouldShowLogoutAlert,
|
||||||
} from "../utils/api";
|
} 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() {
|
export default function Login() {
|
||||||
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth();
|
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth();
|
||||||
@@ -132,11 +144,17 @@ export default function Login() {
|
|||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="admin-login">
|
<Box
|
||||||
<div className="admin-loading">
|
sx={{
|
||||||
<div className="admin-spinner" />
|
minHeight: ["100vh", "100dvh"],
|
||||||
</div>
|
display: "flex",
|
||||||
</div>
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: "background.default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,32 +162,120 @@ export default function Login() {
|
|||||||
return <Navigate to="/" replace />;
|
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 (
|
return (
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-login"
|
component={motion.div}
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
animate={
|
animate={
|
||||||
animatingOut
|
animatingOut
|
||||||
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
||||||
: { scale: 1, opacity: 1, filter: "none" }
|
: { scale: 1, opacity: 1, filter: "none" }
|
||||||
}
|
}
|
||||||
transition={
|
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
||||||
animatingOut
|
sx={{
|
||||||
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
|
minHeight: ["100vh", "100dvh"],
|
||||||
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
|
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" />
|
{/* Animated background orbs (replaces .bg-orb) */}
|
||||||
<div className="bg-orb bg-orb-2" />
|
<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}
|
onClick={toggleTheme}
|
||||||
className="admin-login-theme-btn"
|
|
||||||
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
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
|
{theme === "light" ? (
|
||||||
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
height="20"
|
height="20"
|
||||||
@@ -181,10 +287,7 @@ export default function Login() {
|
|||||||
<circle cx="12" cy="12" r="5" />
|
<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" />
|
<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>
|
</svg>
|
||||||
</span>
|
) : (
|
||||||
<span
|
|
||||||
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
width="20"
|
width="20"
|
||||||
height="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" />
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
)}
|
||||||
</button>
|
</IconButton>
|
||||||
|
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
{!show2FA ? (
|
{!show2FA ? (
|
||||||
<motion.div
|
<Box key="login" component={motion.div} {...cardMotion} sx={cardSx}>
|
||||||
key="login"
|
<Box sx={{ textAlign: "center", mb: 4 }}>
|
||||||
className="admin-login-card"
|
<Box
|
||||||
initial={{ opacity: 0, y: 30 }}
|
component="img"
|
||||||
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
|
|
||||||
src={
|
src={
|
||||||
theme === "dark"
|
theme === "dark"
|
||||||
? "/api/admin/company-settings/logo?variant=dark"
|
? "/api/admin/company-settings/logo?variant=dark"
|
||||||
: "/api/admin/company-settings/logo?variant=light"
|
: "/api/admin/company-settings/logo?variant=light"
|
||||||
}
|
}
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
className="admin-login-logo"
|
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
(e.target as HTMLImageElement).src =
|
(e.target as HTMLImageElement).src =
|
||||||
theme === "dark"
|
theme === "dark"
|
||||||
? "/images/logo-dark.png"
|
? "/images/logo-dark.png"
|
||||||
: "/images/logo-light.png";
|
: "/images/logo-light.png";
|
||||||
}}
|
}}
|
||||||
|
sx={{ height: 48, width: "auto", mb: 2 }}
|
||||||
/>
|
/>
|
||||||
<h1 className="admin-login-title">Interní systém</h1>
|
<Typography
|
||||||
<p className="admin-login-subtitle">Přihlaste se ke svému účtu</p>
|
variant="h1"
|
||||||
</div>
|
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">
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
<FormField label="Uživatelské jméno nebo e-mail">
|
<Field label="Uživatelské jméno nebo e-mail">
|
||||||
<input
|
<TextField
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Zadejte uživatelské jméno"
|
placeholder="Zadejte uživatelské jméno"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Heslo">
|
<Field label="Heslo">
|
||||||
<input
|
<TextField
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Zadejte heslo"
|
placeholder="Zadejte heslo"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<label className="admin-form-checkbox">
|
<Box sx={{ mb: 2 }}>
|
||||||
<input
|
<CheckboxField
|
||||||
type="checkbox"
|
label="Zapamatovat si mě"
|
||||||
checked={remember}
|
checked={remember}
|
||||||
onChange={(e) => setRemember(e.target.checked)}
|
onChange={setRemember}
|
||||||
/>
|
/>
|
||||||
<span>Zapamatovat si mě</span>
|
</Box>
|
||||||
</label>
|
|
||||||
|
|
||||||
<button
|
<Button type="submit" disabled={loading} fullWidth>
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<CircularProgress
|
||||||
className="admin-spinner"
|
size={20}
|
||||||
style={{ width: 20, height: 20, borderWidth: 2 }}
|
thickness={5}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
/>
|
/>
|
||||||
Přihlašování...
|
Přihlašování...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Přihlásit se"
|
"Přihlásit se"
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</Box>
|
||||||
</motion.div>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<Box key="2fa" component={motion.div} {...cardMotion} sx={cardSx}>
|
||||||
key="2fa"
|
<Box sx={{ textAlign: "center", mb: 4 }}>
|
||||||
className="admin-login-card"
|
<Box
|
||||||
initial={{ opacity: 0, y: 30 }}
|
sx={{
|
||||||
animate={
|
display: "inline-flex",
|
||||||
shake
|
alignItems: "center",
|
||||||
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
|
justifyContent: "center",
|
||||||
: { opacity: 1, y: 0 }
|
width: 56,
|
||||||
}
|
height: 56,
|
||||||
exit={{ opacity: 0, y: -20 }}
|
borderRadius: "50%",
|
||||||
transition={
|
background:
|
||||||
shake
|
"rgba(var(--mui-palette-primary-mainChannel) / 0.1)",
|
||||||
? { x: { duration: 0.5, ease: "easeOut" } }
|
color: "primary.main",
|
||||||
: { duration: 0.3 }
|
mb: 2,
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-login-header">
|
|
||||||
<div className="admin-login-2fa-icon">
|
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
@@ -322,26 +415,31 @@ export default function Login() {
|
|||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</Box>
|
||||||
<h1 className="admin-login-title">Dvoufaktorové ověření</h1>
|
<Typography
|
||||||
<p className="admin-login-subtitle">
|
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
|
{useBackupCode
|
||||||
? "Zadejte jeden ze záložních kódů"
|
? "Zadejte jeden ze záložních kódů"
|
||||||
: "Zadejte 6místný kód z autentizační aplikace"}
|
: "Zadejte 6místný kód z autentizační aplikace"}
|
||||||
</p>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<form onSubmit={handle2FASubmit} className="admin-form">
|
<Box component="form" onSubmit={handle2FASubmit}>
|
||||||
<FormField
|
<Field label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}>
|
||||||
label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}
|
<TextField
|
||||||
>
|
inputRef={totpInputRef}
|
||||||
<input
|
|
||||||
ref={totpInputRef}
|
|
||||||
id="totp-code"
|
id="totp-code"
|
||||||
type="text"
|
type="text"
|
||||||
inputMode={useBackupCode ? "text" : "numeric"}
|
|
||||||
pattern={useBackupCode ? undefined : "[0-9]*"}
|
|
||||||
maxLength={useBackupCode ? 8 : 6}
|
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = useBackupCode
|
const val = useBackupCode
|
||||||
@@ -351,80 +449,93 @@ export default function Login() {
|
|||||||
}}
|
}}
|
||||||
required
|
required
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
className="admin-form-input"
|
|
||||||
placeholder={useBackupCode ? "XXXXXXXX" : "000000"}
|
placeholder={useBackupCode ? "XXXXXXXX" : "000000"}
|
||||||
style={
|
slotProps={{
|
||||||
useBackupCode
|
htmlInput: {
|
||||||
|
inputMode: useBackupCode ? "text" : "numeric",
|
||||||
|
pattern: useBackupCode ? undefined : "[0-9]*",
|
||||||
|
maxLength: useBackupCode ? 8 : 6,
|
||||||
|
style: useBackupCode
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
letterSpacing: "0.5rem",
|
letterSpacing: "0.5rem",
|
||||||
fontFamily: "monospace",
|
fontFamily: "monospace",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<button
|
<Button type="submit" disabled={loading} fullWidth>
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<CircularProgress
|
||||||
className="admin-spinner"
|
size={20}
|
||||||
style={{ width: 20, height: 20, borderWidth: 2 }}
|
thickness={5}
|
||||||
|
color="inherit"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
/>
|
/>
|
||||||
Ověřování...
|
Ověřování...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Ověřit"
|
"Ověřit"
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</Box>
|
||||||
|
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "0.5rem",
|
gap: 1,
|
||||||
marginTop: "0.5rem",
|
mt: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
|
variant="text"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUseBackupCode(!useBackupCode);
|
setUseBackupCode(!useBackupCode);
|
||||||
setTotpCode("");
|
setTotpCode("");
|
||||||
}}
|
}}
|
||||||
className="admin-back-link"
|
sx={{
|
||||||
style={{
|
color: "text.secondary",
|
||||||
border: "none",
|
fontSize: "0.875rem",
|
||||||
background: "none",
|
fontWeight: 400,
|
||||||
cursor: "pointer",
|
textTransform: "none",
|
||||||
|
"&:hover": {
|
||||||
|
color: "text.primary",
|
||||||
|
textDecoration: "underline",
|
||||||
|
background: "transparent",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{useBackupCode
|
{useBackupCode
|
||||||
? "Použít autentizační aplikaci"
|
? "Použít autentizační aplikaci"
|
||||||
: "Použít záložní kód"}
|
: "Použít záložní kód"}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="text"
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
className="admin-back-link"
|
sx={{
|
||||||
style={{
|
color: "text.secondary",
|
||||||
border: "none",
|
fontSize: "0.875rem",
|
||||||
background: "none",
|
fontWeight: 400,
|
||||||
cursor: "pointer",
|
textTransform: "none",
|
||||||
|
"&:hover": {
|
||||||
|
color: "text.primary",
|
||||||
|
textDecoration: "underline",
|
||||||
|
background: "transparent",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
← Zpět na přihlášení
|
← Zpět na přihlášení
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</motion.div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,28 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { Button } from "../ui";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-empty-state"
|
sx={{
|
||||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
display: "flex",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
flexDirection: "column",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
alignItems: "center",
|
||||||
transition={{ duration: 0.25 }}
|
justifyContent: "center",
|
||||||
>
|
minHeight: "60vh",
|
||||||
<div
|
gap: 2,
|
||||||
className="admin-empty-icon"
|
textAlign: "center",
|
||||||
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)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Typography variant="h1" sx={{ fontSize: "6rem", fontWeight: 700 }}>
|
||||||
404
|
404
|
||||||
</h2>
|
</Typography>
|
||||||
<p>Stránka nebyla nalezena.</p>
|
<Typography variant="body1">Stránka nebyla nalezena.</Typography>
|
||||||
<Link
|
<Button component={RouterLink} to="/" sx={{ mt: 0.5 }}>
|
||||||
to="/"
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
style={{ marginTop: "0.5rem" }}
|
|
||||||
>
|
|
||||||
Zpět na Dashboard
|
Zpět na Dashboard
|
||||||
</Link>
|
</Button>
|
||||||
</motion.div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
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 Forbidden from "../components/Forbidden";
|
||||||
import {
|
import {
|
||||||
offerCustomersOptions,
|
offerCustomersOptions,
|
||||||
@@ -13,6 +12,23 @@ import {
|
|||||||
type CustomField,
|
type CustomField,
|
||||||
} from "../lib/queries/offers";
|
} from "../lib/queries/offers";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
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";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -42,6 +58,100 @@ interface CustomerForm {
|
|||||||
vat_id: string;
|
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() {
|
export default function OffersCustomers() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
@@ -261,230 +371,195 @@ export default function OffersCustomers() {
|
|||||||
const fullFieldOrder = getFullFieldOrder();
|
const fullFieldOrder = getFullFieldOrder();
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
<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">
|
|
||||||
{hasPermission("customers.edit") && (
|
{hasPermission("customers.edit") && (
|
||||||
<button
|
<IconButton
|
||||||
onClick={() => openEditModal(customer)}
|
size="small"
|
||||||
className="admin-btn-icon"
|
onClick={() => openEditModal(c)}
|
||||||
title="Upravit"
|
|
||||||
aria-label="Upravit"
|
aria-label="Upravit"
|
||||||
|
title="Upravit"
|
||||||
>
|
>
|
||||||
<svg
|
{EditIcon}
|
||||||
width="18"
|
</IconButton>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
{hasPermission("customers.delete") && (
|
{hasPermission("customers.delete") && (
|
||||||
<button
|
// Disabled buttons don't fire title/tooltip, so wrap in a titled
|
||||||
onClick={() =>
|
// span when delete is blocked (customer still has quotations).
|
||||||
setDeleteConfirm({ show: true, customer })
|
<Box
|
||||||
}
|
component="span"
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title={
|
title={
|
||||||
customer.quotation_count > 0
|
cannotDelete ? "Nelze smazat zákazníka s nabídkami" : "Smazat"
|
||||||
? "Nelze smazat zákazníka s nabídkami"
|
|
||||||
: "Smazat"
|
|
||||||
}
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setDeleteConfirm({ show: true, customer: c })}
|
||||||
aria-label={
|
aria-label={
|
||||||
customer.quotation_count > 0
|
cannotDelete
|
||||||
? "Nelze smazat zákazníka s nabídkami"
|
? "Nelze smazat zákazníka s nabídkami"
|
||||||
: "Smazat"
|
: "Smazat"
|
||||||
}
|
}
|
||||||
disabled={customer.quotation_count > 0}
|
disabled={cannotDelete}
|
||||||
>
|
>
|
||||||
<svg
|
{DeleteIcon}
|
||||||
width="18"
|
</IconButton>
|
||||||
height="18"
|
</Box>
|
||||||
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>
|
</Box>
|
||||||
</td>
|
);
|
||||||
</tr>
|
},
|
||||||
))}
|
},
|
||||||
</tbody>
|
];
|
||||||
</table>
|
|
||||||
</div>
|
return (
|
||||||
)}
|
<PageEnter>
|
||||||
</div>
|
<PageHeader
|
||||||
</motion.div>
|
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 */}
|
{/* Create/Edit Modal */}
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={closeModal}
|
onClose={closeModal}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
title={editingCustomer ? "Upravit zákazníka" : "Nový zákazník"}
|
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}
|
loading={saving}
|
||||||
size="lg"
|
maxWidth="md"
|
||||||
>
|
>
|
||||||
<div className="admin-form">
|
<Field label="Název" required>
|
||||||
<FormField label="Název" required>
|
<TextField
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Název firmy / jméno"
|
placeholder="Název firmy / jméno"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Ulice">
|
<Field label="Ulice">
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
value={form.street}
|
value={form.street}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((prev) => ({ ...prev, street: e.target.value }))
|
setForm((prev) => ({ ...prev, street: e.target.value }))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<FormField label="Město">
|
sx={{
|
||||||
<input
|
display: "grid",
|
||||||
type="text"
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Město">
|
||||||
|
<TextField
|
||||||
value={form.city}
|
value={form.city}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((prev) => ({ ...prev, city: e.target.value }))
|
setForm((prev) => ({ ...prev, city: e.target.value }))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="PSČ">
|
<Field label="PSČ">
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
value={form.postal_code}
|
value={form.postal_code}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
@@ -492,24 +567,26 @@ export default function OffersCustomers() {
|
|||||||
postal_code: e.target.value,
|
postal_code: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
<FormField label="Země">
|
<Field label="Země">
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
value={form.country}
|
value={form.country}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((prev) => ({ ...prev, country: e.target.value }))
|
setForm((prev) => ({ ...prev, country: e.target.value }))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<FormField label="IČO">
|
sx={{
|
||||||
<input
|
display: "grid",
|
||||||
type="text"
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="IČO">
|
||||||
|
<TextField
|
||||||
value={form.company_id}
|
value={form.company_id}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
@@ -517,41 +594,43 @@ export default function OffersCustomers() {
|
|||||||
company_id: e.target.value,
|
company_id: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="DIČ">
|
<Field label="DIČ">
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
value={form.vat_id}
|
value={form.vat_id}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((prev) => ({ ...prev, vat_id: e.target.value }))
|
setForm((prev) => ({ ...prev, vat_id: e.target.value }))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
{/* Dynamic custom fields */}
|
{/* Dynamic custom fields */}
|
||||||
<div style={{ marginTop: 4 }}>
|
<Box sx={{ mt: 0.5 }}>
|
||||||
<label
|
<Typography
|
||||||
className="admin-form-label"
|
variant="body2"
|
||||||
style={{ display: "block", marginBottom: 4 }}
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
mb: 0.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Vlastní pole
|
Vlastní pole
|
||||||
</label>
|
</Typography>
|
||||||
{customFields.map((field, idx) => (
|
{customFields.map((field, idx) => (
|
||||||
<div key={field._key} style={{ marginBottom: 8 }}>
|
<Box key={field._key} sx={{ mb: 1 }}>
|
||||||
<div
|
<Box
|
||||||
className="admin-form-row"
|
sx={{
|
||||||
style={{ marginBottom: 0, alignItems: "flex-end" }}
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
alignItems: "flex-start",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<FormField
|
<TextField
|
||||||
label={idx === 0 ? "Název" : " "}
|
label={idx === 0 ? "Název" : undefined}
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={field.name}
|
value={field.name}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const updated = [...customFields];
|
const updated = [...customFields];
|
||||||
@@ -561,23 +640,17 @@ export default function OffersCustomers() {
|
|||||||
};
|
};
|
||||||
setCustomFields(updated);
|
setCustomFields(updated);
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Např. Kontakt"
|
placeholder="Např. Kontakt"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
<Box
|
||||||
<FormField
|
sx={{
|
||||||
label={idx === 0 ? "Hodnota" : " "}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 4,
|
gap: 0.5,
|
||||||
alignItems: "center",
|
alignItems: idx === 0 ? "flex-end" : "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
label={idx === 0 ? "Hodnota" : undefined}
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const updated = [...customFields];
|
const updated = [...customFields];
|
||||||
@@ -587,11 +660,11 @@ export default function OffersCustomers() {
|
|||||||
};
|
};
|
||||||
setCustomFields(updated);
|
setCustomFields(updated);
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
sx={{ flex: 1 }}
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
size="small"
|
||||||
|
color="error"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const key = `custom_${idx}`;
|
const key = `custom_${idx}`;
|
||||||
setFieldOrder((prev) => {
|
setFieldOrder((prev) => {
|
||||||
@@ -605,50 +678,40 @@ export default function OffersCustomers() {
|
|||||||
return k;
|
return k;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
setCustomFields(
|
setCustomFields(customFields.filter((_, i) => i !== idx));
|
||||||
customFields.filter((_, i) => i !== idx),
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Odebrat pole"
|
title="Odebrat pole"
|
||||||
aria-label="Odebrat pole"
|
aria-label="Odebrat pole"
|
||||||
>
|
>
|
||||||
<svg
|
{RemoveIcon}
|
||||||
width="14"
|
</IconButton>
|
||||||
height="14"
|
</Box>
|
||||||
viewBox="0 0 24 24"
|
</Box>
|
||||||
fill="none"
|
<Box sx={{ mt: 0.5 }}>
|
||||||
stroke="currentColor"
|
<CheckboxField
|
||||||
strokeWidth="2"
|
label={
|
||||||
>
|
<Typography variant="body2" sx={{ fontSize: "0.8rem" }}>
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
Zobrazit název v PDF
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
</Typography>
|
||||||
</svg>
|
}
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<label className="admin-form-checkbox" style={{ marginTop: 4 }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={field.showLabel !== false}
|
checked={field.showLabel !== false}
|
||||||
onChange={(e) => {
|
onChange={(checked) => {
|
||||||
const updated = [...customFields];
|
const updated = [...customFields];
|
||||||
updated[idx] = {
|
updated[idx] = {
|
||||||
...updated[idx],
|
...updated[idx],
|
||||||
showLabel: e.target.checked,
|
showLabel: checked,
|
||||||
};
|
};
|
||||||
setCustomFields(updated);
|
setCustomFields(updated);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: "0.8rem" }}>
|
</Box>
|
||||||
Zobrazit název v PDF
|
</Box>
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
startIcon={SmallPlusIcon}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setCustomFields([
|
setCustomFields([
|
||||||
...customFields,
|
...customFields,
|
||||||
@@ -660,90 +723,83 @@ export default function OffersCustomers() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
className="admin-btn admin-btn-secondary"
|
sx={{ mt: 0.5 }}
|
||||||
style={{ marginTop: 4, fontSize: "0.85rem" }}
|
|
||||||
>
|
>
|
||||||
<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
|
Přidat pole
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
{/* Field order for PDF */}
|
{/* Field order for PDF */}
|
||||||
<div style={{ marginTop: 16 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<label className="admin-form-label">Pořadí polí v PDF</label>
|
<Typography
|
||||||
<small
|
variant="body2"
|
||||||
className="admin-form-hint"
|
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||||
style={{ display: "block", marginBottom: 8 }}
|
>
|
||||||
|
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.
|
Určuje pořadí řádků v adresním bloku zákazníka na PDF nabídce.
|
||||||
</small>
|
</Typography>
|
||||||
<div className="admin-reorder-list">
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||||
{fullFieldOrder.map((key, index) => (
|
{fullFieldOrder.map((key, index) => (
|
||||||
<div key={key} className="admin-reorder-item">
|
<Box
|
||||||
<div className="admin-reorder-arrows">
|
key={key}
|
||||||
<button
|
sx={{
|
||||||
type="button"
|
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)}
|
onClick={() => moveField(index, -1)}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Nahoru"
|
title="Nahoru"
|
||||||
aria-label="Nahoru"
|
aria-label="Nahoru"
|
||||||
|
sx={{ p: 0.25 }}
|
||||||
>
|
>
|
||||||
<svg
|
{UpIcon}
|
||||||
width="12"
|
</IconButton>
|
||||||
height="12"
|
<IconButton
|
||||||
viewBox="0 0 24 24"
|
size="small"
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M18 15l-6-6-6 6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => moveField(index, 1)}
|
onClick={() => moveField(index, 1)}
|
||||||
disabled={index === fullFieldOrder.length - 1}
|
disabled={index === fullFieldOrder.length - 1}
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Dolů"
|
title="Dolů"
|
||||||
aria-label="Dolů"
|
aria-label="Dolů"
|
||||||
|
sx={{ p: 0.25 }}
|
||||||
>
|
>
|
||||||
<svg
|
{DownIcon}
|
||||||
width="12"
|
</IconButton>
|
||||||
height="12"
|
</Box>
|
||||||
viewBox="0 0 24 24"
|
<Typography
|
||||||
fill="none"
|
variant="body2"
|
||||||
stroke="currentColor"
|
sx={{
|
||||||
strokeWidth="2"
|
color: key.startsWith("custom_")
|
||||||
>
|
? "primary.main"
|
||||||
<path d="M6 9l6 6 6-6" />
|
: "text.primary",
|
||||||
</svg>
|
fontWeight: key.startsWith("custom_") ? 600 : 400,
|
||||||
</button>
|
}}
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`admin-reorder-label${key.startsWith("custom_") ? " accent" : ""}`}
|
|
||||||
>
|
>
|
||||||
{getFieldDisplayName(key)}
|
{getFieldDisplayName(key)}
|
||||||
</span>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Modal>
|
||||||
</FormModal>
|
|
||||||
|
|
||||||
{/* Delete Confirm Modal */}
|
{/* Delete Confirm Modal */}
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => setDeleteConfirm({ show: false, customer: null })}
|
onClose={() => setDeleteConfirm({ show: false, customer: null })}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
@@ -751,9 +807,9 @@ export default function OffersCustomers() {
|
|||||||
message={`Opravdu chcete smazat zákazníka "${deleteConfirm.customer?.name}"? Tato akce je nevratná.`}
|
message={`Opravdu chcete smazat zákazníka "${deleteConfirm.customer?.name}"? Tato akce je nevratná.`}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
type="danger"
|
confirmVariant="danger"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useState, useRef, type ReactNode } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
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 Forbidden from "../components/Forbidden";
|
||||||
import RichEditor from "../components/RichEditor";
|
import RichEditor from "../components/RichEditor";
|
||||||
import {
|
import {
|
||||||
@@ -16,8 +15,25 @@ import {
|
|||||||
type ScopeSection,
|
type ScopeSection,
|
||||||
} from "../lib/queries/offers";
|
} from "../lib/queries/offers";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
|
||||||
import apiFetch from "../utils/api";
|
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";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -33,45 +49,118 @@ interface ScopeForm {
|
|||||||
sections: ScopeSection[];
|
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() {
|
export default function OffersTemplates() {
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const [activeTab, setActiveTab] = useState<"items" | "scopes">("items");
|
const [activeTab, setActiveTab] = useState<"items" | "scopes">("items");
|
||||||
|
|
||||||
if (!hasPermission("settings.templates")) return <Forbidden />;
|
if (!hasPermission("settings.templates")) return <Forbidden />;
|
||||||
|
|
||||||
|
const tabs: TabDef[] = [
|
||||||
|
{ value: "items", label: "Šablony položek" },
|
||||||
|
{ value: "scopes", label: "Šablony rozsahu" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<PageEnter>
|
||||||
<motion.div
|
<PageHeader
|
||||||
className="admin-page-header"
|
title="Šablony"
|
||||||
initial={{ opacity: 0, y: 12 }}
|
subtitle="Šablony položek a rozsahu projektu"
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="admin-tabs mb-4">
|
<Tabs
|
||||||
<button
|
value={activeTab}
|
||||||
className={`admin-tab ${activeTab === "items" ? "active" : ""}`}
|
onChange={(v) => setActiveTab(v as "items" | "scopes")}
|
||||||
onClick={() => setActiveTab("items")}
|
tabs={tabs}
|
||||||
>
|
/>
|
||||||
Šablony položek
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`admin-tab ${activeTab === "scopes" ? "active" : ""}`}
|
|
||||||
onClick={() => setActiveTab("scopes")}
|
|
||||||
>
|
|
||||||
Šablony rozsahu
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === "items" ? <ItemTemplatesTab /> : <ScopeTemplatesTab />}
|
<TabPanel value="items" current={activeTab}>
|
||||||
</div>
|
<ItemTemplatesTab />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="scopes" current={activeTab}>
|
||||||
|
<ScopeTemplatesTab />
|
||||||
|
</TabPanel>
|
||||||
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,155 +258,135 @@ function ItemTemplatesTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
<>
|
const columns: DataColumn<ItemTemplate>[] = [
|
||||||
<motion.div
|
{
|
||||||
className="admin-card"
|
key: "name",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
header: "Název",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
width: "26%",
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
bold: true,
|
||||||
>
|
render: (t) => t.name,
|
||||||
<div className="admin-card-header flex-between">
|
},
|
||||||
<h3 className="admin-card-title">
|
{
|
||||||
Šablony položek ({templates.length})
|
key: "description",
|
||||||
</h3>
|
header: "Popis",
|
||||||
<button
|
width: "30%",
|
||||||
onClick={openCreate}
|
render: (t) => (
|
||||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||||
>
|
|
||||||
<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)" }}>
|
|
||||||
{t.description || "—"}
|
{t.description || "—"}
|
||||||
</td>
|
</Box>
|
||||||
<td>{Number(t.default_price).toFixed(2)}</td>
|
),
|
||||||
<td style={{ color: "var(--text-secondary)" }}>
|
},
|
||||||
|
{
|
||||||
|
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 || "—"}
|
{t.category || "—"}
|
||||||
</td>
|
</Box>
|
||||||
<td>
|
),
|
||||||
<div className="admin-table-actions">
|
},
|
||||||
<button
|
{
|
||||||
|
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)}
|
onClick={() => openEdit(t)}
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Upravit"
|
|
||||||
aria-label="Upravit"
|
aria-label="Upravit"
|
||||||
|
title="Upravit"
|
||||||
>
|
>
|
||||||
<svg
|
{EditIcon}
|
||||||
width="18"
|
</IconButton>
|
||||||
height="18"
|
<IconButton
|
||||||
viewBox="0 0 24 24"
|
size="small"
|
||||||
fill="none"
|
color="error"
|
||||||
stroke="currentColor"
|
onClick={() => setDeleteConfirm({ show: true, template: t })}
|
||||||
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"
|
|
||||||
aria-label="Smazat"
|
aria-label="Smazat"
|
||||||
|
title="Smazat"
|
||||||
>
|
>
|
||||||
<svg
|
{DeleteIcon}
|
||||||
width="18"
|
</IconButton>
|
||||||
height="18"
|
</Box>
|
||||||
viewBox="0 0 24 24"
|
),
|
||||||
fill="none"
|
},
|
||||||
stroke="currentColor"
|
];
|
||||||
strokeWidth="2"
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<polyline points="3 6 5 6 21 6" />
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
<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" />
|
Šablony položek ({templates.length})
|
||||||
</svg>
|
</Typography>
|
||||||
</button>
|
<Button size="small" startIcon={PlusIcon} onClick={openCreate}>
|
||||||
</div>
|
Přidat
|
||||||
</td>
|
</Button>
|
||||||
</tr>
|
</Box>
|
||||||
))}
|
|
||||||
</tbody>
|
<Card>
|
||||||
</table>
|
<DataTable<ItemTemplate>
|
||||||
</div>
|
columns={columns}
|
||||||
)}
|
rows={templates}
|
||||||
</div>
|
rowKey={(t) => t.id}
|
||||||
</motion.div>
|
empty={<EmptyState title="Zatím žádné šablony položek." />}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Item Template Modal */}
|
{/* Item Template Modal */}
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
title={editingTemplate ? "Upravit šablonu" : "Nová šablona položky"}
|
title={editingTemplate ? "Upravit šablonu" : "Nová šablona položky"}
|
||||||
submitLabel={editingTemplate ? "Uložit" : "Vytvořit"}
|
submitText={editingTemplate ? "Uložit" : "Vytvořit"}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
|
maxWidth="md"
|
||||||
>
|
>
|
||||||
<div className="admin-form">
|
<Field label="Název" required>
|
||||||
<FormField label="Název" required>
|
<TextField
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Popis">
|
<Field label="Popis">
|
||||||
<textarea
|
<TextField
|
||||||
value={form.description}
|
value={form.description}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((p) => ({
|
setForm((p) => ({ ...p, description: e.target.value }))
|
||||||
...p,
|
|
||||||
description: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
multiline
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<FormField label="Výchozí cena">
|
sx={{
|
||||||
<input
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Výchozí cena">
|
||||||
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
value={form.default_price}
|
value={form.default_price}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -326,26 +395,21 @@ function ItemTemplatesTab() {
|
|||||||
default_price: parseFloat(e.target.value),
|
default_price: parseFloat(e.target.value),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
slotProps={{ htmlInput: { step: "0.01", inputMode: "decimal" } }}
|
||||||
step="0.01"
|
|
||||||
inputMode="decimal"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Kategorie">
|
<Field label="Kategorie">
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
|
||||||
value={form.category}
|
value={form.category}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm((p) => ({ ...p, category: e.target.value }))
|
setForm((p) => ({ ...p, category: e.target.value }))
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Modal>
|
||||||
</FormModal>
|
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => setDeleteConfirm({ show: false, template: null })}
|
onClose={() => setDeleteConfirm({ show: false, template: null })}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
@@ -353,10 +417,10 @@ function ItemTemplatesTab() {
|
|||||||
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
|
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
type="danger"
|
confirmVariant="danger"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
</>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,268 +597,256 @@ function ScopeTemplatesTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
if (isPending) {
|
||||||
<>
|
return <LoadingState />;
|
||||||
<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 })
|
|
||||||
}
|
}
|
||||||
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"
|
aria-label="Smazat"
|
||||||
|
title="Smazat"
|
||||||
>
|
>
|
||||||
<svg
|
{DeleteIcon}
|
||||||
width="18"
|
</IconButton>
|
||||||
height="18"
|
</Box>
|
||||||
viewBox="0 0 24 24"
|
),
|
||||||
fill="none"
|
},
|
||||||
stroke="currentColor"
|
];
|
||||||
strokeWidth="2"
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<polyline points="3 6 5 6 21 6" />
|
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||||
<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" />
|
Šablony rozsahu ({templates.length})
|
||||||
</svg>
|
</Typography>
|
||||||
</button>
|
<Button size="small" startIcon={PlusIcon} onClick={openCreate}>
|
||||||
</div>
|
Přidat
|
||||||
</td>
|
</Button>
|
||||||
</tr>
|
</Box>
|
||||||
))}
|
|
||||||
</tbody>
|
<Card>
|
||||||
</table>
|
<DataTable<ScopeTemplate>
|
||||||
</div>
|
columns={columns}
|
||||||
)}
|
rows={templates}
|
||||||
</div>
|
rowKey={(t) => t.id}
|
||||||
</motion.div>
|
empty={<EmptyState title="Zatím žádné šablony rozsahu." />}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Scope Template Modal (large) */}
|
{/* Scope Template Modal (large) */}
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
title={
|
title={
|
||||||
editingTemplate ? "Upravit šablonu rozsahu" : "Nová šablona rozsahu"
|
editingTemplate ? "Upravit šablonu rozsahu" : "Nová šablona rozsahu"
|
||||||
}
|
}
|
||||||
submitLabel={editingTemplate ? "Uložit" : "Vytvořit"}
|
submitText={editingTemplate ? "Uložit" : "Vytvořit"}
|
||||||
size="lg"
|
maxWidth="lg"
|
||||||
loading={saving}
|
loading={saving}
|
||||||
>
|
>
|
||||||
<div className="admin-form">
|
<Field label="Název šablony" required>
|
||||||
<FormField label="Název šablony" required>
|
<TextField
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Box>
|
||||||
<label className="admin-form-label mb-2">Sekce</label>
|
<Typography
|
||||||
<div className="admin-scope-list">
|
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) => (
|
{form.sections.map((section, index) => (
|
||||||
<div key={section._key} className="admin-scope-section">
|
<Box
|
||||||
<div className="admin-scope-section-header">
|
key={section._key}
|
||||||
<span className="admin-scope-number">{index + 1}.</span>
|
sx={{
|
||||||
<span className="admin-scope-title">
|
border: 1,
|
||||||
{section.title ||
|
borderColor: "divider",
|
||||||
section.title_cz ||
|
borderRadius: 2,
|
||||||
`Sekce ${index + 1}`}
|
p: 2,
|
||||||
</span>
|
}}
|
||||||
<div className="admin-scope-actions">
|
>
|
||||||
<button
|
<Box
|
||||||
type="button"
|
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)}
|
onClick={() => moveSection(index, -1)}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Posunout nahoru"
|
title="Posunout nahoru"
|
||||||
aria-label="Posunout nahoru"
|
aria-label="Posunout nahoru"
|
||||||
>
|
>
|
||||||
<svg
|
{UpIcon}
|
||||||
width="14"
|
</IconButton>
|
||||||
height="14"
|
<IconButton
|
||||||
viewBox="0 0 24 24"
|
size="small"
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M18 15l-6-6-6 6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => moveSection(index, 1)}
|
onClick={() => moveSection(index, 1)}
|
||||||
disabled={index === form.sections.length - 1}
|
disabled={index === form.sections.length - 1}
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Posunout dolů"
|
title="Posunout dolů"
|
||||||
aria-label="Posunout dolů"
|
aria-label="Posunout dolů"
|
||||||
>
|
>
|
||||||
<svg
|
{DownIcon}
|
||||||
width="14"
|
</IconButton>
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M6 9l6 6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{form.sections.length > 1 && (
|
{form.sections.length > 1 && (
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
size="small"
|
||||||
|
color="error"
|
||||||
onClick={() => removeSection(index)}
|
onClick={() => removeSection(index)}
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Odebrat"
|
title="Odebrat"
|
||||||
aria-label="Odebrat"
|
aria-label="Odebrat"
|
||||||
>
|
>
|
||||||
<svg
|
{RemoveIcon}
|
||||||
width="14"
|
</IconButton>
|
||||||
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>
|
</Box>
|
||||||
</div>
|
|
||||||
<div className="admin-form">
|
<Box
|
||||||
<div className="admin-form-row">
|
sx={{
|
||||||
<FormField
|
display: "grid",
|
||||||
label={
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<>
|
gap: 2,
|
||||||
<span className="offers-lang-badge">EN</span> Název
|
}}
|
||||||
sekce
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<input
|
<Box sx={{ mb: 2 }}>
|
||||||
type="text"
|
<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}
|
value={section.title}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateSection(index, "title", e.target.value)
|
updateSection(index, "title", e.target.value)
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Název sekce (anglicky)"
|
placeholder="Název sekce (anglicky)"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Box>
|
||||||
<FormField
|
<Box sx={{ mb: 2 }}>
|
||||||
label={
|
<Box
|
||||||
<>
|
sx={{
|
||||||
<span className="offers-lang-badge offers-lang-badge-cz">
|
display: "flex",
|
||||||
CZ
|
alignItems: "center",
|
||||||
</span>{" "}
|
gap: 0.75,
|
||||||
Název sekce
|
mb: 0.5,
|
||||||
</>
|
}}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<input
|
<StatusChip label="CZ" color="success" />
|
||||||
type="text"
|
<Typography
|
||||||
|
component="label"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
Název sekce
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
value={section.title_cz}
|
value={section.title_cz}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateSection(index, "title_cz", e.target.value)
|
updateSection(index, "title_cz", e.target.value)
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Název sekce (česky)"
|
placeholder="Název sekce (česky)"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
<FormField label="Obsah">
|
|
||||||
|
<Field label="Obsah">
|
||||||
<RichEditor
|
<RichEditor
|
||||||
value={section.content}
|
value={section.content}
|
||||||
onChange={(val) => updateSection(index, "content", val)}
|
onChange={(val) => updateSection(index, "content", val)}
|
||||||
placeholder="Obsah sekce..."
|
placeholder="Obsah sekce..."
|
||||||
minHeight="150px"
|
minHeight="150px"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
<div style={{ marginTop: "0.75rem" }}>
|
<Box sx={{ mt: 1.5 }}>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
startIcon={PlusIcon}
|
||||||
onClick={addSection}
|
onClick={addSection}
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
>
|
>
|
||||||
+ Přidat sekci
|
Přidat sekci
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Modal>
|
||||||
</FormModal>
|
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => setDeleteConfirm({ show: false, template: null })}
|
onClose={() => setDeleteConfirm({ show: false, template: null })}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
@@ -802,9 +854,9 @@ function ScopeTemplatesTab() {
|
|||||||
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
|
message={`Opravdu chcete smazat šablonu "${deleteConfirm.template?.name}"?`}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
type="danger"
|
confirmVariant="danger"
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
/>
|
/>
|
||||||
</>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,42 @@
|
|||||||
import { useState } from "react";
|
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 { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
import apiFetch from "../utils/api";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
|
|
||||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||||
import SortIcon from "../components/SortIcon";
|
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
|
import useDebounce from "../hooks/useDebounce";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
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 { 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";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -23,11 +47,14 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
stornovana: "Stornována",
|
stornovana: "Stornována",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_CLASSES: Record<string, string> = {
|
const STATUS_COLORS: Record<
|
||||||
prijata: "admin-badge-order-prijata",
|
string,
|
||||||
v_realizaci: "admin-badge-order-realizace",
|
"default" | "success" | "error" | "warning" | "info"
|
||||||
dokoncena: "admin-badge-order-dokoncena",
|
> = {
|
||||||
stornovana: "admin-badge-order-stornovana",
|
prijata: "info",
|
||||||
|
v_realizaci: "warning",
|
||||||
|
dokoncena: "success",
|
||||||
|
stornovana: "error",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
@@ -43,210 +70,20 @@ interface Order {
|
|||||||
invoice_id?: number;
|
invoice_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Orders() {
|
const PlusIcon = (
|
||||||
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">
|
|
||||||
<svg
|
<svg
|
||||||
width="28"
|
width="20"
|
||||||
height="28"
|
height="20"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.5"
|
strokeWidth="2"
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
>
|
||||||
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
<line x1="3" y1="6" x2="21" y2="6" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
<path d="M16 10a4 4 0 0 1-8 0" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
);
|
||||||
<p>Zatím nejsou žádné objednávky.</p>
|
const ViewIcon = (
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
width="18"
|
width="18"
|
||||||
height="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" />
|
<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" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
);
|
||||||
{o.invoice_id ? (
|
const InvoiceViewIcon = (
|
||||||
<Link
|
|
||||||
to={`/invoices/${o.invoice_id}`}
|
|
||||||
className="admin-btn-icon accent"
|
|
||||||
title="Zobrazit fakturu"
|
|
||||||
aria-label="Zobrazit fakturu"
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
width="18"
|
width="18"
|
||||||
height="18"
|
height="18"
|
||||||
@@ -288,15 +119,8 @@ export default function Orders() {
|
|||||||
F
|
F
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
);
|
||||||
) : (
|
const InvoiceCreateIcon = (
|
||||||
hasPermission("invoices.create") && (
|
|
||||||
<Link
|
|
||||||
to={`/invoices/new?fromOrder=${o.id}`}
|
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Vytvořit fakturu"
|
|
||||||
aria-label="Vytvořit fakturu"
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
width="18"
|
width="18"
|
||||||
height="18"
|
height="18"
|
||||||
@@ -310,17 +134,8 @@ export default function Orders() {
|
|||||||
<line x1="12" y1="11" x2="12" y2="17" />
|
<line x1="12" y1="11" x2="12" y2="17" />
|
||||||
<line x1="9" y1="14" x2="15" y2="14" />
|
<line x1="9" y1="14" x2="15" y2="14" />
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
);
|
||||||
)
|
const DeleteIcon = (
|
||||||
)}
|
|
||||||
{hasPermission("orders.delete") && (
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setDeleteConfirm({ show: true, order: o })
|
|
||||||
}
|
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Smazat"
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
width="18"
|
width="18"
|
||||||
height="18"
|
height="18"
|
||||||
@@ -328,25 +143,376 @@ export default function Orders() {
|
|||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="2"
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
>
|
>
|
||||||
<polyline points="3 6 5 6 21 6" />
|
<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" />
|
<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>
|
</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}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDeleteConfirm({ show: false, order: null });
|
setDeleteConfirm({ show: false, order: null });
|
||||||
@@ -355,28 +521,184 @@ export default function Orders() {
|
|||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title="Smazat objednávku"
|
title="Smazat objednávku"
|
||||||
message={
|
message={
|
||||||
<>
|
deleteConfirm.order
|
||||||
Opravdu chcete smazat objednávku "
|
? `Opravdu chcete smazat objednávku „${deleteConfirm.order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`
|
||||||
{deleteConfirm.order?.order_number}"? 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>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
confirmVariant="danger"
|
||||||
type="danger"
|
|
||||||
loading={deleteMutation.isPending}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import {
|
import {
|
||||||
usePlanWork,
|
usePlanWork,
|
||||||
getCell,
|
getCell,
|
||||||
|
getCells,
|
||||||
type ModalMode as HookModalMode,
|
type ModalMode as HookModalMode,
|
||||||
} from "../hooks/usePlanWork";
|
} from "../hooks/usePlanWork";
|
||||||
import type { PlanCellModalMode } from "../components/PlanCellModal";
|
import type { PlanCellModalMode } from "../components/PlanCellModal";
|
||||||
@@ -13,8 +16,13 @@ import PlanGrid from "../components/PlanGrid";
|
|||||||
import PlanCellModal from "../components/PlanCellModal";
|
import PlanCellModal from "../components/PlanCellModal";
|
||||||
import PlanCategoriesModal from "../components/PlanCategoriesModal";
|
import PlanCategoriesModal from "../components/PlanCategoriesModal";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
|
import { Button, Alert, LoadingState, PageEnter } from "../ui";
|
||||||
import { projectListOptions, type Project } from "../lib/queries/projects";
|
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.
|
// plan.css is imported once globally in AdminApp.tsx — no per-page re-import.
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
@@ -190,84 +198,86 @@ export default function PlanWork() {
|
|||||||
// Null when the modal is closed or in a "create" state with no source cell.
|
// Null when the modal is closed or in a "create" state with no source cell.
|
||||||
const [modalCell, setModalCell] = useState<ReturnType<typeof getCell>>(null);
|
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(
|
const openCreate = useCallback(
|
||||||
(userId: number, date: string) => {
|
(userId: number, date: string, source: "entry" | "override" = "entry") => {
|
||||||
setModalCell(null);
|
setModalCell(null);
|
||||||
|
if (source === "override") {
|
||||||
|
setHookModal({ kind: "create-override", userId, date });
|
||||||
|
} else {
|
||||||
setHookModal({ kind: "create", userId, date });
|
setHookModal({ kind: "create", userId, date });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[setHookModal],
|
[setHookModal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openCell = useCallback(
|
const openCell = useCallback(
|
||||||
(userId: number, date: string) => {
|
(userId: number, date: string, cells: ResolvedCell[]) => {
|
||||||
const cell = getCell(grid, userId, date);
|
const primary = cells[0] ?? null;
|
||||||
// Past-date guard: the server rejects create/edit on past dates with
|
// Past-date guard: the server rejects create/edit on past dates with
|
||||||
// a 403 (see assertNotPastDate in plan.service.ts). To keep the user
|
// a 403 (see assertNotPastDate in plan.service.ts). To keep the user
|
||||||
// out of the "click → modal opens → error toast" state, route any
|
// 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).
|
// past-day click to view mode (or to no-op if there's no record).
|
||||||
if (isPastDate(date, todayIsoLocal())) {
|
if (isPastDate(date, todayIsoLocal())) {
|
||||||
if (!cell) {
|
if (!primary) return;
|
||||||
// Empty past cell — nothing to show or edit.
|
setModalCell(primary);
|
||||||
return;
|
|
||||||
}
|
|
||||||
setModalCell(cell);
|
|
||||||
setHookModal({ kind: "view", userId, date });
|
setHookModal({ kind: "view", userId, date });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!cell) {
|
if (cells.length === 0) {
|
||||||
// Empty cell — open the create modal if the user can edit, otherwise
|
|
||||||
// there's nothing to view.
|
|
||||||
if (canEdit) openCreate(userId, date);
|
if (canEdit) openCreate(userId, date);
|
||||||
return;
|
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);
|
setModalCell(cell);
|
||||||
if (cell.source === "entry" && cell.entryId !== null) {
|
if (cell.source === "entry" && cell.entryId !== null) {
|
||||||
if (canEdit) {
|
if (cell.rangeFrom && cell.rangeTo && cell.rangeFrom !== cell.rangeTo) {
|
||||||
// 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
|
|
||||||
) {
|
|
||||||
setHookModal({
|
setHookModal({
|
||||||
kind: "day-in-range",
|
kind: "day-in-range",
|
||||||
entryId: cell.entryId,
|
entryId: cell.entryId,
|
||||||
userId,
|
userId,
|
||||||
date,
|
date,
|
||||||
});
|
});
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
setHookModal({
|
setHookModal({
|
||||||
kind: "edit-range",
|
kind: "edit-range",
|
||||||
entryId: cell.entryId,
|
entryId: cell.entryId,
|
||||||
userId,
|
userId,
|
||||||
date,
|
date,
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// Read-only view
|
|
||||||
setHookModal({ kind: "view", userId, date });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cell.source === "override" && cell.overrideId !== null) {
|
if (cell.source === "override" && cell.overrideId !== null) {
|
||||||
if (canEdit) {
|
|
||||||
setHookModal({
|
setHookModal({
|
||||||
kind: "edit-override",
|
kind: "edit-override",
|
||||||
overrideId: cell.overrideId,
|
overrideId: cell.overrideId,
|
||||||
userId,
|
userId,
|
||||||
date,
|
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(() => {
|
const closeModal = useCallback(() => {
|
||||||
@@ -277,11 +287,14 @@ export default function PlanWork() {
|
|||||||
|
|
||||||
// Switch the modal from "day-in-range" to "edit-range" mode, keeping the
|
// 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.
|
// 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(
|
const switchToEditRange = useCallback(
|
||||||
(entryId: number, userId: number, date: string) => {
|
(entryId: number, userId: number, date: string) => {
|
||||||
const cell = getCell(grid, userId, date);
|
if (!modalCell) return;
|
||||||
if (!cell) return;
|
|
||||||
setModalCell(cell);
|
|
||||||
setHookModal({
|
setHookModal({
|
||||||
kind: "edit-range",
|
kind: "edit-range",
|
||||||
entryId,
|
entryId,
|
||||||
@@ -289,7 +302,7 @@ export default function PlanWork() {
|
|||||||
date,
|
date,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[grid, setHookModal],
|
[modalCell, setHookModal],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Mutation wrappers -----------------------------------------------------
|
// --- Mutation wrappers -----------------------------------------------------
|
||||||
@@ -324,8 +337,9 @@ export default function PlanWork() {
|
|||||||
let firstDate: string | null = null;
|
let firstDate: string | null = null;
|
||||||
if (grid) {
|
if (grid) {
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [date, cell] of Object.entries(byDate)) {
|
for (const [date, cells] of Object.entries(byDate)) {
|
||||||
if (cell && cell.entryId === id) {
|
const hit = cells.find((c) => c.entryId === id);
|
||||||
|
if (hit) {
|
||||||
userId = Number(uidStr);
|
userId = Number(uidStr);
|
||||||
firstDate = body.date_from ?? date;
|
firstDate = body.date_from ?? date;
|
||||||
break;
|
break;
|
||||||
@@ -354,10 +368,11 @@ export default function PlanWork() {
|
|||||||
let firstDate: string | null = null;
|
let firstDate: string | null = null;
|
||||||
if (grid) {
|
if (grid) {
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [date, cell] of Object.entries(byDate)) {
|
for (const [date, cells] of Object.entries(byDate)) {
|
||||||
if (cell && cell.entryId === id) {
|
const hit = cells.find((c) => c.entryId === id);
|
||||||
|
if (hit) {
|
||||||
userId = Number(uidStr);
|
userId = Number(uidStr);
|
||||||
firstDate = cell.rangeFrom ?? date;
|
firstDate = hit.rangeFrom ?? date;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,8 +414,9 @@ export default function PlanWork() {
|
|||||||
let date: string | null = null;
|
let date: string | null = null;
|
||||||
if (grid) {
|
if (grid) {
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [d, cell] of Object.entries(byDate)) {
|
for (const [d, cells] of Object.entries(byDate)) {
|
||||||
if (cell && cell.overrideId === id) {
|
const hit = cells.find((c) => c.overrideId === id);
|
||||||
|
if (hit) {
|
||||||
userId = Number(uidStr);
|
userId = Number(uidStr);
|
||||||
date = d;
|
date = d;
|
||||||
break;
|
break;
|
||||||
@@ -428,8 +444,9 @@ export default function PlanWork() {
|
|||||||
let date: string | null = null;
|
let date: string | null = null;
|
||||||
if (grid) {
|
if (grid) {
|
||||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||||
for (const [d, cell] of Object.entries(byDate)) {
|
for (const [d, cells] of Object.entries(byDate)) {
|
||||||
if (cell && cell.overrideId === id) {
|
const hit = cells.find((c) => c.overrideId === id);
|
||||||
|
if (hit) {
|
||||||
userId = Number(uidStr);
|
userId = Number(uidStr);
|
||||||
date = d;
|
date = d;
|
||||||
break;
|
break;
|
||||||
@@ -452,11 +469,14 @@ export default function PlanWork() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// "Create override from range" — turn a multi-day entry into a one-day
|
// "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
|
// override for the clicked date. We mirror the CLICKED record's fields
|
||||||
// create a new override mirroring its fields, locked to a single date.
|
// (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(
|
const createOverrideFromRange = useCallback(
|
||||||
async (entryId: number, userId: number, date: string) => {
|
async (entryId: number, userId: number, date: string) => {
|
||||||
const cell = getCell(grid, userId, date);
|
const cell = modalCell;
|
||||||
if (!cell) {
|
if (!cell) {
|
||||||
alert.error("Nepodařilo se najít zdrojový záznam");
|
alert.error("Nepodařilo se najít zdrojový záznam");
|
||||||
return;
|
return;
|
||||||
@@ -480,7 +500,7 @@ export default function PlanWork() {
|
|||||||
// symmetry with the modal contract.
|
// symmetry with the modal contract.
|
||||||
void entryId;
|
void entryId;
|
||||||
},
|
},
|
||||||
[grid, createOverride, alert, firePulse, rollbackMutation],
|
[modalCell, createOverride, alert, firePulse, rollbackMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Modal key for remount-on-mode-change ----------------------------------
|
// --- Modal key for remount-on-mode-change ----------------------------------
|
||||||
@@ -501,6 +521,9 @@ export default function PlanWork() {
|
|||||||
const modalMode: PlanCellModalMode = useMemo(() => {
|
const modalMode: PlanCellModalMode = useMemo(() => {
|
||||||
if (hookModal.kind === "closed") return { kind: "closed" };
|
if (hookModal.kind === "closed") return { kind: "closed" };
|
||||||
if (hookModal.kind === "create") return hookModal;
|
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 (hookModal.kind === "view") {
|
||||||
if (!modalCell) return { kind: "closed" };
|
if (!modalCell) return { kind: "closed" };
|
||||||
return {
|
return {
|
||||||
@@ -533,8 +556,16 @@ export default function PlanWork() {
|
|||||||
},
|
},
|
||||||
} as PlanCellModalMode;
|
} 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" };
|
return { kind: "closed" };
|
||||||
}, [hookModal, modalCell]);
|
}, [hookModal, modalCell, grid]);
|
||||||
|
|
||||||
if (!canEdit && !hasPermission("attendance.record")) {
|
if (!canEdit && !hasPermission("attendance.record")) {
|
||||||
// Hard block for users who have neither manage nor record permission.
|
// Hard block for users who have neither manage nor record permission.
|
||||||
@@ -542,121 +573,109 @@ export default function PlanWork() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="plan-work-page">
|
<PageEnter sx={{ position: "relative" }}>
|
||||||
<motion.h1
|
<Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
|
||||||
className="admin-page-title"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25 }}
|
|
||||||
>
|
|
||||||
Plán prací
|
Plán prací
|
||||||
</motion.h1>
|
</Typography>
|
||||||
|
|
||||||
{!canEdit && (
|
{!canEdit && (
|
||||||
<motion.div
|
<Box role="status" sx={{ mb: 2 }}>
|
||||||
className="plan-banner"
|
<Alert severity="info">
|
||||||
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>
|
|
||||||
<strong>Režim náhledu</strong> — můžete pouze prohlížet plán. Úpravy
|
<strong>Režim náhledu</strong> — můžete pouze prohlížet plán. Úpravy
|
||||||
jsou dostupné oprávněným uživatelům.
|
jsou dostupné oprávněným uživatelům.
|
||||||
</span>
|
</Alert>
|
||||||
</motion.div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<motion.div
|
<Box
|
||||||
className="plan-toolbar"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "flex",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
flexWrap: "wrap",
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
alignItems: "center",
|
||||||
>
|
gap: 1.5,
|
||||||
<button
|
mb: 2,
|
||||||
type="button"
|
}}
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
onClick={goToToday}
|
|
||||||
>
|
>
|
||||||
|
{/* Nav buttons grouped so they stay on one row when the toolbar
|
||||||
|
wraps on mobile. */}
|
||||||
|
<Box sx={{ display: "inline-flex", gap: 1 }}>
|
||||||
|
<Button variant="outlined" color="inherit" onClick={goToToday}>
|
||||||
Dnes
|
Dnes
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outlined"
|
||||||
className="admin-btn admin-btn-secondary"
|
color="inherit"
|
||||||
onClick={goPrev}
|
onClick={goPrev}
|
||||||
aria-label="Předchozí"
|
aria-label="Předchozí"
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outlined"
|
||||||
className="admin-btn admin-btn-secondary"
|
color="inherit"
|
||||||
onClick={goNext}
|
onClick={goNext}
|
||||||
aria-label="Další"
|
aria-label="Další"
|
||||||
>
|
>
|
||||||
→
|
→
|
||||||
</button>
|
</Button>
|
||||||
<span className="plan-range-label-slot">
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 220,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AnimatePresence mode="popLayout" initial={false}>
|
<AnimatePresence mode="popLayout" initial={false}>
|
||||||
<motion.span
|
<motion.div
|
||||||
className="plan-range-label"
|
|
||||||
key={`${isoDate(range.from)}-${view}`}
|
key={`${isoDate(range.from)}-${view}`}
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{ fontWeight: 600, whiteSpace: "nowrap" }}
|
||||||
>
|
>
|
||||||
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
|
{formatRangeLabel(isoDate(range.from), isoDate(range.to), view)}
|
||||||
</motion.span>
|
</Typography>
|
||||||
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</span>
|
</Box>
|
||||||
<div
|
<Box
|
||||||
className="plan-view-toggle"
|
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="Měřítko zobrazení"
|
aria-label="Měřítko zobrazení"
|
||||||
|
sx={{ display: "inline-flex", gap: 1 }}
|
||||||
>
|
>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant={view === "week" ? "contained" : "outlined"}
|
||||||
className={
|
color={view === "week" ? "primary" : "inherit"}
|
||||||
view === "week"
|
|
||||||
? "admin-btn admin-btn-primary"
|
|
||||||
: "admin-btn admin-btn-secondary"
|
|
||||||
}
|
|
||||||
onClick={() => setViewWithAnim("week")}
|
onClick={() => setViewWithAnim("week")}
|
||||||
>
|
>
|
||||||
Týden
|
Týden
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant={view === "month" ? "contained" : "outlined"}
|
||||||
className={
|
color={view === "month" ? "primary" : "inherit"}
|
||||||
view === "month"
|
|
||||||
? "admin-btn admin-btn-primary"
|
|
||||||
: "admin-btn admin-btn-secondary"
|
|
||||||
}
|
|
||||||
onClick={() => setViewWithAnim("month")}
|
onClick={() => setViewWithAnim("month")}
|
||||||
>
|
>
|
||||||
Měsíc
|
Měsíc
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outlined"
|
||||||
className="admin-btn admin-btn-secondary"
|
color="inherit"
|
||||||
onClick={() => setCatModalOpen(true)}
|
onClick={() => setCatModalOpen(true)}
|
||||||
>
|
>
|
||||||
Správa kategorií
|
Správa kategorií
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</Box>
|
||||||
|
|
||||||
{gridLoading && (
|
{gridLoading && <LoadingState />}
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Grid entrance: same pattern as the h1 / banner / toolbar above
|
Grid entrance: same pattern as the h1 / banner / toolbar above
|
||||||
@@ -675,11 +694,6 @@ export default function PlanWork() {
|
|||||||
patch the cache in place and pulse a single cell via pulseKey.
|
patch the cache in place and pulse a single cell via pulseKey.
|
||||||
*/}
|
*/}
|
||||||
{grid ? (
|
{grid ? (
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25, ease: "easeOut", delay: 0.18 }}
|
|
||||||
>
|
|
||||||
<PlanGrid
|
<PlanGrid
|
||||||
data={grid}
|
data={grid}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
@@ -688,7 +702,6 @@ export default function PlanWork() {
|
|||||||
pulseKey={lastMutated}
|
pulseKey={lastMutated}
|
||||||
onCellClick={openCell}
|
onCellClick={openCell}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<PlanCellModal
|
<PlanCellModal
|
||||||
@@ -706,12 +719,14 @@ export default function PlanWork() {
|
|||||||
onDeleteOverride={deleteOverrideFn}
|
onDeleteOverride={deleteOverrideFn}
|
||||||
onCreateOverrideFromRange={createOverrideFromRange}
|
onCreateOverrideFromRange={createOverrideFromRange}
|
||||||
onSwitchToEditRange={switchToEditRange}
|
onSwitchToEditRange={switchToEditRange}
|
||||||
|
onAddRecord={openCreate}
|
||||||
|
onEditRecord={editRecord}
|
||||||
/>
|
/>
|
||||||
<PlanCategoriesModal
|
<PlanCategoriesModal
|
||||||
isOpen={catModalOpen}
|
isOpen={catModalOpen}
|
||||||
onClose={() => setCatModalOpen(false)}
|
onClose={() => setCatModalOpen(false)}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
import { useParams, useNavigate, Link as RouterLink } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
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 {
|
import {
|
||||||
projectDetailOptions,
|
projectDetailOptions,
|
||||||
@@ -13,11 +16,21 @@ import {
|
|||||||
} from "../lib/queries/projects";
|
} from "../lib/queries/projects";
|
||||||
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
|
import { userListOptions, type User as ApiUser } from "../lib/queries/users";
|
||||||
import Forbidden from "../components/Forbidden";
|
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 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";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -27,6 +40,15 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
zruseny: "Zrušený",
|
zruseny: "Zrušený",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<
|
||||||
|
string,
|
||||||
|
"default" | "success" | "error" | "warning" | "info"
|
||||||
|
> = {
|
||||||
|
aktivni: "success",
|
||||||
|
dokonceny: "info",
|
||||||
|
zruseny: "default",
|
||||||
|
};
|
||||||
|
|
||||||
function formatNoteDate(dateStr: string) {
|
function formatNoteDate(dateStr: string) {
|
||||||
if (!dateStr) return "";
|
if (!dateStr) return "";
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
@@ -51,6 +73,36 @@ interface ProjectForm {
|
|||||||
responsible_user_id: string;
|
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() {
|
export default function ProjectDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
@@ -81,9 +133,17 @@ export default function ProjectDetail() {
|
|||||||
const notes: ProjectNote[] = project?.project_notes || [];
|
const notes: ProjectNote[] = project?.project_notes || [];
|
||||||
|
|
||||||
const { data: usersData } = useQuery(userListOptions("projects.view"));
|
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,
|
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
|
// 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;
|
if (!project) return null;
|
||||||
|
|
||||||
const canEdit = hasPermission("projects.edit");
|
const canEdit = hasPermission("projects.edit");
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-loading">
|
<PageEnter>
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<motion.div
|
<Box>
|
||||||
className="admin-page-header"
|
<Box
|
||||||
initial={{ opacity: 0, y: 12 }}
|
sx={{
|
||||||
animate={{ opacity: 1, y: 0 }}
|
display: "flex",
|
||||||
transition={{ duration: 0.25 }}
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
<Link
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
to="/projects"
|
to="/projects"
|
||||||
className="admin-btn-icon"
|
variant="outlined"
|
||||||
title="Zpět"
|
color="inherit"
|
||||||
aria-label="Zpět"
|
startIcon={BackIcon}
|
||||||
>
|
>
|
||||||
<svg
|
Zpět
|
||||||
width="20"
|
</Button>
|
||||||
height="20"
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||||
viewBox="0 0 24 24"
|
<Typography variant="h4">
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<h1 className="admin-page-title">
|
|
||||||
Projekt {project.project_number}
|
Projekt {project.project_number}
|
||||||
</h1>
|
</Typography>
|
||||||
</div>
|
<StatusChip
|
||||||
</div>
|
label={STATUS_LABELS[project.status] || project.status}
|
||||||
|
color={STATUS_COLORS[project.status] || "default"}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className="admin-page-actions">
|
<Box sx={headerActionsSx}>
|
||||||
<button
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
onClick={handleSave}
|
{saving ? "Ukládání..." : "Uložit"}
|
||||||
className="admin-btn admin-btn-primary"
|
</Button>
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<div className="admin-spinner admin-spinner-sm" />
|
|
||||||
Ukládání...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Uložit"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{!project.order_id && (
|
{!project.order_id && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
onClick={() => setDeleteConfirm(true)}
|
onClick={() => setDeleteConfirm(true)}
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
>
|
||||||
Smazat
|
Smazat
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Basic info form */}
|
||||||
<motion.div
|
<Card sx={{ mb: 3 }}>
|
||||||
className="admin-card"
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
Základní údaje
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Typography>
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
<Box
|
||||||
>
|
sx={{
|
||||||
<div className="admin-card-body">
|
display: "grid",
|
||||||
<h3 className="admin-card-title">Základní údaje</h3>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<div className="admin-form">
|
gap: 2,
|
||||||
<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",
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</FormField>
|
<Field label="Číslo projektu">
|
||||||
<FormField label="Název">
|
<TextField value={project.project_number} fullWidth disabled />
|
||||||
<input
|
</Field>
|
||||||
type="text"
|
<Field label="Název">
|
||||||
|
<TextField
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => updateForm("name", e.target.value)}
|
onChange={(e) => updateForm("name", e.target.value)}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Název projektu"
|
placeholder="Název projektu"
|
||||||
|
fullWidth
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
<Field label="Zákazník">
|
||||||
|
<TextField
|
||||||
<div className="admin-form-row">
|
|
||||||
<FormField label="Zákazník">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={project.customer_name || "—"}
|
value={project.customer_name || "—"}
|
||||||
className="admin-form-input"
|
fullWidth
|
||||||
readOnly
|
disabled
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
cursor: "default",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Zodpovědná osoba">
|
<Field label="Zodpovědná osoba">
|
||||||
<select
|
<Select
|
||||||
value={form.responsible_user_id}
|
value={form.responsible_user_id}
|
||||||
onChange={(e) =>
|
onChange={(v) => updateForm("responsible_user_id", v)}
|
||||||
updateForm("responsible_user_id", e.target.value)
|
|
||||||
}
|
|
||||||
className="admin-form-select"
|
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
>
|
>
|
||||||
<option value="">— Nevybráno —</option>
|
<MenuItem value="">— Nevybráno —</MenuItem>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<option key={u.id} value={u.id}>
|
<MenuItem key={u.id} value={String(u.id)}>
|
||||||
{u.name}
|
{u.name}
|
||||||
</option>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
<Box
|
||||||
<div className="admin-form-row admin-form-row-3">
|
sx={{
|
||||||
<FormField label="Stav">
|
display: "grid",
|
||||||
<select
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Stav">
|
||||||
|
<Select
|
||||||
value={form.status}
|
value={form.status}
|
||||||
onChange={(e) => updateForm("status", e.target.value)}
|
onChange={(v) => updateForm("status", v)}
|
||||||
className="admin-form-select"
|
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
>
|
>
|
||||||
<option value="aktivni">Aktivní</option>
|
<MenuItem value="aktivni">Aktivní</MenuItem>
|
||||||
<option value="dokonceny">Dokončený</option>
|
<MenuItem value="dokonceny">Dokončený</MenuItem>
|
||||||
<option value="zruseny">Zrušený</option>
|
<MenuItem value="zruseny">Zrušený</MenuItem>
|
||||||
</select>
|
</Select>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Datum zahájení">
|
<Field label="Datum zahájení">
|
||||||
<AdminDatePicker
|
<DateField
|
||||||
mode="date"
|
|
||||||
value={form.start_date}
|
value={form.start_date}
|
||||||
onChange={(val: string) => updateForm("start_date", val)}
|
onChange={(val) => updateForm("start_date", val)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
<FormField label="Datum ukončení">
|
<Field label="Datum ukončení">
|
||||||
<AdminDatePicker
|
<DateField
|
||||||
mode="date"
|
|
||||||
value={form.end_date}
|
value={form.end_date}
|
||||||
onChange={(val: string) => updateForm("end_date", val)}
|
onChange={(val) => updateForm("end_date", val)}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<motion.div
|
<Card sx={{ mb: 3 }}>
|
||||||
className="admin-card"
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
Poznámky
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Typography>
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
|
||||||
>
|
|
||||||
<div className="admin-card-body">
|
|
||||||
<h3 className="admin-card-title">Poznámky</h3>
|
|
||||||
|
|
||||||
{/* Add note */}
|
{/* Add note */}
|
||||||
<div className="mb-4">
|
<Box sx={{ mb: 3 }}>
|
||||||
<textarea
|
<TextField
|
||||||
value={newNote}
|
value={newNote}
|
||||||
onChange={(e) => setNewNote(e.target.value)}
|
onChange={(e) => setNewNote(e.target.value)}
|
||||||
className="admin-form-input"
|
multiline
|
||||||
rows={2}
|
minRows={2}
|
||||||
placeholder="Napište poznámku..."
|
placeholder="Napište poznámku..."
|
||||||
style={{ resize: "vertical", width: "100%" }}
|
fullWidth
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
|
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
|
||||||
handleAddNote();
|
handleAddNote();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="mt-2">
|
<Box sx={{ mt: 1 }}>
|
||||||
<button
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
onClick={handleAddNote}
|
onClick={handleAddNote}
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
disabled={addingNote || !newNote.trim()}
|
disabled={addingNote || !newNote.trim()}
|
||||||
>
|
>
|
||||||
{addingNote ? (
|
{addingNote ? "Přidávání..." : "Přidat poznámku"}
|
||||||
<div className="admin-spinner admin-spinner-sm" />
|
</Button>
|
||||||
) : (
|
</Box>
|
||||||
"Přidat poznámku"
|
</Box>
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Legacy notes (read-only) */}
|
{/* Legacy notes (read-only) */}
|
||||||
{project.notes && (
|
{project.notes && (
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
padding: "0.75rem",
|
p: 1.5,
|
||||||
background: "var(--bg-secondary)",
|
bgcolor: "action.hover",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: 2,
|
||||||
marginBottom: "0.5rem",
|
mb: 1,
|
||||||
fontSize: "0.85rem",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Typography
|
||||||
style={{
|
variant="caption"
|
||||||
fontSize: "0.75rem",
|
color="text.secondary"
|
||||||
color: "var(--text-tertiary)",
|
sx={{ display: "block", mb: 0.5 }}
|
||||||
marginBottom: "0.25rem",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Starší poznámka (před zavedením systému)
|
Starší poznámka (před zavedením systému)
|
||||||
</div>
|
</Typography>
|
||||||
<div style={{ whiteSpace: "pre-wrap" }}>{project.notes}</div>
|
<Typography
|
||||||
</div>
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ whiteSpace: "pre-wrap" }}
|
||||||
|
>
|
||||||
|
{project.notes}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notes list */}
|
{/* Notes list */}
|
||||||
{notes.length === 0 && !project.notes && (
|
{notes.length === 0 && !project.notes && (
|
||||||
<div
|
<Typography
|
||||||
style={{
|
variant="body2"
|
||||||
color: "var(--text-tertiary)",
|
color="text.secondary"
|
||||||
fontSize: "0.875rem",
|
sx={{ textAlign: "center", py: 2 }}
|
||||||
textAlign: "center",
|
|
||||||
padding: "1rem 0",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Zatím žádné poznámky
|
Zatím žádné poznámky
|
||||||
</div>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{(notes.length > 0 || project.notes) && (
|
{(notes.length > 0 || project.notes) && (
|
||||||
<div
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "0.5rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{notes.map((note) => (
|
{notes.map((note) => (
|
||||||
<div
|
<Box
|
||||||
key={note.id}
|
key={note.id}
|
||||||
style={{
|
sx={{
|
||||||
padding: "0.75rem",
|
p: 1.5,
|
||||||
background: "var(--bg-secondary)",
|
bgcolor: "action.hover",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: 2,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
gap: "0.5rem",
|
gap: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<Box sx={{ flex: 1 }}>
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.5rem",
|
gap: 1,
|
||||||
marginBottom: "0.25rem",
|
mb: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: 600, fontSize: "0.85rem" }}>
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
{note.user_name}
|
{note.user_name}
|
||||||
</span>
|
</Typography>
|
||||||
<span
|
<Typography variant="caption" color="text.secondary">
|
||||||
style={{
|
|
||||||
color: "var(--text-tertiary)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatNoteDate(note.created_at)}
|
{formatNoteDate(note.created_at)}
|
||||||
</span>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
<div
|
<Typography
|
||||||
style={{
|
variant="body2"
|
||||||
whiteSpace: "pre-wrap",
|
sx={{ whiteSpace: "pre-wrap", lineHeight: 1.5 }}
|
||||||
fontSize: "0.875rem",
|
|
||||||
lineHeight: 1.5,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{note.content}
|
{note.content}
|
||||||
</div>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
onClick={() => handleDeleteNote(note.id)}
|
onClick={() => handleDeleteNote(note.id)}
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Smazat poznámku"
|
title="Smazat poznámku"
|
||||||
|
aria-label="Smazat poznámku"
|
||||||
disabled={deletingNoteId === note.id}
|
disabled={deletingNoteId === note.id}
|
||||||
style={{
|
sx={{ flexShrink: 0 }}
|
||||||
flexShrink: 0,
|
|
||||||
opacity: deletingNoteId === note.id ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{deletingNoteId === note.id ? (
|
{TrashIcon}
|
||||||
<div
|
</IconButton>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</Box>
|
||||||
)}
|
</Box>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Project File Manager */}
|
{/* Project File Manager */}
|
||||||
<motion.div
|
<Box sx={{ mb: 2 }}>
|
||||||
style={{ marginBottom: "1rem" }}
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
|
||||||
>
|
|
||||||
<ProjectFileManager
|
<ProjectFileManager
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
projectNumber={project.project_number}
|
projectNumber={project.project_number}
|
||||||
hasPermission={hasPermission}
|
hasPermission={hasPermission}
|
||||||
hasNasFolder={project.has_nas_folder ?? false}
|
hasNasFolder={project.has_nas_folder ?? false}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</Box>
|
||||||
|
|
||||||
{/* Links */}
|
{/* Links */}
|
||||||
<motion.div
|
<Card sx={{ mb: 3 }}>
|
||||||
className="admin-card"
|
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
Propojení
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Typography>
|
||||||
transition={{ duration: 0.25, delay: 0.15 }}
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-card-body">
|
<Box>
|
||||||
<h3 className="admin-card-title">Propojení</h3>
|
<Typography variant="caption" color="text.secondary">
|
||||||
<div className="admin-form-row">
|
Objednávka
|
||||||
<FormField label="Objednávka">
|
</Typography>
|
||||||
<div>
|
<Typography variant="body2">
|
||||||
{project.order_id ? (
|
{project.order_id ? (
|
||||||
<Link
|
<Box
|
||||||
|
component={RouterLink}
|
||||||
to={`/orders/${project.order_id}`}
|
to={`/orders/${project.order_id}`}
|
||||||
className="link-accent"
|
sx={{
|
||||||
|
color: "primary.main",
|
||||||
|
textDecoration: "none",
|
||||||
|
"&:hover": { textDecoration: "underline" },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{project.order_number}
|
{project.order_number}
|
||||||
{project.order_status && (
|
{project.order_status && (
|
||||||
<span
|
<Box
|
||||||
className="text-tertiary"
|
component="span"
|
||||||
style={{ fontWeight: 400, marginLeft: "0.5rem" }}
|
sx={{ color: "text.secondary", ml: 1 }}
|
||||||
>
|
>
|
||||||
(
|
(
|
||||||
{STATUS_LABELS[project.order_status] ||
|
{STATUS_LABELS[project.order_status] ||
|
||||||
project.order_status}
|
project.order_status}
|
||||||
)
|
)
|
||||||
</span>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</div>
|
</Typography>
|
||||||
</FormField>
|
</Box>
|
||||||
<FormField label="Nabídka">
|
<Box>
|
||||||
<div>
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Nabídka
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
{project.quotation_id ? (
|
{project.quotation_id ? (
|
||||||
<Link
|
<Box
|
||||||
|
component={RouterLink}
|
||||||
to={`/offers/${project.quotation_id}`}
|
to={`/offers/${project.quotation_id}`}
|
||||||
className="link-accent"
|
sx={{
|
||||||
|
color: "primary.main",
|
||||||
|
textDecoration: "none",
|
||||||
|
"&:hover": { textDecoration: "underline" },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{project.quotation_number}
|
{project.quotation_number}
|
||||||
</Link>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</div>
|
</Typography>
|
||||||
</FormField>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Card>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm}
|
isOpen={deleteConfirm}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDeleteConfirm(false);
|
setDeleteConfirm(false);
|
||||||
@@ -643,30 +641,19 @@ export default function ProjectDetail() {
|
|||||||
}}
|
}}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title="Smazat projekt"
|
title="Smazat projekt"
|
||||||
message={
|
message={`Opravdu chcete smazat projekt „${project.project_number} – ${project.name}"? Tato akce je nevratná.`}
|
||||||
<>
|
|
||||||
Opravdu chcete smazat projekt "{project.project_number} –{" "}
|
|
||||||
{project.name}"? 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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
confirmVariant="danger"
|
||||||
type="danger"
|
|
||||||
loading={deleting}
|
loading={deleting}
|
||||||
|
>
|
||||||
|
{project.has_nas_folder && (
|
||||||
|
<CheckboxField
|
||||||
|
label="Smazat i soubory na disku"
|
||||||
|
checked={deleteFiles}
|
||||||
|
onChange={setDeleteFiles}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
</ConfirmDialog>
|
||||||
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,40 @@
|
|||||||
import { useState } from "react";
|
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 { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
import { formatDate } from "../utils/formatters";
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
|
|
||||||
import { formatDate, czechPlural } from "../utils/formatters";
|
|
||||||
import SortIcon from "../components/SortIcon";
|
|
||||||
import useTableSort from "../hooks/useTableSort";
|
import useTableSort from "../hooks/useTableSort";
|
||||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
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 { 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";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
@@ -22,10 +44,13 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
zruseny: "Zrušený",
|
zruseny: "Zrušený",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_CLASSES: Record<string, string> = {
|
const STATUS_COLORS: Record<
|
||||||
aktivni: "admin-badge-project-aktivni",
|
string,
|
||||||
dokonceny: "admin-badge-project-dokonceny",
|
"default" | "success" | "error" | "warning" | "info"
|
||||||
zruseny: "admin-badge-project-zruseny",
|
> = {
|
||||||
|
aktivni: "success",
|
||||||
|
dokonceny: "info",
|
||||||
|
zruseny: "default",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Project {
|
interface Project {
|
||||||
@@ -41,12 +66,56 @@ interface Project {
|
|||||||
order_number?: string;
|
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() {
|
export default function Projects() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
|
|
||||||
const { sort, order, handleSort, activeSort } =
|
const { sort, order, handleSort } = useTableSort("project_number");
|
||||||
useTableSort("project_number");
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
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 {
|
const {
|
||||||
items: projects,
|
items: projects,
|
||||||
pagination,
|
pagination,
|
||||||
@@ -99,239 +236,190 @@ export default function Projects() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const columns: DataColumn<Project>[] = [
|
||||||
<div>
|
{
|
||||||
<motion.div
|
key: "project_number",
|
||||||
className="admin-page-header"
|
header: "Číslo",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
width: "12%",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
sortKey: "project_number",
|
||||||
transition={{ duration: 0.25 }}
|
mono: true,
|
||||||
|
render: (p) => (
|
||||||
|
<Box
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/projects/${p.id}`}
|
||||||
|
sx={{
|
||||||
|
color: "primary.main",
|
||||||
|
textDecoration: "none",
|
||||||
|
"&:hover": { textDecoration: "underline" },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
{p.project_number}
|
||||||
<h1 className="admin-page-title">Projekty</h1>
|
</Box>
|
||||||
<p className="admin-page-subtitle">
|
),
|
||||||
{pagination?.total ?? projects.length}{" "}
|
},
|
||||||
{czechPlural(
|
{
|
||||||
pagination?.total ?? projects.length,
|
key: "name",
|
||||||
"projekt",
|
header: "Název",
|
||||||
"projekty",
|
width: "16%",
|
||||||
"projektů",
|
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>
|
</Box>
|
||||||
</div>
|
),
|
||||||
</motion.div>
|
},
|
||||||
|
];
|
||||||
|
|
||||||
<motion.div
|
return (
|
||||||
className="admin-card"
|
<PageEnter>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<Box
|
||||||
animate={{ opacity: 1, y: 0 }}
|
sx={{
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
display: "flex",
|
||||||
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 3,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-card-body">
|
<Typography variant="h4">Projekty</Typography>
|
||||||
<div className="admin-search-bar mb-4">
|
{hasPermission("projects.create") && (
|
||||||
<input
|
<Button startIcon={PlusIcon} onClick={openCreate}>
|
||||||
type="text"
|
Přidat projekt
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FilterBar>
|
||||||
|
<Box sx={{ flex: "1 1 320px" }}>
|
||||||
|
<TextField
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value);
|
setSearch(e.target.value);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
|
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
|
||||||
|
fullWidth
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
{projects.length === 0 ? (
|
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||||
<div className="admin-empty-state">
|
<DataTable<Project>
|
||||||
<div className="admin-empty-icon">
|
columns={columns}
|
||||||
<svg
|
rows={projects}
|
||||||
width="28"
|
rowKey={(p) => p.id}
|
||||||
height="28"
|
sortBy={sort}
|
||||||
viewBox="0 0 24 24"
|
sortDir={order}
|
||||||
fill="none"
|
onSort={handleSort}
|
||||||
stroke="currentColor"
|
empty={
|
||||||
strokeWidth="1.5"
|
<Box sx={{ textAlign: "center", py: 6 }}>
|
||||||
strokeLinecap="round"
|
<Typography color="text.secondary" gutterBottom>
|
||||||
strokeLinejoin="round"
|
Zatím nejsou žádné projekty.
|
||||||
>
|
</Typography>
|
||||||
<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" />
|
<Typography variant="body2" color="text.secondary">
|
||||||
</svg>
|
Projekty vznikají z objednávek nebo je můžete vytvořit ručně
|
||||||
</div>
|
tlačítkem „Přidat projekt".
|
||||||
<p>Zatím nejsou žádné projekty.</p>
|
</Typography>
|
||||||
<p
|
</Box>
|
||||||
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
|
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
{deleteMutation.isPending &&
|
<Pagination
|
||||||
deleteTarget?.id === p.id ? (
|
page={page}
|
||||||
<div className="admin-spinner admin-spinner-sm" />
|
pageCount={pagination?.total_pages ?? 1}
|
||||||
) : (
|
onChange={setPage}
|
||||||
<svg
|
/>
|
||||||
width="18"
|
</Card>
|
||||||
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>
|
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={!!deleteTarget}
|
isOpen={!!deleteTarget}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
@@ -340,25 +428,119 @@ export default function Projects() {
|
|||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title="Smazat projekt"
|
title="Smazat projekt"
|
||||||
message={
|
message={
|
||||||
<>
|
deleteTarget
|
||||||
Opravdu chcete smazat projekt {deleteTarget?.project_number}?
|
? `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>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
type="danger"
|
confirmVariant="danger"
|
||||||
loading={deleteMutation.isPending}
|
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
@@ -1,14 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
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 Forbidden from "../components/Forbidden";
|
||||||
import { formatDate } from "../utils/attendanceHelpers";
|
import { formatDate } from "../utils/attendanceHelpers";
|
||||||
import { formatKm, todayLocalStr } from "../utils/formatters";
|
import { formatKm, todayLocalStr } from "../utils/formatters";
|
||||||
@@ -17,9 +14,25 @@ import {
|
|||||||
tripListOptions,
|
tripListOptions,
|
||||||
tripVehiclesOptions,
|
tripVehiclesOptions,
|
||||||
type BackendTrip,
|
type BackendTrip,
|
||||||
type TripVehicle,
|
|
||||||
} from "../lib/queries/trips";
|
} from "../lib/queries/trips";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
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";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
interface TripForm {
|
interface TripForm {
|
||||||
@@ -33,6 +46,114 @@ interface TripForm {
|
|||||||
notes: string;
|
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() {
|
export default function Trips() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
@@ -197,11 +318,7 @@ export default function Trips() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (tripsLoading) {
|
if (tripsLoading) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = trips.reduce(
|
const totals = trips.reduce(
|
||||||
@@ -216,440 +333,341 @@ export default function Trips() {
|
|||||||
{ total: 0, business: 0, private: 0, count: 0 },
|
{ total: 0, business: 0, private: 0, count: 0 },
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const recentTrips = trips.slice(0, 10);
|
||||||
<div>
|
|
||||||
<motion.div
|
const columns: DataColumn<BackendTrip>[] = [
|
||||||
className="admin-page-header"
|
{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
key: "trip_date",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
header: "Datum",
|
||||||
transition={{ duration: 0.25 }}
|
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} → {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>
|
{EditIcon}
|
||||||
<h1 className="admin-page-title">Kniha jízd</h1>
|
</IconButton>
|
||||||
<p className="admin-page-subtitle">
|
<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", {
|
{new Date().toLocaleDateString("cs-CZ", {
|
||||||
month: "long",
|
month: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
})}
|
})}
|
||||||
</p>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-page-actions">
|
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||||
<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 jízdu
|
Přidat jízdu
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-grid admin-grid-4"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "grid",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
gridTemplateColumns: {
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
xs: "1fr",
|
||||||
|
sm: "1fr 1fr",
|
||||||
|
lg: "repeat(4, 1fr)",
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-stat-card info">
|
<StatCard
|
||||||
<div className="admin-stat-icon info">
|
label="Počet jízd"
|
||||||
<svg
|
value={totals.count}
|
||||||
width="22"
|
icon={TripsIcon}
|
||||||
height="22"
|
color="info"
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
fill="none"
|
<StatCard
|
||||||
stroke="currentColor"
|
label="Celkem naježděno"
|
||||||
strokeWidth="2"
|
value={`${formatKm(totals.total)} km`}
|
||||||
strokeLinecap="round"
|
icon={TotalIcon}
|
||||||
strokeLinejoin="round"
|
color="default"
|
||||||
>
|
/>
|
||||||
<line x1="12" y1="20" x2="12" y2="10" />
|
<StatCard
|
||||||
<line x1="18" y1="20" x2="18" y2="4" />
|
label="Služební"
|
||||||
<line x1="6" y1="20" x2="6" y2="16" />
|
value={`${formatKm(totals.business)} km`}
|
||||||
</svg>
|
icon={BusinessIcon}
|
||||||
</div>
|
color="success"
|
||||||
<div className="admin-stat-content">
|
/>
|
||||||
<span className="admin-stat-value">{totals.count}</span>
|
<StatCard
|
||||||
<span className="admin-stat-label">Počet jízd</span>
|
label="Soukromé"
|
||||||
</div>
|
value={`${formatKm(totals.private)} km`}
|
||||||
</div>
|
icon={PrivateIcon}
|
||||||
|
color="warning"
|
||||||
<div className="admin-stat-card">
|
/>
|
||||||
<div className="admin-stat-icon">
|
</Box>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Recent Trips */}
|
{/* Recent Trips */}
|
||||||
<motion.div
|
<Card sx={{ mt: 3 }}>
|
||||||
className="admin-card mt-6"
|
<Box
|
||||||
initial={{ opacity: 0, y: 12 }}
|
sx={{
|
||||||
animate={{ opacity: 1, y: 0 }}
|
display: "flex",
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-card-header flex-between">
|
<Typography variant="h6">Poslední jízdy</Typography>
|
||||||
<h2 className="admin-card-title">Poslední jízdy</h2>
|
<Button
|
||||||
<Link
|
component={RouterLink}
|
||||||
to="/trips/history"
|
to="/trips/history"
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
>
|
>
|
||||||
Zobrazit historii
|
Zobrazit historii
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
<div className="admin-card-body">
|
<DataTable<BackendTrip>
|
||||||
{trips.length === 0 ? (
|
columns={columns}
|
||||||
<div className="admin-empty-state">
|
rows={recentTrips}
|
||||||
<div className="admin-empty-icon">
|
rowKey={(trip) => trip.id}
|
||||||
<svg
|
empty={
|
||||||
width="28"
|
<Box sx={{ textAlign: "center", py: 6 }}>
|
||||||
height="28"
|
<Typography color="text.secondary" gutterBottom>
|
||||||
viewBox="0 0 24 24"
|
Zatím nemáte žádné záznamy jízd.
|
||||||
fill="none"
|
</Typography>
|
||||||
stroke="currentColor"
|
<Button startIcon={PlusIcon} onClick={openCreateModal}>
|
||||||
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"
|
|
||||||
>
|
|
||||||
Přidat první jízdu
|
Přidat první jízdu
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
) : (
|
|
||||||
<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} → {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 })
|
|
||||||
}
|
}
|
||||||
className="admin-btn-icon danger"
|
/>
|
||||||
title="Smazat"
|
</Card>
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Add/Edit Modal */}
|
{/* Add/Edit Modal */}
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={showModal}
|
isOpen={showModal}
|
||||||
onClose={() => setShowModal(false)}
|
onClose={() => setShowModal(false)}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
title={editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
|
title={editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
|
||||||
loading={submitMutation.isPending}
|
loading={submitMutation.isPending}
|
||||||
size="lg"
|
maxWidth="md"
|
||||||
>
|
>
|
||||||
<div className="admin-form">
|
<Box
|
||||||
<div className="admin-form-row">
|
sx={{
|
||||||
<FormField label="Vozidlo" error={errors.vehicle_id} required>
|
display: "grid",
|
||||||
<select
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Vozidlo" required error={errors.vehicle_id}>
|
||||||
|
<Select
|
||||||
value={form.vehicle_id}
|
value={form.vehicle_id}
|
||||||
onChange={(e) => {
|
onChange={(value) => {
|
||||||
handleVehicleChange(e.target.value);
|
handleVehicleChange(value);
|
||||||
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
|
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
|
||||||
}}
|
}}
|
||||||
className="admin-form-select"
|
error={!!errors.vehicle_id}
|
||||||
>
|
options={[
|
||||||
<option value="">Vyberte vozidlo</option>
|
{ value: "", label: "Vyberte vozidlo" },
|
||||||
{vehicles.map((v) => (
|
...vehicles.map((v) => ({
|
||||||
<option key={v.id} value={v.id}>
|
value: String(v.id),
|
||||||
{v.spz} - {v.name}
|
label: `${v.spz} - ${v.name}`,
|
||||||
</option>
|
})),
|
||||||
))}
|
]}
|
||||||
</select>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Datum jízdy" error={errors.trip_date} required>
|
<Field label="Datum jízdy" required error={errors.trip_date}>
|
||||||
<AdminDatePicker
|
<DateField
|
||||||
mode="date"
|
|
||||||
value={form.trip_date}
|
value={form.trip_date}
|
||||||
onChange={(val: string) => {
|
onChange={(val) => {
|
||||||
setForm({ ...form, trip_date: val });
|
setForm({ ...form, trip_date: val });
|
||||||
setErrors((prev) => ({ ...prev, trip_date: "" }));
|
setErrors((prev) => ({ ...prev, trip_date: "" }));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row admin-form-row-3">
|
<Box
|
||||||
<FormField
|
sx={{
|
||||||
label="Počáteční stav km"
|
display: "grid",
|
||||||
error={errors.start_km}
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||||
required
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<Field label="Počáteční stav km" required error={errors.start_km}>
|
||||||
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={form.start_km}
|
value={form.start_km}
|
||||||
|
error={!!errors.start_km}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, start_km: e.target.value });
|
setForm({ ...form, start_km: e.target.value });
|
||||||
setErrors((prev) => ({ ...prev, start_km: "" }));
|
setErrors((prev) => ({ ...prev, start_km: "" }));
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Konečný stav km" error={errors.end_km} required>
|
<Field label="Konečný stav km" required error={errors.end_km}>
|
||||||
<input
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={form.end_km}
|
value={form.end_km}
|
||||||
|
error={!!errors.end_km}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, end_km: e.target.value });
|
setForm({ ...form, end_km: e.target.value });
|
||||||
setErrors((prev) => ({ ...prev, end_km: "" }));
|
setErrors((prev) => ({ ...prev, end_km: "" }));
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Vzdálenost">
|
<Field label="Vzdálenost">
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
value={`${formatKm(calculateDistance())} km`}
|
value={`${formatKm(calculateDistance())} km`}
|
||||||
className="admin-form-input"
|
|
||||||
readOnly
|
|
||||||
disabled
|
disabled
|
||||||
|
slotProps={{ htmlInput: { readOnly: true } }}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<FormField label="Místo odjezdu" error={errors.route_from} required>
|
sx={{
|
||||||
<input
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Místo odjezdu" required error={errors.route_from}>
|
||||||
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
value={form.route_from}
|
value={form.route_from}
|
||||||
|
error={!!errors.route_from}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, route_from: e.target.value });
|
setForm({ ...form, route_from: e.target.value });
|
||||||
setErrors((prev) => ({ ...prev, route_from: "" }));
|
setErrors((prev) => ({ ...prev, route_from: "" }));
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Např. Praha"
|
placeholder="Např. Praha"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Místo příjezdu" error={errors.route_to} required>
|
<Field label="Místo příjezdu" required error={errors.route_to}>
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
value={form.route_to}
|
value={form.route_to}
|
||||||
|
error={!!errors.route_to}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setForm({ ...form, route_to: e.target.value });
|
setForm({ ...form, route_to: e.target.value });
|
||||||
setErrors((prev) => ({ ...prev, route_to: "" }));
|
setErrors((prev) => ({ ...prev, route_to: "" }));
|
||||||
}}
|
}}
|
||||||
className="admin-form-input"
|
|
||||||
placeholder="Např. Brno"
|
placeholder="Např. Brno"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<FormField label="Typ jízdy">
|
<Field label="Typ jízdy">
|
||||||
<select
|
<Select
|
||||||
value={form.is_business}
|
value={String(form.is_business)}
|
||||||
onChange={(e) =>
|
onChange={(value) =>
|
||||||
setForm({
|
setForm({ ...form, is_business: parseInt(value) })
|
||||||
...form,
|
|
||||||
is_business: parseInt(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "1", label: "Služební" },
|
||||||
<option value={1}>Služební</option>
|
{ value: "0", label: "Soukromá" },
|
||||||
<option value={0}>Soukromá</option>
|
]}
|
||||||
</select>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Poznámky">
|
<Field label="Poznámky">
|
||||||
<textarea
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||||
className="admin-form-textarea"
|
|
||||||
rows={2}
|
|
||||||
placeholder="Volitelné poznámky..."
|
placeholder="Volitelné poznámky..."
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Modal>
|
||||||
</FormModal>
|
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => setDeleteConfirm({ show: false, tripId: null })}
|
onClose={() => setDeleteConfirm({ show: false, tripId: null })}
|
||||||
onConfirm={() => handleDelete(deleteConfirm.tripId!)}
|
onConfirm={() => handleDelete(deleteConfirm.tripId!)}
|
||||||
@@ -657,9 +675,9 @@ export default function Trips() {
|
|||||||
message="Opravdu chcete smazat tento záznam?"
|
message="Opravdu chcete smazat tento záznam?"
|
||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
cancelText="Zrušit"
|
cancelText="Zrušit"
|
||||||
type="danger"
|
confirmVariant="danger"
|
||||||
loading={deleteMutation.isPending}
|
loading={deleteMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,42 @@
|
|||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
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 { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import FormModal from "../components/FormModal";
|
|
||||||
import {
|
import {
|
||||||
tripListOptions,
|
tripListOptions,
|
||||||
tripVehiclesOptions,
|
tripVehiclesOptions,
|
||||||
tripUsersOptions,
|
tripUsersOptions,
|
||||||
type BackendTrip,
|
type BackendTrip,
|
||||||
type TripVehicle,
|
|
||||||
type TripUser,
|
|
||||||
} from "../lib/queries/trips";
|
} from "../lib/queries/trips";
|
||||||
import {
|
import { companySettingsOptions } from "../lib/queries/settings";
|
||||||
companySettingsOptions,
|
|
||||||
type CompanySettingsData,
|
|
||||||
} from "../lib/queries/settings";
|
|
||||||
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import { formatDate } from "../utils/attendanceHelpers";
|
import { formatDate } from "../utils/attendanceHelpers";
|
||||||
import { formatKm } from "../utils/formatters";
|
import { formatKm } from "../utils/formatters";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
DataTable,
|
||||||
|
Modal,
|
||||||
|
ConfirmDialog,
|
||||||
|
Field,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
DateField,
|
||||||
|
MonthField,
|
||||||
|
StatusChip,
|
||||||
|
FilterBar,
|
||||||
|
LoadingState,
|
||||||
|
EmptyState,
|
||||||
|
StatCard,
|
||||||
|
PageEnter,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
const API_BASE = "/api/admin";
|
const API_BASE = "/api/admin";
|
||||||
|
|
||||||
interface Trip {
|
interface Trip {
|
||||||
@@ -71,15 +83,109 @@ function mapTrip(bt: BackendTrip): Trip {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PrintIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 6 2 18 2 18 9" />
|
||||||
|
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||||
|
<rect x="6" y="14" width="12" height="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const EditIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DeleteIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TripsIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="20" x2="12" y2="10" />
|
||||||
|
<line x1="18" y1="20" x2="18" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="16" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const TotalIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const BusinessIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
||||||
|
<circle cx="5.5" cy="18" r="2" />
|
||||||
|
<circle cx="18.5" cy="18" r="2" />
|
||||||
|
<path d="M8 18h8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function TripsAdmin() {
|
export default function TripsAdmin() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const [filterMonth, setFilterMonth] = useState(() =>
|
const [filterPeriod, setFilterPeriod] = useState(() => {
|
||||||
String(new Date().getMonth() + 1),
|
const now = new Date();
|
||||||
);
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
const [filterYear, setFilterYear] = useState(() =>
|
});
|
||||||
String(new Date().getFullYear()),
|
|
||||||
);
|
|
||||||
const [filterVehicleId, setFilterVehicleId] = useState("");
|
const [filterVehicleId, setFilterVehicleId] = useState("");
|
||||||
const [filterUserId, setFilterUserId] = useState("");
|
const [filterUserId, setFilterUserId] = useState("");
|
||||||
const printRef = useRef<HTMLDivElement>(null);
|
const printRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -113,8 +219,8 @@ export default function TripsAdmin() {
|
|||||||
|
|
||||||
const { data: tripsData, isPending } = useQuery(
|
const { data: tripsData, isPending } = useQuery(
|
||||||
tripListOptions({
|
tripListOptions({
|
||||||
month: Number(filterMonth) || undefined,
|
month: Number(filterPeriod.slice(5, 7)) || undefined,
|
||||||
year: Number(filterYear) || undefined,
|
year: Number(filterPeriod.slice(0, 4)) || undefined,
|
||||||
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
|
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
|
||||||
userId: filterUserId ? Number(filterUserId) : undefined,
|
userId: filterUserId ? Number(filterUserId) : undefined,
|
||||||
perPage: 100,
|
perPage: 100,
|
||||||
@@ -212,10 +318,10 @@ export default function TripsAdmin() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPeriodName = () =>
|
const getPeriodName = () =>
|
||||||
new Date(Number(filterYear), Number(filterMonth) - 1).toLocaleString(
|
new Date(
|
||||||
"cs-CZ",
|
Number(filterPeriod.slice(0, 4)),
|
||||||
{ month: "long", year: "numeric" },
|
Number(filterPeriod.slice(5, 7)) - 1,
|
||||||
);
|
).toLocaleString("cs-CZ", { month: "long", year: "numeric" });
|
||||||
const getSelectedVehicleName = () => {
|
const getSelectedVehicleName = () => {
|
||||||
if (!filterVehicleId) return null;
|
if (!filterVehicleId) return null;
|
||||||
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
|
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
|
||||||
@@ -331,463 +437,352 @@ export default function TripsAdmin() {
|
|||||||
.reduce((sum, t) => sum + t.distance, 0),
|
.reduce((sum, t) => sum + t.distance, 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const columns: DataColumn<Trip>[] = [
|
||||||
<div>
|
{
|
||||||
<motion.div
|
key: "trip_date",
|
||||||
className="admin-page-header"
|
header: "Datum",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
width: "11%",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
mono: true,
|
||||||
transition={{ duration: 0.25 }}
|
render: (trip) => formatDate(trip.trip_date),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "driver",
|
||||||
|
header: "Řidič",
|
||||||
|
width: "16%",
|
||||||
|
render: (trip) => trip.driver_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vehicle",
|
||||||
|
header: "Vozidlo",
|
||||||
|
width: "11%",
|
||||||
|
mono: true,
|
||||||
|
render: (trip) => <StatusChip label={trip.spz} color="default" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "route",
|
||||||
|
header: "Trasa",
|
||||||
|
width: "20%",
|
||||||
|
render: (trip) => (
|
||||||
|
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||||
|
{trip.route_from} → {trip.route_to}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "km",
|
||||||
|
header: "Stav km",
|
||||||
|
width: "14%",
|
||||||
|
mono: true,
|
||||||
|
render: (trip) => (
|
||||||
|
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "distance",
|
||||||
|
header: "Vzdálenost",
|
||||||
|
width: "12%",
|
||||||
|
mono: true,
|
||||||
|
bold: true,
|
||||||
|
render: (trip) => `${formatKm(trip.distance)} km`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
header: "Typ",
|
||||||
|
width: "10%",
|
||||||
|
render: (trip) => (
|
||||||
|
<StatusChip
|
||||||
|
label={trip.is_business ? "Služební" : "Soukromá"}
|
||||||
|
color={trip.is_business ? "success" : "warning"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Akce",
|
||||||
|
width: "10%",
|
||||||
|
align: "right",
|
||||||
|
render: (trip) => (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => openEditModal(trip)}
|
||||||
|
aria-label="Upravit"
|
||||||
|
title="Upravit"
|
||||||
>
|
>
|
||||||
<div>
|
{EditIcon}
|
||||||
<h1 className="admin-page-title">Správa knihy jízd</h1>
|
</IconButton>
|
||||||
</div>
|
<IconButton
|
||||||
<div className="admin-page-actions">
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setDeleteConfirm({ show: true, trip })}
|
||||||
|
aria-label="Smazat"
|
||||||
|
title="Smazat"
|
||||||
|
>
|
||||||
|
{DeleteIcon}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageEnter>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4">Správa knihy jízd</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||||
{trips.length > 0 && (
|
{trips.length > 0 && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
startIcon={PrintIcon}
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
title="Tisk knihy jízd"
|
title="Tisk knihy jízd"
|
||||||
>
|
>
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
style={{ marginRight: "0.5rem" }}
|
|
||||||
>
|
|
||||||
<polyline points="6 9 6 2 18 2 18 9" />
|
|
||||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
|
||||||
<rect x="6" y="14" width="12" height="8" />
|
|
||||||
</svg>
|
|
||||||
Tisk
|
Tisk
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Link to="/vehicles" className="admin-btn admin-btn-secondary">
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
|
to="/vehicles"
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
>
|
||||||
Vozidla
|
Vozidla
|
||||||
</Link>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
</motion.div>
|
</Box>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<motion.div
|
<FilterBar>
|
||||||
className="admin-card"
|
<Box sx={{ flex: "0 0 180px" }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<MonthField value={filterPeriod} onChange={setFilterPeriod} />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Box>
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
<Box sx={{ flex: "0 0 220px" }}>
|
||||||
>
|
<Select
|
||||||
<div className="admin-card-body">
|
|
||||||
<div className="admin-form-row admin-form-row-4">
|
|
||||||
<FormField label="Měsíc" style={{ marginBottom: 0 }}>
|
|
||||||
<select
|
|
||||||
value={filterMonth}
|
|
||||||
onChange={(e) => setFilterMonth(e.target.value)}
|
|
||||||
className="admin-form-select"
|
|
||||||
>
|
|
||||||
{Array.from({ length: 12 }, (_, i) => (
|
|
||||||
<option key={i + 1} value={i + 1}>
|
|
||||||
{new Date(2000, i).toLocaleString("cs-CZ", {
|
|
||||||
month: "long",
|
|
||||||
})}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Rok" style={{ marginBottom: 0 }}>
|
|
||||||
<select
|
|
||||||
value={filterYear}
|
|
||||||
onChange={(e) => setFilterYear(e.target.value)}
|
|
||||||
className="admin-form-select"
|
|
||||||
>
|
|
||||||
{Array.from({ length: 5 }, (_, i) => {
|
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
|
||||||
return (
|
|
||||||
<option key={y} value={y}>
|
|
||||||
{y}
|
|
||||||
</option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Vozidlo" style={{ marginBottom: 0 }}>
|
|
||||||
<select
|
|
||||||
value={filterVehicleId}
|
value={filterVehicleId}
|
||||||
onChange={(e) => setFilterVehicleId(e.target.value)}
|
onChange={setFilterVehicleId}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "", label: "Všechna vozidla" },
|
||||||
<option value="">Všechna vozidla</option>
|
...vehicles.map((v) => ({
|
||||||
{vehicles.map((v) => (
|
value: String(v.id),
|
||||||
<option key={v.id} value={v.id}>
|
label: `${v.spz} - ${v.name}`,
|
||||||
{v.spz} - {v.name}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
/>
|
||||||
</select>
|
</Box>
|
||||||
</FormField>
|
<Box sx={{ flex: "0 0 220px" }}>
|
||||||
<FormField label="Řidič" style={{ marginBottom: 0 }}>
|
<Select
|
||||||
<select
|
|
||||||
value={filterUserId}
|
value={filterUserId}
|
||||||
onChange={(e) => setFilterUserId(e.target.value)}
|
onChange={setFilterUserId}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "", label: "Všichni řidiči" },
|
||||||
<option value="">Všichni řidiči</option>
|
...tripUsers.map((u) => ({
|
||||||
{tripUsers.map((u) => (
|
value: String(u.id),
|
||||||
<option key={u.id} value={u.id}>
|
label: u.name,
|
||||||
{u.name}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
/>
|
||||||
</select>
|
</Box>
|
||||||
</FormField>
|
</FilterBar>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
{/* Stats Cards */}
|
||||||
className="admin-grid admin-grid-3 mt-6"
|
<Box
|
||||||
initial={{ opacity: 0, y: 12 }}
|
sx={{
|
||||||
animate={{ opacity: 1, y: 0 }}
|
display: "grid",
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
gridTemplateColumns: {
|
||||||
|
xs: "1fr",
|
||||||
|
sm: "repeat(3, 1fr)",
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-stat-card info">
|
<StatCard
|
||||||
<div className="admin-stat-icon info">
|
label="Počet jízd"
|
||||||
<svg
|
value={totals.count}
|
||||||
width="22"
|
icon={TripsIcon}
|
||||||
height="22"
|
color="info"
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
fill="none"
|
<StatCard
|
||||||
stroke="currentColor"
|
label="Celkem naježděno"
|
||||||
strokeWidth="2"
|
value={`${formatKm(totals.total)} km`}
|
||||||
strokeLinecap="round"
|
icon={TotalIcon}
|
||||||
strokeLinejoin="round"
|
color="default"
|
||||||
>
|
/>
|
||||||
<line x1="12" y1="20" x2="12" y2="10" />
|
<StatCard
|
||||||
<line x1="18" y1="20" x2="18" y2="4" />
|
label="Služební km"
|
||||||
<line x1="6" y1="20" x2="6" y2="16" />
|
value={`${formatKm(totals.business)} km`}
|
||||||
</svg>
|
icon={BusinessIcon}
|
||||||
</div>
|
color="success"
|
||||||
<div className="admin-stat-content">
|
/>
|
||||||
<span className="admin-stat-value">{totals.count}</span>
|
</Box>
|
||||||
<span className="admin-stat-label">Počet jízd</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-card">
|
|
||||||
<div className="admin-stat-icon">
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-content">
|
|
||||||
<span className="admin-stat-value">
|
|
||||||
{formatKm(totals.total)} km
|
|
||||||
</span>
|
|
||||||
<span className="admin-stat-label">Celkem naježděno</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-card success">
|
|
||||||
<div className="admin-stat-icon success">
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
|
||||||
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
|
||||||
<circle cx="5.5" cy="18" r="2" />
|
|
||||||
<circle cx="18.5" cy="18" r="2" />
|
|
||||||
<path d="M8 18h8" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-content">
|
|
||||||
<span className="admin-stat-value">
|
|
||||||
{formatKm(totals.business)} km
|
|
||||||
</span>
|
|
||||||
<span className="admin-stat-label">Služební km</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Trips Table */}
|
{/* Trips Table */}
|
||||||
<motion.div
|
<Card sx={{ mt: 3 }}>
|
||||||
className="admin-card mt-6"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
|
||||||
>
|
|
||||||
<div className="admin-card-body">
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<div className="admin-loading">
|
<LoadingState />
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<DataTable<Trip>
|
||||||
{trips.length === 0 && (
|
columns={columns}
|
||||||
<div className="admin-empty-state">
|
rows={trips}
|
||||||
<p>Žádné záznamy jízd pro vybrané období.</p>
|
rowKey={(trip) => trip.id}
|
||||||
</div>
|
empty={
|
||||||
)}
|
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
||||||
{trips.length > 0 && (
|
|
||||||
<div className="admin-table-responsive">
|
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Datum</th>
|
|
||||||
<th>Řidič</th>
|
|
||||||
<th>Vozidlo</th>
|
|
||||||
<th>Trasa</th>
|
|
||||||
<th>Stav km</th>
|
|
||||||
<th>Vzdálenost</th>
|
|
||||||
<th>Typ</th>
|
|
||||||
<th>Akce</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{trips.map((trip) => (
|
|
||||||
<tr key={trip.id}>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{formatDate(trip.trip_date)}
|
|
||||||
</td>
|
|
||||||
<td>{trip.driver_name}</td>
|
|
||||||
<td>
|
|
||||||
<span className="admin-badge">{trip.spz}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{trip.route_from} → {trip.route_to}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
<span style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{formatKm(trip.start_km)} -{" "}
|
|
||||||
{formatKm(trip.end_km)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
<strong>{formatKm(trip.distance)} km</strong>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
|
|
||||||
>
|
|
||||||
{trip.is_business ? "Služební" : "Soukromá"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="admin-table-actions">
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(trip)}
|
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Upravit"
|
|
||||||
aria-label="Upravit"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setDeleteConfirm({ show: true, trip })
|
|
||||||
}
|
}
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</Card>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={showEditModal && !!editingTrip}
|
isOpen={showEditModal && !!editingTrip}
|
||||||
onClose={() => setShowEditModal(false)}
|
onClose={() => setShowEditModal(false)}
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
title="Upravit jízdu"
|
title="Upravit jízdu"
|
||||||
|
subtitle={editingTrip?.driver_name}
|
||||||
loading={editMutation.isPending}
|
loading={editMutation.isPending}
|
||||||
size="lg"
|
maxWidth="lg"
|
||||||
>
|
>
|
||||||
{editingTrip && (
|
{editingTrip && (
|
||||||
<div className="admin-form">
|
<>
|
||||||
<p
|
<Box
|
||||||
className="text-secondary"
|
sx={{
|
||||||
style={{ marginTop: "0", marginBottom: "0.75rem" }}
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{editingTrip.driver_name}
|
<Field label="Vozidlo">
|
||||||
</p>
|
<Select
|
||||||
<div className="admin-form-row">
|
|
||||||
<FormField label="Vozidlo">
|
|
||||||
<select
|
|
||||||
value={editForm.vehicle_id}
|
value={editForm.vehicle_id}
|
||||||
onChange={(e) =>
|
onChange={(value) =>
|
||||||
setEditForm({
|
setEditForm({ ...editForm, vehicle_id: value })
|
||||||
...editForm,
|
|
||||||
vehicle_id: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className="admin-form-select"
|
options={vehicles.map((v) => ({
|
||||||
>
|
value: String(v.id),
|
||||||
{vehicles.map((v) => (
|
label: `${v.spz} - ${v.name}`,
|
||||||
<option key={v.id} value={v.id}>
|
}))}
|
||||||
{v.spz} - {v.name}
|
/>
|
||||||
</option>
|
</Field>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Datum jízdy">
|
<Field label="Datum jízdy">
|
||||||
<AdminDatePicker
|
<DateField
|
||||||
mode="date"
|
|
||||||
value={editForm.trip_date}
|
value={editForm.trip_date}
|
||||||
onChange={(val: string) =>
|
onChange={(val) =>
|
||||||
setEditForm({ ...editForm, trip_date: val })
|
setEditForm({ ...editForm, trip_date: val })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<FormField label="Počáteční stav km">
|
sx={{
|
||||||
<input
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Počáteční stav km">
|
||||||
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={editForm.start_km}
|
value={editForm.start_km}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, start_km: e.target.value })
|
setEditForm({ ...editForm, start_km: e.target.value })
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Konečný stav km">
|
<Field label="Konečný stav km">
|
||||||
<input
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={editForm.end_km}
|
value={editForm.end_km}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, end_km: e.target.value })
|
setEditForm({ ...editForm, end_km: e.target.value })
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
slotProps={{ htmlInput: { min: 0 } }}
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Vzdálenost">
|
<Field label="Vzdálenost">
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
value={`${formatKm(calculateDistance())} km`}
|
value={`${formatKm(calculateDistance())} km`}
|
||||||
className="admin-form-input"
|
|
||||||
readOnly
|
|
||||||
disabled
|
disabled
|
||||||
|
slotProps={{ htmlInput: { readOnly: true } }}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<FormField label="Místo odjezdu">
|
sx={{
|
||||||
<input
|
display: "grid",
|
||||||
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Místo odjezdu">
|
||||||
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
value={editForm.route_from}
|
value={editForm.route_from}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({
|
setEditForm({ ...editForm, route_from: e.target.value })
|
||||||
...editForm,
|
|
||||||
route_from: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Místo příjezdu">
|
<Field label="Místo příjezdu">
|
||||||
<input
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
value={editForm.route_to}
|
value={editForm.route_to}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, route_to: e.target.value })
|
setEditForm({ ...editForm, route_to: e.target.value })
|
||||||
}
|
}
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<FormField label="Typ jízdy">
|
<Field label="Typ jízdy">
|
||||||
<select
|
<Select
|
||||||
value={editForm.is_business}
|
value={String(editForm.is_business)}
|
||||||
onChange={(e) =>
|
onChange={(value) =>
|
||||||
setEditForm({
|
setEditForm({ ...editForm, is_business: parseInt(value) })
|
||||||
...editForm,
|
|
||||||
is_business: parseInt(e.target.value),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "1", label: "Služební" },
|
||||||
<option value={1}>Služební</option>
|
{ value: "0", label: "Soukromá" },
|
||||||
<option value={0}>Soukromá</option>
|
]}
|
||||||
</select>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
|
|
||||||
<FormField label="Poznámky">
|
<Field label="Poznámky">
|
||||||
<textarea
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
value={editForm.notes}
|
value={editForm.notes}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setEditForm({ ...editForm, notes: e.target.value })
|
setEditForm({ ...editForm, notes: e.target.value })
|
||||||
}
|
}
|
||||||
className="admin-form-textarea"
|
|
||||||
rows={2}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</Field>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</FormModal>
|
</Modal>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => setDeleteConfirm({ show: false, trip: null })}
|
onClose={() => setDeleteConfirm({ show: false, trip: null })}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
@@ -915,6 +910,6 @@ export default function TripsAdmin() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</PageEnter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import Box from "@mui/material/Box";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { formatDate } from "../utils/attendanceHelpers";
|
import { formatDate } from "../utils/attendanceHelpers";
|
||||||
import { formatKm } from "../utils/formatters";
|
import { formatKm } from "../utils/formatters";
|
||||||
import FormField from "../components/FormField";
|
import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips";
|
||||||
import {
|
import {
|
||||||
tripHistoryOptions,
|
Card,
|
||||||
tripVehiclesOptions,
|
DataTable,
|
||||||
type BackendTrip,
|
Select,
|
||||||
type TripVehicle,
|
MonthField,
|
||||||
} from "../lib/queries/trips";
|
StatusChip,
|
||||||
|
PageHeader,
|
||||||
|
PageEnter,
|
||||||
|
FilterBar,
|
||||||
|
EmptyState,
|
||||||
|
LoadingState,
|
||||||
|
StatCard,
|
||||||
|
type DataColumn,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
interface Trip {
|
interface Trip {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -29,8 +35,56 @@ interface Trip {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TripsIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="20" x2="12" y2="10" />
|
||||||
|
<line x1="18" y1="20" x2="18" y2="4" />
|
||||||
|
<line x1="6" y1="20" x2="6" y2="16" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const TotalIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
const BusinessIcon = (
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
||||||
|
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
||||||
|
<circle cx="5.5" cy="18" r="2" />
|
||||||
|
<circle cx="18.5" cy="18" r="2" />
|
||||||
|
<path d="M8 18h8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function TripsHistory() {
|
export default function TripsHistory() {
|
||||||
const alert = useAlert();
|
|
||||||
const { user, hasPermission } = useAuth();
|
const { user, hasPermission } = useAuth();
|
||||||
const [month, setMonth] = useState(() => {
|
const [month, setMonth] = useState(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -48,7 +102,7 @@ export default function TripsHistory() {
|
|||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const trips = (tripsData ?? []).map((t) => ({
|
const trips: Trip[] = (tripsData ?? []).map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
trip_date: t.trip_date,
|
trip_date: t.trip_date,
|
||||||
spz: t.vehicles?.spz ?? "",
|
spz: t.vehicles?.spz ?? "",
|
||||||
@@ -81,222 +135,156 @@ export default function TripsHistory() {
|
|||||||
return date.toLocaleDateString("cs-CZ", { month: "long", year: "numeric" });
|
return date.toLocaleDateString("cs-CZ", { month: "long", year: "numeric" });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const columns: DataColumn<Trip>[] = [
|
||||||
<div>
|
{
|
||||||
<motion.div
|
key: "trip_date",
|
||||||
className="admin-page-header"
|
header: "Datum",
|
||||||
initial={{ opacity: 0, y: 12 }}
|
width: "11%",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
mono: true,
|
||||||
transition={{ duration: 0.25 }}
|
render: (trip) => formatDate(trip.trip_date),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vehicle",
|
||||||
|
header: "Vozidlo",
|
||||||
|
width: "10%",
|
||||||
|
render: (trip) => <StatusChip label={trip.spz} color="default" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "driver",
|
||||||
|
header: "Řidič",
|
||||||
|
width: "14%",
|
||||||
|
render: (trip) => (
|
||||||
|
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||||
|
{trip.driver_name}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "route",
|
||||||
|
header: "Trasa",
|
||||||
|
width: "18%",
|
||||||
|
render: (trip) => (
|
||||||
|
<Box component="span" sx={{ whiteSpace: "nowrap" }}>
|
||||||
|
{trip.route_from} → {trip.route_to}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "km",
|
||||||
|
header: "Stav km",
|
||||||
|
width: "13%",
|
||||||
|
mono: true,
|
||||||
|
render: (trip) => (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{ whiteSpace: "nowrap", color: "text.secondary" }}
|
||||||
>
|
>
|
||||||
<div>
|
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
|
||||||
<h1 className="admin-page-title">Historie jízd</h1>
|
</Box>
|
||||||
<p className="admin-page-subtitle">{getMonthName(month)}</p>
|
),
|
||||||
</div>
|
},
|
||||||
</motion.div>
|
{
|
||||||
|
key: "distance",
|
||||||
|
header: "Vzdálenost",
|
||||||
|
width: "10%",
|
||||||
|
mono: true,
|
||||||
|
bold: true,
|
||||||
|
render: (trip) => `${formatKm(trip.distance)} km`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
header: "Typ",
|
||||||
|
width: "10%",
|
||||||
|
render: (trip) => (
|
||||||
|
<StatusChip
|
||||||
|
label={trip.is_business ? "Služební" : "Soukromá"}
|
||||||
|
color={trip.is_business ? "success" : "warning"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "notes",
|
||||||
|
header: "Poznámka",
|
||||||
|
width: "14%",
|
||||||
|
render: (trip) => (
|
||||||
|
<Box component="span" sx={{ color: "text.secondary" }}>
|
||||||
|
{trip.notes || "—"}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageEnter>
|
||||||
|
<PageHeader title="Historie jízd" subtitle={getMonthName(month)} />
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<motion.div
|
<FilterBar>
|
||||||
className="admin-card"
|
<Box sx={{ flex: "0 0 180px" }}>
|
||||||
initial={{ opacity: 0, y: 12 }}
|
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</Box>
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
<Box sx={{ flex: "0 0 220px" }}>
|
||||||
>
|
<Select
|
||||||
<div className="admin-card-body">
|
|
||||||
<div className="admin-form-row">
|
|
||||||
<FormField label="Měsíc">
|
|
||||||
<AdminDatePicker
|
|
||||||
mode="month"
|
|
||||||
value={month}
|
|
||||||
onChange={(val: string) => setMonth(val)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
<FormField label="Vozidlo">
|
|
||||||
<select
|
|
||||||
value={vehicleId}
|
value={vehicleId}
|
||||||
onChange={(e) => setVehicleId(e.target.value)}
|
onChange={(value) => setVehicleId(value)}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "", label: "Všechna vozidla" },
|
||||||
<option value="">Všechna vozidla</option>
|
...vehicles.map((v) => ({
|
||||||
{vehicles.map((v) => (
|
value: String(v.id),
|
||||||
<option key={v.id} value={v.id}>
|
label: `${v.spz} - ${v.name}`,
|
||||||
{v.spz} - {v.name}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
/>
|
||||||
</select>
|
</Box>
|
||||||
</FormField>
|
</FilterBar>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
<Box
|
||||||
className="admin-grid admin-grid-3 mt-6"
|
sx={{
|
||||||
initial={{ opacity: 0, y: 12 }}
|
display: "grid",
|
||||||
animate={{ opacity: 1, y: 0 }}
|
gridTemplateColumns: {
|
||||||
transition={{ duration: 0.25, delay: 0.08 }}
|
xs: "1fr",
|
||||||
|
sm: "1fr 1fr",
|
||||||
|
lg: "repeat(3, 1fr)",
|
||||||
|
},
|
||||||
|
gap: 2,
|
||||||
|
mt: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="admin-stat-card info">
|
<StatCard
|
||||||
<div className="admin-stat-icon info">
|
label="Počet jízd"
|
||||||
<svg
|
value={totals.count}
|
||||||
width="22"
|
icon={TripsIcon}
|
||||||
height="22"
|
color="info"
|
||||||
viewBox="0 0 24 24"
|
/>
|
||||||
fill="none"
|
<StatCard
|
||||||
stroke="currentColor"
|
label="Celkem naježděno"
|
||||||
strokeWidth="2"
|
value={`${formatKm(totals.total)} km`}
|
||||||
strokeLinecap="round"
|
icon={TotalIcon}
|
||||||
strokeLinejoin="round"
|
color="default"
|
||||||
>
|
/>
|
||||||
<line x1="12" y1="20" x2="12" y2="10" />
|
<StatCard
|
||||||
<line x1="18" y1="20" x2="18" y2="4" />
|
label="Služební km"
|
||||||
<line x1="6" y1="20" x2="6" y2="16" />
|
value={`${formatKm(totals.business)} km`}
|
||||||
</svg>
|
icon={BusinessIcon}
|
||||||
</div>
|
color="success"
|
||||||
<div className="admin-stat-content">
|
/>
|
||||||
<span className="admin-stat-value">{totals.count}</span>
|
</Box>
|
||||||
<span className="admin-stat-label">Počet jízd</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-card">
|
|
||||||
<div className="admin-stat-icon">
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-content">
|
|
||||||
<span className="admin-stat-value">
|
|
||||||
{formatKm(totals.total)} km
|
|
||||||
</span>
|
|
||||||
<span className="admin-stat-label">Celkem naježděno</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-card success">
|
|
||||||
<div className="admin-stat-icon success">
|
|
||||||
<svg
|
|
||||||
width="22"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
|
|
||||||
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
|
|
||||||
<circle cx="5.5" cy="18" r="2" />
|
|
||||||
<circle cx="18.5" cy="18" r="2" />
|
|
||||||
<path d="M8 18h8" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-content">
|
|
||||||
<span className="admin-stat-value">
|
|
||||||
{formatKm(totals.business)} km
|
|
||||||
</span>
|
|
||||||
<span className="admin-stat-label">Služební km</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Trips Table */}
|
{/* Trips Table */}
|
||||||
<motion.div
|
<Card sx={{ mt: 3 }}>
|
||||||
className="admin-card mt-6"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
|
||||||
>
|
|
||||||
<div className="admin-card-body">
|
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<div className="admin-loading">
|
<LoadingState />
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<DataTable<Trip>
|
||||||
{trips.length === 0 && (
|
columns={columns}
|
||||||
<div className="admin-empty-state">
|
rows={trips}
|
||||||
<p>Žádné záznamy jízd pro vybrané období.</p>
|
rowKey={(trip) => trip.id}
|
||||||
</div>
|
empty={
|
||||||
|
<EmptyState title="Žádné záznamy jízd pro vybrané období." />
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{trips.length > 0 && (
|
</Card>
|
||||||
<div className="admin-table-responsive">
|
</PageEnter>
|
||||||
<table className="admin-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Datum</th>
|
|
||||||
<th>Vozidlo</th>
|
|
||||||
<th>Řidič</th>
|
|
||||||
<th>Trasa</th>
|
|
||||||
<th>Stav km</th>
|
|
||||||
<th>Vzdálenost</th>
|
|
||||||
<th>Typ</th>
|
|
||||||
<th>Poznámka</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{trips.map((trip) => (
|
|
||||||
<tr key={trip.id}>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{formatDate(trip.trip_date)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span className="admin-badge">{trip.spz}</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{trip.driver_name}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span style={{ whiteSpace: "nowrap" }}>
|
|
||||||
{trip.route_from} → {trip.route_to}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formatKm(trip.start_km)} -{" "}
|
|
||||||
{formatKm(trip.end_km)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
<strong>{formatKm(trip.distance)} km</strong>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
|
|
||||||
>
|
|
||||||
{trip.is_business ? "Služební" : "Soukromá"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
style={{
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
maxWidth: "200px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{trip.notes || "—"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user