Compare commits
221 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6cd5046d5 | ||
|
|
25c8c39ba6 | ||
|
|
4b2770a000 | ||
|
|
434a12b4a8 | ||
|
|
28fb399217 | ||
|
|
c0ff7b4549 | ||
|
|
1e21c12f33 | ||
|
|
ab12f9d791 | ||
|
|
cf442b9a99 | ||
|
|
2e7caea5a6 | ||
|
|
702a4984ee | ||
|
|
769767c33d | ||
|
|
f18e2e0f2c | ||
|
|
d62abe81fd | ||
|
|
ac7c220bb1 | ||
|
|
b7ebf04001 | ||
|
|
441f46a471 | ||
|
|
f011a67ff3 | ||
|
|
92594f89c3 | ||
|
|
6664e3bc4d | ||
|
|
c5cfd855ba | ||
|
|
2565e0de21 | ||
|
|
817f0b2f00 | ||
|
|
e4ec08a5ef | ||
|
|
049ee492c0 | ||
|
|
f4bc038f8b | ||
|
|
58e5fd2b1d | ||
|
|
0226cdd52b | ||
|
|
b665ea1d9b | ||
|
|
ec74ded953 | ||
|
|
bcf63d00d1 | ||
|
|
096784768f | ||
|
|
3623b441ce | ||
|
|
4bf7bf4ed6 | ||
|
|
92252382ac | ||
|
|
8bed920de1 | ||
|
|
9d2992b722 | ||
|
|
1ddc0feccd | ||
|
|
cce2c9cfaa | ||
|
|
2610301258 | ||
|
|
13d1fc2be3 | ||
|
|
95ca258718 | ||
|
|
6db87bf4ae | ||
|
|
c6a146d57a | ||
|
|
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 |
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/
|
||||||
|
|||||||
53
CLAUDE.md
53
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)
|
||||||
@@ -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.
|
||||||
1274
docs/superpowers/plans/2026-06-08-ai-assistant-phase1.md
Normal file
1274
docs/superpowers/plans/2026-06-08-ai-assistant-phase1.md
Normal file
File diff suppressed because it is too large
Load Diff
891
docs/superpowers/plans/2026-06-08-bulk-plan-creation.md
Normal file
891
docs/superpowers/plans/2026-06-08-bulk-plan-creation.md
Normal file
@@ -0,0 +1,891 @@
|
|||||||
|
# Bulk Plan Creation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Assign one project/category to many employees over a date range in a single action ("Hromadné přiřazení" on /plan-work).
|
||||||
|
|
||||||
|
**Architecture:** A new `POST /api/admin/plan/entries/bulk` endpoint drives a `bulkCreateEntries` service that, per employee, computes the eligible days in the range (weekday filter + Feature A's under-cap rule), groups contiguous eligible days into runs, and creates one range `work_plan_entries` row per run by **reusing `createEntry`**. The frontend adds a presentational `BulkPlanModal` (mirroring the single-create form + the existing bulk-attendance employee picker) wired from `PlanWork`, with a summary alert and `["plan"]` invalidation. No DB migration.
|
||||||
|
|
||||||
|
**Tech Stack:** Fastify 5 + Prisma (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (server-side, real test DB). Spec: `docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md`. Builds on Feature A (multi-record plan cells, v2.0.5).
|
||||||
|
|
||||||
|
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`. The frontend has no component-test harness (server-side tests only), so Task 2 gates on `tsc -b` + `build`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Backend**
|
||||||
|
|
||||||
|
- `src/schemas/plan.schema.ts` — add `BulkPlanEntrySchema`.
|
||||||
|
- `src/services/plan.service.ts` — add `bulkCreateEntries(...)` (reuses `createEntry`, `assertNotPastDate`, `assertActiveCategory`, `toDateOnly`, `MAX_RECORDS_PER_CELL`).
|
||||||
|
- `src/routes/admin/plan.ts` — add `POST /entries/bulk`.
|
||||||
|
|
||||||
|
**Frontend**
|
||||||
|
|
||||||
|
- `src/admin/components/BulkPlanModal.tsx` — new presentational modal.
|
||||||
|
- `src/admin/hooks/usePlanWork.ts` — add a `bulkCreate` mutation.
|
||||||
|
- `src/admin/pages/PlanWork.tsx` — toolbar button + bulk form state + toggles + submit wiring.
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
|
||||||
|
- `src/__tests__/plan.test.ts` — `bulkCreateEntries` service tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — bulk endpoint + service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/schemas/plan.schema.ts`
|
||||||
|
- Modify: `src/services/plan.service.ts`
|
||||||
|
- Modify: `src/routes/admin/plan.ts`
|
||||||
|
- Test: `src/__tests__/plan.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing service tests**
|
||||||
|
|
||||||
|
Add this block to the end of `src/__tests__/plan.test.ts` (before the final HTTP describe block is fine; anywhere at top level). It imports `bulkCreateEntries` — add it to the existing `import { ... } from "../services/plan.service"` list at the top of the file.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// bulkCreateEntries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("plan.service.bulkCreateEntries", () => {
|
||||||
|
it("creates one continuous range per employee when weekends are included", async () => {
|
||||||
|
// 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one
|
||||||
|
// range 06-01..06-03 covering all 3 days.
|
||||||
|
const res = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-06-01",
|
||||||
|
date_to: "2096-06-03",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-wknd`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("data" in res).toBe(true);
|
||||||
|
if (!("data" in res)) return;
|
||||||
|
expect(res.data.created_entries).toBe(1);
|
||||||
|
expect(res.data.created_days).toBe(3);
|
||||||
|
expect(res.data.skipped_days).toBe(0);
|
||||||
|
expect(res.data.users).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits into per-work-week ranges and skips weekends when excluded", async () => {
|
||||||
|
// 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue.
|
||||||
|
// Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped.
|
||||||
|
const res = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-06-01",
|
||||||
|
date_to: "2096-06-05",
|
||||||
|
include_weekends: false,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-week`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("data" in res).toBe(true);
|
||||||
|
if (!("data" in res)) return;
|
||||||
|
expect(res.data.created_entries).toBe(2);
|
||||||
|
expect(res.data.created_days).toBe(3);
|
||||||
|
expect(res.data.skipped_days).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips days already at the cap and splits the range around them", async () => {
|
||||||
|
// Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on
|
||||||
|
// bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips
|
||||||
|
// 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await prisma.work_plan_entries.create({
|
||||||
|
data: {
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: new Date("2096-07-02"),
|
||||||
|
date_to: new Date("2096-07-02"),
|
||||||
|
category: "work",
|
||||||
|
note: `${N}cap${i}`,
|
||||||
|
created_by: adminUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const res = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-07-01",
|
||||||
|
date_to: "2096-07-03",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-cap`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("data" in res).toBe(true);
|
||||||
|
if (!("data" in res)) return;
|
||||||
|
expect(res.data.created_entries).toBe(2);
|
||||||
|
expect(res.data.created_days).toBe(2);
|
||||||
|
expect(res.data.skipped_days).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aggregates across multiple employees", async () => {
|
||||||
|
const res = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId, noPermUserId],
|
||||||
|
date_from: "2096-08-03", // Friday
|
||||||
|
date_to: "2096-08-03",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-multi`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("data" in res).toBe(true);
|
||||||
|
if (!("data" in res)) return;
|
||||||
|
expect(res.data.users).toBe(2);
|
||||||
|
expect(res.data.created_entries).toBe(2);
|
||||||
|
expect(res.data.created_days).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty user_ids, past dates, bad range, and inactive category", async () => {
|
||||||
|
const empty = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [],
|
||||||
|
date_from: "2096-06-01",
|
||||||
|
date_to: "2096-06-01",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in empty && empty.status).toBe(400);
|
||||||
|
|
||||||
|
const past = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2000-01-01",
|
||||||
|
date_to: "2000-01-02",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in past && past.status).toBe(403);
|
||||||
|
|
||||||
|
const badRange = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-06-10",
|
||||||
|
date_to: "2096-06-01",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in badRange && badRange.status).toBe(400);
|
||||||
|
|
||||||
|
const badCat = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-06-01",
|
||||||
|
date_to: "2096-06-01",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "__nope__",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in badCat && badCat.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/__tests__/plan.test.ts`
|
||||||
|
Expected: FAIL — `bulkCreateEntries` is not exported / not a function.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `bulkCreateEntries` in `plan.service.ts`**
|
||||||
|
|
||||||
|
Add at the end of `src/services/plan.service.ts` (after `listOverrides`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bulk entry creation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk-assign a project/category to many employees over a date range. For each
|
||||||
|
* employee, walk the range, keep the days that pass the weekday filter
|
||||||
|
* (weekends excluded unless include_weekends) AND are under the per-cell cap,
|
||||||
|
* group contiguous kept days into runs, and create one range entry per run by
|
||||||
|
* reusing createEntry (so cap / past-date / category validation stays DRY).
|
||||||
|
*
|
||||||
|
* Days that pass the weekday filter but are already at the cap are counted in
|
||||||
|
* skipped_days (and split the range). Weekend days, when excluded, are out of
|
||||||
|
* scope and are NOT counted as skipped.
|
||||||
|
*/
|
||||||
|
export async function bulkCreateEntries(
|
||||||
|
input: {
|
||||||
|
user_ids: number[];
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
include_weekends: boolean;
|
||||||
|
project_id?: number | null;
|
||||||
|
category: string;
|
||||||
|
note: string;
|
||||||
|
},
|
||||||
|
actorUserId: number,
|
||||||
|
): Promise<
|
||||||
|
Result<{
|
||||||
|
created_entries: number;
|
||||||
|
created_days: number;
|
||||||
|
skipped_days: number;
|
||||||
|
users: number;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
if (input.user_ids.length === 0) {
|
||||||
|
return { error: "Vyberte alespoň jednoho zaměstnance", status: 400 };
|
||||||
|
}
|
||||||
|
if (input.date_to < input.date_from) {
|
||||||
|
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
|
||||||
|
}
|
||||||
|
const lockFrom = assertNotPastDate(input.date_from, false);
|
||||||
|
if (lockFrom) return lockFrom;
|
||||||
|
const lockTo = assertNotPastDate(input.date_to, false);
|
||||||
|
if (lockTo) return lockTo;
|
||||||
|
const catErr = await assertActiveCategory(input.category);
|
||||||
|
if (catErr) return catErr;
|
||||||
|
|
||||||
|
const from = toDateOnly(input.date_from);
|
||||||
|
const to = toDateOnly(input.date_to);
|
||||||
|
|
||||||
|
// The day list for the whole range (UTC midnights), built once.
|
||||||
|
const days: Date[] = [];
|
||||||
|
for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||||
|
days.push(new Date(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
let createdEntries = 0;
|
||||||
|
let createdDays = 0;
|
||||||
|
let skippedDays = 0;
|
||||||
|
|
||||||
|
for (const userId of input.user_ids) {
|
||||||
|
// Per-day cap counts from this user's existing active entries.
|
||||||
|
const existing = await prisma.work_plan_entries.findMany({
|
||||||
|
where: {
|
||||||
|
user_id: userId,
|
||||||
|
is_deleted: false,
|
||||||
|
date_from: { lte: to },
|
||||||
|
date_to: { gte: from },
|
||||||
|
},
|
||||||
|
select: { date_from: true, date_to: true },
|
||||||
|
});
|
||||||
|
const countFor = (day: Date): number =>
|
||||||
|
existing.filter((e) => e.date_from <= day && e.date_to >= day).length;
|
||||||
|
|
||||||
|
// Eligibility per day (weekday filter + under-cap). Tally cap skips.
|
||||||
|
const eligible: boolean[] = [];
|
||||||
|
for (const day of days) {
|
||||||
|
const dow = day.getUTCDay(); // 0 = Sun, 6 = Sat
|
||||||
|
const isWeekend = dow === 0 || dow === 6;
|
||||||
|
if (isWeekend && !input.include_weekends) {
|
||||||
|
eligible.push(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (countFor(day) >= MAX_RECORDS_PER_CELL) {
|
||||||
|
skippedDays++;
|
||||||
|
eligible.push(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
eligible.push(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group contiguous eligible days into runs; create one range per run.
|
||||||
|
let runStart = -1;
|
||||||
|
for (let i = 0; i <= days.length; i++) {
|
||||||
|
const ok = i < days.length && eligible[i];
|
||||||
|
if (ok && runStart === -1) {
|
||||||
|
runStart = i;
|
||||||
|
} else if (!ok && runStart !== -1) {
|
||||||
|
const segFrom = days[runStart].toISOString().slice(0, 10);
|
||||||
|
const segTo = days[i - 1].toISOString().slice(0, 10);
|
||||||
|
const runDays = i - runStart;
|
||||||
|
const res = await createEntry(
|
||||||
|
{
|
||||||
|
user_id: userId,
|
||||||
|
date_from: segFrom,
|
||||||
|
date_to: segTo,
|
||||||
|
project_id: input.project_id ?? null,
|
||||||
|
category: input.category,
|
||||||
|
note: input.note,
|
||||||
|
},
|
||||||
|
actorUserId,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if ("data" in res) {
|
||||||
|
createdEntries++;
|
||||||
|
createdDays += runDays;
|
||||||
|
} else {
|
||||||
|
// Rare race (e.g. the cap filled concurrently). Treat as skipped.
|
||||||
|
skippedDays += runDays;
|
||||||
|
}
|
||||||
|
runStart = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
created_entries: createdEntries,
|
||||||
|
created_days: createdDays,
|
||||||
|
skipped_days: skippedDays,
|
||||||
|
users: input.user_ids.length,
|
||||||
|
},
|
||||||
|
oldData: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the service tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/__tests__/plan.test.ts`
|
||||||
|
Expected: PASS (including the 5 new bulk tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add `BulkPlanEntrySchema` to `plan.schema.ts`**
|
||||||
|
|
||||||
|
Append to `src/schemas/plan.schema.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const BulkPlanEntrySchema = z
|
||||||
|
.object({
|
||||||
|
user_ids: z
|
||||||
|
.array(intFromForm)
|
||||||
|
.min(1, "Vyberte alespoň jednoho zaměstnance"),
|
||||||
|
date_from: isoDate,
|
||||||
|
date_to: isoDate,
|
||||||
|
include_weekends: z
|
||||||
|
.union([z.boolean(), z.string()])
|
||||||
|
.transform((v) => v === true || v === "true" || v === "1")
|
||||||
|
.default(false),
|
||||||
|
project_id: z
|
||||||
|
.union([z.number(), z.string(), z.null()])
|
||||||
|
.transform((v) => (v === null ? null : Number(v)))
|
||||||
|
.nullish(),
|
||||||
|
category: planCategoryEnum.default("work"),
|
||||||
|
note: z.string().max(500, "Maximálně 500 znaků").default(""),
|
||||||
|
})
|
||||||
|
.refine((v) => v.date_to >= v.date_from, {
|
||||||
|
message: "Datum do musí být stejné nebo po datumu od",
|
||||||
|
path: ["date_to"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Add the `POST /entries/bulk` route**
|
||||||
|
|
||||||
|
In `src/routes/admin/plan.ts`: add `bulkCreateEntries` to the existing import from `../../services/plan.service` and `BulkPlanEntrySchema` to the import from `../../schemas/plan.schema`. Then add this route immediately after the `POST /entries` handler (after its closing `);`, before `PATCH /entries/:id`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// --- POST /plan/entries/bulk ---
|
||||||
|
app.post(
|
||||||
|
"/entries/bulk",
|
||||||
|
{ preHandler: requirePermission("attendance.manage") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const body = parseBody(BulkPlanEntrySchema, request.body);
|
||||||
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
|
const result = await bulkCreateEntries(
|
||||||
|
body.data!,
|
||||||
|
request.authData!.userId,
|
||||||
|
);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
await logAudit({
|
||||||
|
request,
|
||||||
|
authData: request.authData,
|
||||||
|
action: "create",
|
||||||
|
entityType: "work_plan_entry",
|
||||||
|
description: `Hromadné přiřazení: ${body.data!.category} — ${result.data.users} zaměstnanců, ${body.data!.date_from}–${body.data!.date_to} (${result.data.created_days} dní)`,
|
||||||
|
});
|
||||||
|
return success(reply, result.data, 201, "Hromadné přiřazení dokončeno");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Add an HTTP route test**
|
||||||
|
|
||||||
|
Add to the `describe("HTTP /api/admin/plan", ...)` block in `src/__tests__/plan.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("POST /entries/bulk requires attendance.manage", async () => {
|
||||||
|
const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, {
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2095-06-01",
|
||||||
|
date_to: "2095-06-01",
|
||||||
|
include_weekends: true,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-forbidden`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /entries/bulk creates entries for an admin and returns a summary", async () => {
|
||||||
|
const res = await authPost("/api/admin/plan/entries/bulk", adminToken, {
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2095-06-05", // Sunday; weekends on → 1 day
|
||||||
|
date_to: "2095-06-05",
|
||||||
|
include_weekends: true,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-http`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.created_days).toBe(1);
|
||||||
|
expect(body.data.users).toBe(1);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -b --noEmit` (exit 0), then `npx vitest run` (all pass).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/schemas/plan.schema.ts src/services/plan.service.ts src/routes/admin/plan.ts src/__tests__/plan.test.ts
|
||||||
|
git commit -m "feat(plan): bulk entry creation endpoint + service"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Frontend — bulk modal + wiring
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/components/BulkPlanModal.tsx`
|
||||||
|
- Modify: `src/admin/hooks/usePlanWork.ts`
|
||||||
|
- Modify: `src/admin/pages/PlanWork.tsx`
|
||||||
|
|
||||||
|
Frontend-only; gate on `tsc -b` + `build` (no component-test harness). Mirrors `BulkAttendanceModal.tsx` (presentational; parent owns state + toggles + submit).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `BulkPlanModal.tsx`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Field,
|
||||||
|
DateField,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
CheckboxField,
|
||||||
|
} from "../ui";
|
||||||
|
import type { PlanUser, PlanCategory } from "../lib/queries/plan";
|
||||||
|
import type { Project } from "../lib/queries/projects";
|
||||||
|
|
||||||
|
export interface BulkPlanForm {
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
is_range: boolean;
|
||||||
|
user_ids: string[];
|
||||||
|
include_weekends: boolean;
|
||||||
|
project_id: string;
|
||||||
|
category: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
form: BulkPlanForm;
|
||||||
|
setForm: (form: BulkPlanForm) => void;
|
||||||
|
users: PlanUser[];
|
||||||
|
projects: Project[];
|
||||||
|
categories: PlanCategory[];
|
||||||
|
onSubmit: () => void;
|
||||||
|
submitting: boolean;
|
||||||
|
toggleUser: (userId: number) => void;
|
||||||
|
toggleAllUsers: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BulkPlanModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
users,
|
||||||
|
projects,
|
||||||
|
categories,
|
||||||
|
onSubmit,
|
||||||
|
submitting,
|
||||||
|
toggleUser,
|
||||||
|
toggleAllUsers,
|
||||||
|
}: Props) {
|
||||||
|
const activeCategories = categories.filter((c) => c.is_active);
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Hromadné přiřazení"
|
||||||
|
maxWidth="lg"
|
||||||
|
loading={submitting}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
submitText="Vytvořit"
|
||||||
|
submitDisabled={form.user_ids.length === 0}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ mt: 0.25, mb: 2 }}
|
||||||
|
>
|
||||||
|
Vytvoří záznamy pro vybrané dny u zvolených zaměstnanců. Dny, které už
|
||||||
|
mají 3 záznamy, se přeskočí.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Field label="Datum od">
|
||||||
|
<DateField
|
||||||
|
value={form.date_from}
|
||||||
|
onChange={(val) => setForm({ ...form, date_from: val })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Rozsah dnů">
|
||||||
|
<CheckboxField
|
||||||
|
label="Více dní"
|
||||||
|
checked={form.is_range}
|
||||||
|
onChange={(checked) => setForm({ ...form, is_range: checked })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{form.is_range && (
|
||||||
|
<Field label="Datum do">
|
||||||
|
<DateField
|
||||||
|
value={form.date_to}
|
||||||
|
onChange={(val) => setForm({ ...form, date_to: val })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="Víkendy">
|
||||||
|
<CheckboxField
|
||||||
|
label="Zahrnout víkendy"
|
||||||
|
checked={form.include_weekends}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setForm({ ...form, include_weekends: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 0.5 }}>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
Zaměstnanci
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAllUsers}
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
p: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "primary.main",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{form.user_ids.length === users.length
|
||||||
|
? "Odznačit vše"
|
||||||
|
: "Vybrat vše"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 0.25,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: "auto",
|
||||||
|
p: 1.5,
|
||||||
|
bgcolor: "action.hover",
|
||||||
|
borderRadius: 2,
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{users.map((u) => (
|
||||||
|
<CheckboxField
|
||||||
|
key={u.id}
|
||||||
|
label={u.full_name}
|
||||||
|
checked={form.user_ids.includes(String(u.id))}
|
||||||
|
onChange={() => toggleUser(u.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Vybráno: {form.user_ids.length} z {users.length}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Field label="Projekt">
|
||||||
|
<Select
|
||||||
|
value={form.project_id}
|
||||||
|
onChange={(val) => setForm({ ...form, project_id: val })}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "— bez projektu —" },
|
||||||
|
...projects.map((p) => ({ value: String(p.id), label: p.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Kategorie">
|
||||||
|
<Select
|
||||||
|
value={form.category}
|
||||||
|
onChange={(val) => setForm({ ...form, category: val })}
|
||||||
|
options={activeCategories.map((c) => ({
|
||||||
|
value: c.key,
|
||||||
|
label: c.label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Text poznámky (volitelný)">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={form.note}
|
||||||
|
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||||
|
inputProps={{ maxLength: 500 }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a `bulkCreate` mutation to `usePlanWork.ts`**
|
||||||
|
|
||||||
|
After the `deleteOverride` mutation definition (before the `rollbackMutation` function), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const bulkCreate = useMutation({
|
||||||
|
mutationFn: (body: any) =>
|
||||||
|
apiCall("/api/admin/plan/entries/bulk", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
// No optimistic patch — bulk can touch many cells; just invalidate and
|
||||||
|
// let the grid refetch the authoritative state.
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `bulkCreate` to the hook's return object (next to `createEntry`, etc.).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire the bulk modal into `PlanWork.tsx`**
|
||||||
|
|
||||||
|
(a) Add imports near the other component imports:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import BulkPlanModal, { type BulkPlanForm } from "../components/BulkPlanModal";
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Pull `bulkCreate` out of the `usePlanWork(...)` destructure (alongside `createEntry`, etc.).
|
||||||
|
|
||||||
|
(c) Add bulk state + a default form (place near the other `useState` declarations, after `catModalOpen`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const [bulkOpen, setBulkOpen] = useState(false);
|
||||||
|
const [bulkSubmitting, setBulkSubmitting] = useState(false);
|
||||||
|
const [bulkForm, setBulkForm] = useState<BulkPlanForm>(() => {
|
||||||
|
const today = todayIsoLocal();
|
||||||
|
return {
|
||||||
|
date_from: today,
|
||||||
|
date_to: today,
|
||||||
|
is_range: false,
|
||||||
|
user_ids: [],
|
||||||
|
include_weekends: false,
|
||||||
|
project_id: "",
|
||||||
|
category: "work",
|
||||||
|
note: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const planUsers = grid?.users ?? [];
|
||||||
|
|
||||||
|
const toggleBulkUser = useCallback((userId: number) => {
|
||||||
|
setBulkForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
user_ids: prev.user_ids.includes(String(userId))
|
||||||
|
? prev.user_ids.filter((u) => u !== String(userId))
|
||||||
|
: [...prev.user_ids, String(userId)],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleAllBulkUsers = useCallback(() => {
|
||||||
|
const allIds = (grid?.users ?? []).map((u) => String(u.id));
|
||||||
|
setBulkForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
user_ids: prev.user_ids.length === allIds.length ? [] : allIds,
|
||||||
|
}));
|
||||||
|
}, [grid]);
|
||||||
|
|
||||||
|
const submitBulk = useCallback(async () => {
|
||||||
|
if (bulkForm.user_ids.length === 0) return;
|
||||||
|
setBulkSubmitting(true);
|
||||||
|
try {
|
||||||
|
const data: any = await bulkCreate.mutateAsync({
|
||||||
|
user_ids: bulkForm.user_ids.map(Number),
|
||||||
|
date_from: bulkForm.date_from,
|
||||||
|
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
|
||||||
|
include_weekends: bulkForm.include_weekends,
|
||||||
|
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
|
||||||
|
category: bulkForm.category,
|
||||||
|
note: bulkForm.note,
|
||||||
|
});
|
||||||
|
const skipped =
|
||||||
|
data?.skipped_days > 0
|
||||||
|
? ` ${data.skipped_days} dní přeskočeno (limit 3 na den).`
|
||||||
|
: "";
|
||||||
|
alert.success(
|
||||||
|
`Vytvořeno ${data?.created_days ?? 0} záznamů pro ${data?.users ?? 0} zaměstnanců.${skipped}`,
|
||||||
|
);
|
||||||
|
setBulkOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
|
||||||
|
} finally {
|
||||||
|
setBulkSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [bulkForm, bulkCreate, alert]);
|
||||||
|
```
|
||||||
|
|
||||||
|
(d) Add the toolbar button next to "Správa kategorií" (inside the same `canEdit` region of the toolbar `<Box>`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{
|
||||||
|
canEdit && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => setBulkOpen(true)}
|
||||||
|
>
|
||||||
|
Hromadné přiřazení
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(e) Render the modal alongside `<PlanCategoriesModal ... />`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<BulkPlanModal
|
||||||
|
isOpen={bulkOpen}
|
||||||
|
onClose={() => setBulkOpen(false)}
|
||||||
|
form={bulkForm}
|
||||||
|
setForm={setBulkForm}
|
||||||
|
users={planUsers}
|
||||||
|
projects={projects}
|
||||||
|
categories={categories}
|
||||||
|
onSubmit={submitBulk}
|
||||||
|
submitting={bulkSubmitting}
|
||||||
|
toggleUser={toggleBulkUser}
|
||||||
|
toggleAllUsers={toggleAllBulkUsers}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate + Chrome check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in Chrome on `/plan-work` (dev server already running — do not start it): click "Hromadné přiřazení", select 2 employees, a date range with "Více dní", leave weekends off, pick a project + category, submit; confirm the grid fills only Mon–Fri across the selected people, the summary alert shows counts, and re-running on a day already at 3 records skips it.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/components/BulkPlanModal.tsx src/admin/hooks/usePlanWork.ts src/admin/pages/PlanWork.tsx
|
||||||
|
git commit -m "feat(plan): bulk assignment modal on /plan-work"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Release v2.0.6
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Bump version** to `"version": "2.0.6"` in `package.json`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Full gate**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
npx vitest run
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: tsc exit 0, vitest all pass, build success.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Merge to master + commit + tag**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout master
|
||||||
|
git merge --ff-only feat/bulk-plan-creation
|
||||||
|
git add package.json
|
||||||
|
git commit -m "chore(release): v2.0.6 — bulk plan creation"
|
||||||
|
git tag -a v2.0.6 -m "v2.0.6 — bulk plan creation"
|
||||||
|
```
|
||||||
|
|
||||||
|
(If implementation happened directly on master, skip the checkout/merge.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Push to Gitea**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin master
|
||||||
|
git push origin v2.0.6
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build tarball + deploy to production (REQUIRES explicit user confirmation)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -czf app-ts-2.0.6.tar.gz dist dist-client prisma package.json package-lock.json scripts
|
||||||
|
scp app-ts-2.0.6.tar.gz boha_admin@192.168.50.100:/tmp/
|
||||||
|
ssh boha_admin@192.168.50.100 'set -e; cd /var/www/app-ts && rm -rf dist dist-client prisma scripts package.json package-lock.json && tar -xzf /tmp/app-ts-2.0.6.tar.gz && npm install --omit=dev && npx prisma migrate deploy && pm2 restart app-ts --update-env'
|
||||||
|
```
|
||||||
|
|
||||||
|
No migration ships in this release → `prisma migrate deploy` reports "No pending migrations".
|
||||||
|
|
||||||
|
- [ ] **Step 6: Health check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh boha_admin@192.168.50.100 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/api/admin/session'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `200` then `401`. Confirm pm2 shows `app-ts` v2.0.6 online.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- **Reuse, don't re-validate:** `bulkCreateEntries` calls `createEntry`, which already enforces the cap, past-date, category, and range checks. Don't duplicate those.
|
||||||
|
- **`skipped_days` semantics:** only weekday-eligible days blocked by the cap count as skipped. Weekend days (when excluded) are out of scope, not skipped.
|
||||||
|
- **No DB migration.** Don't run any prisma migrate command.
|
||||||
|
- **Do not start the dev server** — the user runs it. Use the running instance for the Chrome check.
|
||||||
|
- **Do not deploy to production without explicit confirmation** (Task 3, Steps 5–6).
|
||||||
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
792
docs/superpowers/plans/2026-06-08-odin-conversations.md
Normal file
792
docs/superpowers/plans/2026-06-08-odin-conversations.md
Normal file
@@ -0,0 +1,792 @@
|
|||||||
|
# Odin Multi-Conversation Chat — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Turn the single-thread Odin assistant into a multi-conversation chat (top-tabs, rename/delete, auto-title) on the `/odin` page.
|
||||||
|
|
||||||
|
**Architecture:** New `ai_conversations` table; `ai_chat_messages` gains `conversation_id` (FK, cascade). Conversation-scoped service + routes replace the single `/history` endpoints. A new `OdinChat` component (+ focused sub-components) replaces `DashAssistant`, keeping all proven chat/extract/save logic but scoped to the active conversation.
|
||||||
|
|
||||||
|
**Tech Stack:** Fastify 5, Prisma 6 (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (real DB).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-08-odin-conversations-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `prisma/schema.prisma` | + `ai_conversations`; `ai_chat_messages.conversation_id` + relation + index swap |
|
||||||
|
| `prisma/migrations/20260608140000_add_ai_conversations/migration.sql` | create table, add column, backfill, enforce FK |
|
||||||
|
| `src/services/ai.service.ts` | replace `getChatHistory/appendChatMessages/clearChatHistory` with conversation-scoped fns |
|
||||||
|
| `src/schemas/ai.schema.ts` | + `AiCreateConversationSchema`, `AiRenameConversationSchema`; keep `AiHistoryAppendSchema` (reused for append) |
|
||||||
|
| `src/routes/admin/ai.ts` | replace `/history` routes with `/conversations*` routes |
|
||||||
|
| `src/__tests__/ai.test.ts` | replace history tests with conversation tests |
|
||||||
|
| `src/admin/lib/queries/ai.ts` | + conversation query options/types; remove `aiHistoryOptions` |
|
||||||
|
| `src/admin/components/odin/OdinTabs.tsx` | conversation tabs + new + rename/delete menu |
|
||||||
|
| `src/admin/components/odin/OdinThread.tsx` | message list + avatar + empty/busy states |
|
||||||
|
| `src/admin/components/odin/InvoiceReviewCard.tsx` | editable extracted-invoice card |
|
||||||
|
| `src/admin/components/odin/OdinComposer.tsx` | staged chips + attach + input + send |
|
||||||
|
| `src/admin/components/odin/OdinChat.tsx` | orchestrator: queries, state, the ported logic |
|
||||||
|
| `src/admin/pages/Odin.tsx` | render `OdinChat` (was `DashAssistant`) |
|
||||||
|
| `src/admin/components/dashboard/DashAssistant.tsx` | **deleted** |
|
||||||
|
|
||||||
|
**Controller note (migration):** Tasks that touch the DB require the dev server stopped. After Task 1's files are written, the **controller** runs: ask user to stop dev server → `npx prisma generate` → `npx prisma migrate deploy` (applies to dev `app` DB) → resume. Backend tsc is red between Task 1 and Task 2 (data model changes before the service is updated) — that is expected; Task 2 restores green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Data model + migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `prisma/schema.prisma`
|
||||||
|
- Create: `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Edit `prisma/schema.prisma`** — replace the existing `ai_chat_messages` model and add `ai_conversations` above it:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model ai_conversations {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
title String @db.VarChar(255)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
|
||||||
|
messages ai_chat_messages[]
|
||||||
|
|
||||||
|
@@index([user_id, id], map: "idx_ai_conv_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ai_chat_messages {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
conversation_id Int
|
||||||
|
role String @db.VarChar(20)
|
||||||
|
content String @db.Text
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([conversation_id, id], map: "idx_ai_chat_conv")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the migration SQL** at `prisma/migrations/20260608140000_add_ai_conversations/migration.sql`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Multi-conversation Odin: conversations table + conversation_id on messages.
|
||||||
|
|
||||||
|
CREATE TABLE `ai_conversations` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INTEGER NOT NULL,
|
||||||
|
`title` VARCHAR(255) NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_ai_conv_user` (`user_id`, `id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `ai_chat_messages`
|
||||||
|
ADD COLUMN `conversation_id` INTEGER NULL AFTER `user_id`;
|
||||||
|
|
||||||
|
-- Backfill: one conversation per user who has messages ("keep as first conversation").
|
||||||
|
INSERT INTO `ai_conversations` (`user_id`, `title`, `created_at`, `updated_at`)
|
||||||
|
SELECT `user_id`, 'Konverzace', NOW(), NOW()
|
||||||
|
FROM `ai_chat_messages`
|
||||||
|
GROUP BY `user_id`;
|
||||||
|
|
||||||
|
UPDATE `ai_chat_messages` m
|
||||||
|
JOIN `ai_conversations` c ON c.`user_id` = m.`user_id`
|
||||||
|
SET m.`conversation_id` = c.`id`;
|
||||||
|
|
||||||
|
-- Enforce: drop old per-user index, NOT NULL, FK (cascade), per-conversation index.
|
||||||
|
ALTER TABLE `ai_chat_messages`
|
||||||
|
DROP INDEX `idx_ai_chat_user`,
|
||||||
|
MODIFY `conversation_id` INTEGER NOT NULL,
|
||||||
|
ADD CONSTRAINT `fk_ai_chat_conv` FOREIGN KEY (`conversation_id`)
|
||||||
|
REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE,
|
||||||
|
ADD INDEX `idx_ai_chat_conv` (`conversation_id`, `id`);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Controller applies the migration** (not the implementer):
|
||||||
|
- Ask user to stop the dev server (Prisma engine DLL is locked on Windows otherwise).
|
||||||
|
- `npx prisma generate` — expect "Generated Prisma Client".
|
||||||
|
- `npx prisma migrate deploy` — expect "Applying migration `20260608140000_add_ai_conversations`" → "successfully applied".
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add prisma/schema.prisma prisma/migrations/20260608140000_add_ai_conversations
|
||||||
|
git commit -m "feat(odin): ai_conversations table + conversation_id on messages"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend — conversation service, schemas, routes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/services/ai.service.ts`
|
||||||
|
- Modify: `src/schemas/ai.schema.ts`
|
||||||
|
- Modify: `src/routes/admin/ai.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the chat-history block in `ai.service.ts`.** Delete `getChatHistory`, `appendChatMessages`, `clearChatHistory` and the `HISTORY_LIMIT` const, and put this in their place (keep the `StoredChatMessage` interface):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ── Conversations (server-side, per user) ─────────────────────────────────
|
||||||
|
export interface StoredChatMessage {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
export interface ConversationSummary {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONVERSATION_TITLE = "Nová konverzace";
|
||||||
|
const MESSAGE_LIMIT = 200; // cap a single thread read
|
||||||
|
|
||||||
|
export async function listConversations(
|
||||||
|
userId: number,
|
||||||
|
): Promise<ConversationSummary[]> {
|
||||||
|
return prisma.ai_conversations.findMany({
|
||||||
|
where: { user_id: userId },
|
||||||
|
orderBy: { id: "asc" }, // stable tab order
|
||||||
|
select: { id: true, title: true, updated_at: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConversation(
|
||||||
|
userId: number,
|
||||||
|
title?: string,
|
||||||
|
): Promise<ConversationSummary> {
|
||||||
|
return prisma.ai_conversations.create({
|
||||||
|
data: {
|
||||||
|
user_id: userId,
|
||||||
|
title: title?.trim() || DEFAULT_CONVERSATION_TITLE,
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, updated_at: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ownership-checked fetch; null when not found / not owned. */
|
||||||
|
async function ownConversation(userId: number, convId: number) {
|
||||||
|
return prisma.ai_conversations.findFirst({
|
||||||
|
where: { id: convId, user_id: userId },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConversationMessages(
|
||||||
|
userId: number,
|
||||||
|
convId: number,
|
||||||
|
): Promise<{ data: StoredChatMessage[] } | { error: string; status: number }> {
|
||||||
|
if (!(await ownConversation(userId, convId)))
|
||||||
|
return { error: "Konverzace nenalezena", status: 404 };
|
||||||
|
const rows = await prisma.ai_chat_messages.findMany({
|
||||||
|
where: { conversation_id: convId },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
take: MESSAGE_LIMIT,
|
||||||
|
select: { role: true, content: true, created_at: true },
|
||||||
|
});
|
||||||
|
return { data: rows.reverse() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendConversationMessages(
|
||||||
|
userId: number,
|
||||||
|
convId: number,
|
||||||
|
messages: { role: string; content: string }[],
|
||||||
|
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
|
||||||
|
const conv = await ownConversation(userId, convId);
|
||||||
|
if (!conv) return { error: "Konverzace nenalezena", status: 404 };
|
||||||
|
if (messages.length > 0) {
|
||||||
|
await prisma.ai_chat_messages.createMany({
|
||||||
|
data: messages.map((m) => ({
|
||||||
|
user_id: userId,
|
||||||
|
conversation_id: convId,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Auto-title from the first user message (only while still the default), and
|
||||||
|
// always bump updated_at.
|
||||||
|
const firstUser = messages.find((m) => m.role === "user");
|
||||||
|
const data: { updated_at: Date; title?: string } = { updated_at: new Date() };
|
||||||
|
if (conv.title === DEFAULT_CONVERSATION_TITLE && firstUser) {
|
||||||
|
const t = firstUser.content.replace(/\s+/g, " ").trim().slice(0, 60);
|
||||||
|
if (t) data.title = t;
|
||||||
|
}
|
||||||
|
await prisma.ai_conversations.update({ where: { id: convId }, data });
|
||||||
|
return { data: { ok: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameConversation(
|
||||||
|
userId: number,
|
||||||
|
convId: number,
|
||||||
|
title: string,
|
||||||
|
): Promise<{ data: ConversationSummary } | { error: string; status: number }> {
|
||||||
|
if (!(await ownConversation(userId, convId)))
|
||||||
|
return { error: "Konverzace nenalezena", status: 404 };
|
||||||
|
const updated = await prisma.ai_conversations.update({
|
||||||
|
where: { id: convId },
|
||||||
|
data: { title: title.trim() || DEFAULT_CONVERSATION_TITLE },
|
||||||
|
select: { id: true, title: true, updated_at: true },
|
||||||
|
});
|
||||||
|
return { data: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteConversation(
|
||||||
|
userId: number,
|
||||||
|
convId: number,
|
||||||
|
): Promise<{ data: { ok: true } } | { error: string; status: number }> {
|
||||||
|
if (!(await ownConversation(userId, convId)))
|
||||||
|
return { error: "Konverzace nenalezena", status: 404 };
|
||||||
|
await prisma.ai_conversations.delete({ where: { id: convId } }); // cascade
|
||||||
|
return { data: { ok: true } };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Schemas** — in `src/schemas/ai.schema.ts`, keep `AiHistoryAppendSchema` (reused for append) and add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const AiCreateConversationSchema = z.object({
|
||||||
|
title: z.string().max(255).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AiRenameConversationSchema = z.object({
|
||||||
|
title: z.string().min(1, "Název je povinný").max(255),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Routes** — in `src/routes/admin/ai.ts`: update imports (drop `getChatHistory/appendChatMessages/clearChatHistory`, add the new fns; add `AiCreateConversationSchema, AiRenameConversationSchema`; import `parseId` from `"../../schemas/common"`). Delete the three `/history` route blocks and append these (all `requirePermission("ai.use")`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// GET /conversations — this user's conversations (stable order)
|
||||||
|
app.get(
|
||||||
|
"/conversations",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const conversations = await listConversations(request.authData!.userId);
|
||||||
|
return success(reply, { conversations });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /conversations — create
|
||||||
|
app.post(
|
||||||
|
"/conversations",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const body = parseBody(AiCreateConversationSchema, request.body);
|
||||||
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
|
const conversation = await createConversation(
|
||||||
|
request.authData!.userId,
|
||||||
|
body.data.title,
|
||||||
|
);
|
||||||
|
return success(reply, { conversation });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /conversations/:id/messages
|
||||||
|
app.get(
|
||||||
|
"/conversations/:id/messages",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const result = await getConversationMessages(request.authData!.userId, id);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
return success(reply, { messages: result.data });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /conversations/:id/messages — append turns
|
||||||
|
app.post(
|
||||||
|
"/conversations/:id/messages",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const body = parseBody(AiHistoryAppendSchema, request.body);
|
||||||
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
|
const result = await appendConversationMessages(
|
||||||
|
request.authData!.userId,
|
||||||
|
id,
|
||||||
|
body.data.messages,
|
||||||
|
);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
return success(reply, result.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// PATCH /conversations/:id — rename
|
||||||
|
app.patch(
|
||||||
|
"/conversations/:id",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const body = parseBody(AiRenameConversationSchema, request.body);
|
||||||
|
if ("error" in body) return error(reply, body.error, 400);
|
||||||
|
const result = await renameConversation(
|
||||||
|
request.authData!.userId,
|
||||||
|
id,
|
||||||
|
body.data.title,
|
||||||
|
);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
return success(reply, { conversation: result.data });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /conversations/:id
|
||||||
|
app.delete(
|
||||||
|
"/conversations/:id",
|
||||||
|
{ preHandler: requirePermission("ai.use") },
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const id = parseId((request.params as { id: string }).id, reply);
|
||||||
|
if (id === null) return;
|
||||||
|
const result = await deleteConversation(request.authData!.userId, id);
|
||||||
|
if ("error" in result) return error(reply, result.error, result.status);
|
||||||
|
return success(reply, result.data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/services/ai.service.ts src/schemas/ai.schema.ts src/routes/admin/ai.ts
|
||||||
|
git commit -m "feat(odin): conversation-scoped service + routes (replace /history)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Backend tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/__tests__/ai.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the imports + cleanup markers.** Swap the history-fn imports for the conversation fns, and update the top-of-file constants/cleanup:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
computeCostUsd,
|
||||||
|
recordUsage,
|
||||||
|
getMonthSpendUsd,
|
||||||
|
getBudgetUsd,
|
||||||
|
assertBudgetAvailable,
|
||||||
|
isConfigured,
|
||||||
|
listConversations,
|
||||||
|
createConversation,
|
||||||
|
getConversationMessages,
|
||||||
|
appendConversationMessages,
|
||||||
|
renameConversation,
|
||||||
|
deleteConversation,
|
||||||
|
} from "../services/ai.service";
|
||||||
|
|
||||||
|
const KIND = "test_ai";
|
||||||
|
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
|
||||||
|
const CONV_UID_B = 999999992;
|
||||||
|
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the top-level cleanup (`afterAll` and the conversation `beforeEach`) to delete by these. Deleting a conversation cascades its messages:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
|
||||||
|
await prisma.ai_conversations.deleteMany({
|
||||||
|
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
||||||
|
});
|
||||||
|
await prisma.ai_conversations.deleteMany({
|
||||||
|
where: { title: { contains: HIST_MARKER } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace the `describe("ai.service chat history", …)` block** with conversation service tests:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
describe("ai.service conversations", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.ai_conversations.deleteMany({
|
||||||
|
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
expect(conv.title).toBe("Nová konverzace");
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "Kolik je hodin v Praze?" },
|
||||||
|
{ role: "assistant", content: "Nevím, ale…" },
|
||||||
|
]);
|
||||||
|
const list = await listConversations(CONV_UID_A);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
|
||||||
|
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
|
||||||
|
expect("data" in msgs && msgs.data.map((m) => m.content)).toEqual([
|
||||||
|
"Kolik je hodin v Praze?",
|
||||||
|
"Nevím, ale…",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite a renamed title on later appends", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "tohle by nemělo přepsat název" },
|
||||||
|
]);
|
||||||
|
const list = await listConversations(CONV_UID_A);
|
||||||
|
expect(list[0].title).toBe("Moje téma");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates conversations per user (404 across owners)", async () => {
|
||||||
|
const a = await createConversation(CONV_UID_A);
|
||||||
|
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
|
||||||
|
const read = await getConversationMessages(CONV_UID_B, a.id);
|
||||||
|
expect("error" in read && read.status).toBe(404);
|
||||||
|
const ren = await renameConversation(CONV_UID_B, a.id, "hack");
|
||||||
|
expect("error" in ren && ren.status).toBe(404);
|
||||||
|
const del = await deleteConversation(CONV_UID_B, a.id);
|
||||||
|
expect("error" in del && del.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cascade-deletes messages with the conversation", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "x" },
|
||||||
|
]);
|
||||||
|
await deleteConversation(CONV_UID_A, conv.id);
|
||||||
|
const remaining = await prisma.ai_chat_messages.count({
|
||||||
|
where: { conversation_id: conv.id },
|
||||||
|
});
|
||||||
|
expect(remaining).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace the HTTP history tests** (the `/history` `it(...)` blocks) with conversation HTTP tests inside the existing `describe("HTTP /api/admin/ai", …)`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("GET /conversations requires ai.use", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/ai/conversations",
|
||||||
|
headers: { Authorization: `Bearer ${noPermToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
|
||||||
|
const created = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/ai/conversations",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { title: `${HIST_MARKER} t` },
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(200);
|
||||||
|
const id = created.json().data.conversation.id as number;
|
||||||
|
|
||||||
|
const app2 = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/admin/ai/conversations/${id}/messages`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { messages: [{ role: "user", content: "ahoj" }] },
|
||||||
|
});
|
||||||
|
expect(app2.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const msgs = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/ai/conversations/${id}/messages`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
msgs.json().data.messages.map((m: { content: string }) => m.content),
|
||||||
|
).toContain("ahoj");
|
||||||
|
|
||||||
|
const renamed = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/admin/ai/conversations/${id}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { title: `${HIST_MARKER} renamed` },
|
||||||
|
});
|
||||||
|
expect(renamed.json().data.conversation.title).toBe(`${HIST_MARKER} renamed`);
|
||||||
|
|
||||||
|
const del = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/admin/ai/conversations/${id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(del.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET messages of a non-existent conversation → 404", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/ai/conversations/999999000/messages",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate**
|
||||||
|
|
||||||
|
Run: `npx vitest run src/__tests__/ai.test.ts` — expect all passing. Then `npx vitest run` — expect the whole suite green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/__tests__/ai.test.ts
|
||||||
|
git commit -m "test(odin): conversation CRUD, isolation, auto-title, cascade"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Frontend queries + types
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/admin/lib/queries/ai.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add conversation options.** Keep everything (incl. `aiHistoryOptions` for now — it's removed in Task 6 alongside its only consumer `DashAssistant.tsx`, so every task stays green). Add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface Conversation {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiConversationsOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ai", "conversations"],
|
||||||
|
queryFn: () =>
|
||||||
|
jsonQuery<{ conversations: Conversation[] }>(
|
||||||
|
"/api/admin/ai/conversations",
|
||||||
|
),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiConversationMessagesOptions = (id: number) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ai", "conversation", id, "messages"],
|
||||||
|
queryFn: () =>
|
||||||
|
jsonQuery<{ messages: StoredChatMessage[] }>(
|
||||||
|
`/api/admin/ai/conversations/${id}/messages`,
|
||||||
|
),
|
||||||
|
// Re-read the DB on each mount/selection (no stale-cache resurrection).
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: 0,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (we kept `aiHistoryOptions`, so `DashAssistant.tsx` still compiles).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/lib/queries/ai.ts
|
||||||
|
git commit -m "feat(odin): conversation query options"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend presentational components
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/components/odin/OdinTabs.tsx`
|
||||||
|
- Create: `src/admin/components/odin/OdinThread.tsx`
|
||||||
|
- Create: `src/admin/components/odin/InvoiceReviewCard.tsx`
|
||||||
|
- Create: `src/admin/components/odin/OdinComposer.tsx`
|
||||||
|
|
||||||
|
These are **props-driven** (no data fetching). Port presentational details (bubble styling, the `Typography`-color fix, review-card fields, staged chips) from the current `src/admin/components/dashboard/DashAssistant.tsx`, which the implementer should read first.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Shared types** — define at the top of `OdinChat.tsx` (Task 6) and import where needed, OR duplicate the tiny `ChatTurn` shape. Use these exact shapes across files:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ChatTurn {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
export interface ReviewInvoice {
|
||||||
|
uid: string;
|
||||||
|
supplier_name: string;
|
||||||
|
invoice_number: string;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
vat_rate: string;
|
||||||
|
issue_date: string;
|
||||||
|
due_date: string;
|
||||||
|
description: string;
|
||||||
|
file_name: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
export type EditableField =
|
||||||
|
| "supplier_name"
|
||||||
|
| "invoice_number"
|
||||||
|
| "amount"
|
||||||
|
| "currency"
|
||||||
|
| "vat_rate"
|
||||||
|
| "issue_date"
|
||||||
|
| "due_date"
|
||||||
|
| "description";
|
||||||
|
export interface StagedFile {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Put these in a new `src/admin/components/odin/types.ts` and import from there in every odin file.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `OdinTabs.tsx`** — props and behavior:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface OdinTabsProps {
|
||||||
|
conversations: { id: number; title: string }[];
|
||||||
|
activeId: number | null;
|
||||||
|
busy: boolean;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
onNew: () => void;
|
||||||
|
onRename: (id: number, title: string) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Render a horizontally-scrollable row (`Box` with `overflowX: "auto"`, `display: flex`, `gap`). Each conversation is a clickable pill (`Button` size small; active = `contained`, else `outlined color="inherit"`). After the pills, a `+` `Button` calls `onNew`. The **active** pill shows a small `⋮` `IconButton` opening an `@mui/material` `Menu` with "Přejmenovat" and "Smazat". "Přejmenovat" opens the kit `Modal` containing a `TextField` (prefilled with current title) → on submit calls `onRename`. "Smazat" opens the kit `ConfirmDialog` → on confirm calls `onDelete`. All actions disabled while `busy`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `OdinThread.tsx`** — props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface OdinThreadProps {
|
||||||
|
turns: ChatTurn[];
|
||||||
|
busy: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
threadRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Render the scrolling message area (`flex: 1, minHeight: 0, overflowY: "auto"`). Assistant turns left with a small circular Odin avatar (`Box` 28px, `borderRadius: "50%"`, `bgcolor: "primary.main"`, white "O"); user turns right-aligned `primary.main` bubble. **Message colour MUST be on the `Typography`** (`color: user ? "common.white" : "text.primary"`) — GlobalStyles pins `p` to text.secondary. Empty state (no turns, not busy, not loading): centered Odin avatar + "Dobrý den, jsem Odin. Zeptejte se, nebo přiložte fakturu (PDF) k importu." When `loading`: "Načítám…". When `busy`: a `CircularProgress size={14}` + "Pracuji…" row.
|
||||||
|
|
||||||
|
- [ ] **Step 4: `InvoiceReviewCard.tsx`** — props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface InvoiceReviewCardProps {
|
||||||
|
inv: ReviewInvoice;
|
||||||
|
busy: boolean;
|
||||||
|
onChange: (uid: string, field: EditableField, value: string) => void;
|
||||||
|
onSave: (inv: ReviewInvoice) => void;
|
||||||
|
onDiscard: (uid: string) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Port the card body from `DashAssistant.tsx` verbatim (the `Chip "Faktura"` + filename header, the 2-col grid of `TextField`s including **"Částka s DPH"**, "Datum splatnosti", "Popis", and the Uložit / Zahodit buttons). Replace `patch(...)`→`onChange(...)`, `saveInvoice`→`onSave`, the discard filter→`onDiscard(inv.uid)`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: `OdinComposer.tsx`** — props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface OdinComposerProps {
|
||||||
|
input: string;
|
||||||
|
attachments: StagedFile[];
|
||||||
|
busy: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
|
fileRef: React.RefObject<HTMLInputElement>;
|
||||||
|
onInput: (v: string) => void;
|
||||||
|
onFiles: (files: FileList | null) => void;
|
||||||
|
onRemoveAttachment: (id: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Port the staged-attachment chips + the composer row (hidden file `input`, "Přiložit" button, `TextField` with Enter-to-submit, "Odeslat" button) from `DashAssistant.tsx`. Disable per `busy` / `!canSubmit`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -p tsconfig.app.json --noEmit` — expect exit 0 (these compile independently of the orchestrator). If a component is unused at this point, that is fine — Task 6 wires them.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/components/odin
|
||||||
|
git commit -m "feat(odin): tabs, thread, review-card, composer components"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: OdinChat orchestrator + page + delete DashAssistant
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/admin/components/odin/OdinChat.tsx`
|
||||||
|
- Modify: `src/admin/pages/Odin.tsx`
|
||||||
|
- Modify: `src/admin/lib/queries/ai.ts` (remove `aiHistoryOptions`)
|
||||||
|
- Delete: `src/admin/components/dashboard/DashAssistant.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `OdinChat.tsx`.** It owns all state + queries + the ported submit/extract/save logic, scoped to the active conversation. Port the helper fns (`summaryNote`, `nextUid`, `plural`, `MODEL_CONTEXT`, `MAX_STORE`) and the **submit / saveInvoice / onFiles / persist** logic from `DashAssistant.tsx` with these adaptations:
|
||||||
|
- **Queries:** `usage` (unchanged), `aiConversationsOptions()`, and `aiConversationMessagesOptions(activeId)` (enabled when `activeId != null`).
|
||||||
|
- **State:** `activeId: number | null`, plus `input`, `busy`, `turns`, `review`, `attachments`, `fileRef`, `threadRef`.
|
||||||
|
- **Ensure a conversation exists:** when the conversations query resolves: if empty, `POST /conversations` to create a default and `setActiveId(new.id)` + invalidate `["ai","conversations"]`; else if `activeId == null`, `setActiveId(list[0].id)`.
|
||||||
|
- **Seed per conversation:** a `seededId = useRef<number|null>(null)`. Effect keyed on `[activeId, messagesData]`: when `messagesData` for the active id arrives and `seededId.current !== activeId`, set `seededId.current = activeId`, `setTurns(messagesData.messages.map(m => ({role:m.role, content:m.content})))`, `setReview([])`.
|
||||||
|
- **Switch tab (`onSelect`):** `setActiveId(id); setTurns([]); setReview([]); seededId.current = null;` (force re-seed; the messages query for the new id loads and seeds).
|
||||||
|
- **`persist(msgs)`** posts to `/api/admin/ai/conversations/${activeId}/messages` (was `/history`); best-effort + `console.error` on failure.
|
||||||
|
- **`submit`:** identical to `DashAssistant.submit` EXCEPT it first ensures `activeId` (if null, await-create one and use it); marks `seededId.current = activeId`; on success persists via the conversation endpoint; on error rolls back + keeps input/attachments. Gate `canSubmit` on `!conversationsPending && activeId != null && !busy && (input.trim() || attachments.length)`.
|
||||||
|
- **`onNew`:** `POST /conversations` → select it (`setActiveId`, clear turns/review, `seededId.current=null`) + invalidate `["ai","conversations"]`.
|
||||||
|
- **`onRename(id, title)`:** `PATCH /conversations/:id` → invalidate `["ai","conversations"]`.
|
||||||
|
- **`onDelete(id)`:** `DELETE /conversations/:id` → invalidate `["ai","conversations"]`; if `id === activeId`, pick another conversation from the (refetched) list or null.
|
||||||
|
- **Layout:** a full-height shell — `Box sx={{ height: "calc(100dvh - 140px)", display: "flex", flexDirection: "column" }}` containing: header (`Odin` + budget `Utraceno: $x / $y`), `<OdinTabs … />`, `<OdinThread … />` (flex 1), the review list (`review.map(inv => <InvoiceReviewCard … />)` in a bounded `maxHeight: "35vh", overflowY: "auto"` box), and `<OdinComposer … />`.
|
||||||
|
- **Self-hide:** keep `if (usage && usage.configured === false) return null;`.
|
||||||
|
|
||||||
|
Reference the exact bodies of `submit`, `saveInvoice`, `onFiles`, `summaryNote`, `nextUid` in the current `DashAssistant.tsx` (lines ~52–333) — copy them, applying only the adaptations above.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `src/admin/pages/Odin.tsx`** — swap the import and render:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import OdinChat from "../components/odin/OdinChat";
|
||||||
|
// …
|
||||||
|
<Box sx={{ maxWidth: 1100, mx: "auto" }}>
|
||||||
|
<OdinChat />
|
||||||
|
</Box>;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Delete `src/admin/components/dashboard/DashAssistant.tsx`** and remove the now-orphaned `aiHistoryOptions` from `src/admin/lib/queries/ai.ts` (DashAssistant was its only consumer).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git rm src/admin/components/dashboard/DashAssistant.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Gate**
|
||||||
|
|
||||||
|
Run: `npx tsc -b --noEmit` — expect exit 0. Run: `npm run build` — expect success. Run: `npx vitest run` — expect the full suite green.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/admin/components/odin/OdinChat.tsx src/admin/pages/Odin.tsx src/admin/lib/queries/ai.ts
|
||||||
|
git commit -m "feat(odin): OdinChat orchestrator; render on /odin; remove DashAssistant"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification (controller)
|
||||||
|
|
||||||
|
- `npx tsc -b --noEmit` = 0, `npm run build` = success, `npx vitest run` = all green.
|
||||||
|
- Manual (user/controller): create a conversation, send a chat, attach a PDF → review → save, rename a tab, delete a tab, reload (history persists per conversation), switch tabs (each keeps its own thread).
|
||||||
|
- Adversarial review pass (workflow) before merge/release.
|
||||||
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.
|
||||||
190
docs/superpowers/specs/2026-06-08-ai-assistant-phase1-design.md
Normal file
190
docs/superpowers/specs/2026-06-08-ai-assistant-phase1-design.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# Design — AI assistant, Phase 1 (chat + invoice import + budget cap)
|
||||||
|
|
||||||
|
**Date:** 2026-06-08
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation plan
|
||||||
|
**Phase 1 of 2.** Phase 2 (the AI querying system data via read-tools) is a separate later cycle — see "Deferred: Phase 2" at the end.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add the app's first AI capability: an **admin-only chat assistant embedded on the
|
||||||
|
dashboard** where you can talk to Claude and, crucially, **attach received-invoice
|
||||||
|
PDFs that the AI reads and imports into Přijaté faktury** (extract → you confirm →
|
||||||
|
save). All AI usage is bounded by an **admin-editable monthly budget (default
|
||||||
|
$50)** that hard-stops calls when exhausted.
|
||||||
|
|
||||||
|
## Decisions (locked in brainstorming)
|
||||||
|
|
||||||
|
- **Scope (Phase 1):** chat + invoice import + budget cap. **No system-data
|
||||||
|
access** (the AI cannot query your invoices/projects/attendance — that's Phase 2).
|
||||||
|
- **Placement:** a `DashAssistant` widget on the **dashboard, at the top under the
|
||||||
|
welcome text** — not a separate page, not a floating bubble.
|
||||||
|
- **Access:** **admins only**, via a new `ai.use` permission.
|
||||||
|
- **Model:** **Claude Sonnet 4.6** (strong vision, low cost).
|
||||||
|
- **Save flow:** extract → **you confirm/edit → save** (never silent auto-save).
|
||||||
|
- **Budget:** **$50/month**, resets each calendar month, **admin-editable in
|
||||||
|
Settings**; AI calls are **blocked** once the month's spend reaches the budget.
|
||||||
|
- **Chat:** **non-streaming** (request → full reply) and **in-browser history
|
||||||
|
only** (not persisted server-side) for v1 — both are easy later upgrades.
|
||||||
|
|
||||||
|
## Non-goals / out of scope (Phase 1)
|
||||||
|
|
||||||
|
- No system-data query tools / function-calling (Phase 2).
|
||||||
|
- No server-side conversation persistence, no streaming.
|
||||||
|
- No new invoice storage path — reuses `POST /received-invoices` verbatim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Greenfield AI integration; the rest of the app is unaffected and works unchanged
|
||||||
|
if `ANTHROPIC_API_KEY` is absent (the assistant widget simply doesn't render and
|
||||||
|
the AI routes return a clear "not configured" error).
|
||||||
|
|
||||||
|
- **Dependency:** `@anthropic-ai/sdk`.
|
||||||
|
- **Env:** `ANTHROPIC_API_KEY` (required for the feature). Optional
|
||||||
|
`AI_MONTHLY_BUDGET_USD` default seed (the live value lives in the DB, below).
|
||||||
|
- **`src/services/ai.service.ts`** — wraps the SDK, owns the model choice + system
|
||||||
|
prompt + cost accounting. Two operations:
|
||||||
|
- `chat(messages)` → `client.messages.create({ model: "claude-sonnet-4-6", … })`,
|
||||||
|
non-streaming, returns `{ reply: string, usage }`.
|
||||||
|
- `extractInvoice(pdfBuffer, fileName)` → `client.messages.create` with the PDF as
|
||||||
|
a `document` content block + `output_config.format` JSON-schema for the
|
||||||
|
received-invoice fields, returns `{ fields, usage }`.
|
||||||
|
- Both call the spend tracker to record usage **and** are gated by the budget
|
||||||
|
guard (below) BEFORE the API call.
|
||||||
|
- Isolated so tests can stub the Anthropic call (no real API in CI).
|
||||||
|
|
||||||
|
## Spend cap (the $50/month budget)
|
||||||
|
|
||||||
|
- **New table `ai_usage`** (migration): `id`, `user_id?`, `kind` ("chat" |
|
||||||
|
"extract"), `model`, `input_tokens`, `output_tokens`, `cost_usd`
|
||||||
|
`@db.Decimal(10,6)`, `created_at @db.Timestamp(0)`, with an index on
|
||||||
|
`created_at`.
|
||||||
|
- **Cost map** (in `ai.service.ts`): model → `{ inputPerToken, outputPerToken }`.
|
||||||
|
Sonnet 4.6 = $3 / $15 per 1M → `0.000003` / `0.000015` per token. `cost_usd =
|
||||||
|
in*inP + out*outP`.
|
||||||
|
- **Budget value:** a new `ai_monthly_budget_usd Decimal @default(50)` column on
|
||||||
|
`company_settings` (the existing app-config singleton). Admin-editable in
|
||||||
|
Settings.
|
||||||
|
- **Budget guard `assertBudgetAvailable()`:** sum `cost_usd` for rows where
|
||||||
|
`created_at >= startOfMonth(now)`; if `>= ai_monthly_budget_usd`, return
|
||||||
|
`{ error: "Měsíční rozpočet AI byl vyčerpán", status: 402 }`. Called at the top
|
||||||
|
of every AI route BEFORE the Claude call.
|
||||||
|
- **Usage read:** `GET /api/admin/ai/usage` → `{ month_spend_usd, budget_usd,
|
||||||
|
remaining_usd }` for the chat's budget indicator and the Settings page.
|
||||||
|
|
||||||
|
## Backend routes — `src/routes/admin/ai.ts`
|
||||||
|
|
||||||
|
All guarded by `requirePermission("ai.use")`.
|
||||||
|
|
||||||
|
- `POST /api/admin/ai/chat` — body `{ messages: {role, content}[] }`. Budget guard →
|
||||||
|
`ai.service.chat` → log usage → `success(reply: string, remaining_usd)`.
|
||||||
|
- `POST /api/admin/ai/extract-invoices` — multipart PDF files (≤ `MAX_UPLOAD_SIZE`,
|
||||||
|
PDF/image only). Budget guard → `extractInvoice` per file → returns the extracted
|
||||||
|
fields per file (NOT saved) + usage. The files are **not** persisted here.
|
||||||
|
- `GET /api/admin/ai/usage` — current-month spend + budget + remaining.
|
||||||
|
|
||||||
|
If `ANTHROPIC_API_KEY` is unset, the POST routes return
|
||||||
|
`{ error: "AI není nakonfigurováno", status: 503 }`.
|
||||||
|
|
||||||
|
## Invoice import flow (extract → confirm → save)
|
||||||
|
|
||||||
|
1. Admin attaches one or more invoice PDFs in the dashboard chat (optionally with a
|
||||||
|
text note).
|
||||||
|
2. Frontend posts the files to `POST /ai/extract-invoices`; the AI returns the
|
||||||
|
extracted fields for each.
|
||||||
|
3. The chat renders an **editable review card per invoice** (dodavatel, číslo
|
||||||
|
faktury, částka, měna, sazba DPH, datum vystavení/splatnosti, poznámka),
|
||||||
|
pre-filled with the AI's values, plus a duplicate hint if `invoice_number` +
|
||||||
|
`supplier_name` already exists.
|
||||||
|
4. Admin glances/edits → **"Uložit"** (per card) or **"Uložit vše"**.
|
||||||
|
5. The frontend submits **the original File + the confirmed metadata** to the
|
||||||
|
**existing `POST /api/admin/received-invoices`** (multipart) — same NAS storage,
|
||||||
|
same server-side VAT recompute, same audit. The AI never writes invoices
|
||||||
|
directly. (The PDF is uploaded twice — once to extract, once to save — which is
|
||||||
|
fine for invoice-sized files and avoids any server-side temp-file handling.)
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
- **`src/admin/components/dashboard/DashAssistant.tsx`** — the chat widget:
|
||||||
|
message list, input box, attach-file button, and a small budget indicator
|
||||||
|
("Rozpočet: $X.XX / $50"). Non-streaming; conversation in component state only.
|
||||||
|
Renders the invoice review cards inline (reusing the received-invoice field
|
||||||
|
inputs / kit components).
|
||||||
|
- **`src/admin/pages/Dashboard.tsx`** — render `<DashAssistant />` at the top,
|
||||||
|
directly under the welcome text, **only when** the user is admin / has `ai.use`
|
||||||
|
(and only when AI is configured — a `GET /ai/usage` 503 hides it gracefully).
|
||||||
|
- **`src/admin/lib/queries/ai.ts`** — React Query options/mutations for chat,
|
||||||
|
extract, and usage. On a successful invoice save, invalidate `["received-invoices"]`.
|
||||||
|
- **Settings** — an admin field to set the monthly budget (writes
|
||||||
|
`company_settings.ai_monthly_budget_usd`).
|
||||||
|
|
||||||
|
## Permissions & migration
|
||||||
|
|
||||||
|
One Prisma migration:
|
||||||
|
|
||||||
|
- Create `ai_usage` table.
|
||||||
|
- Add `ai_monthly_budget_usd` to `company_settings` (default 50).
|
||||||
|
- Insert the `ai.use` permission and grant it to the **admin** role (explicit
|
||||||
|
`INSERT`s in `migration.sql`, per the project's migration policy).
|
||||||
|
|
||||||
|
(The admin role bypasses permission checks via `roleName === "admin"`, but the
|
||||||
|
explicit `ai.use` permission lets the access be widened later without code changes
|
||||||
|
and documents intent.)
|
||||||
|
|
||||||
|
## Security / privacy
|
||||||
|
|
||||||
|
- **Phase 1 has no tools**, so the AI cannot take actions on your data — its only
|
||||||
|
effect is the human-confirmed invoice save. A malicious/auto-generated invoice
|
||||||
|
PDF therefore can't trigger anything (the injection-to-action surface arrives in
|
||||||
|
Phase 2 and is designed there).
|
||||||
|
- Attached PDFs are sent to the Anthropic API to be read. The API key lives in
|
||||||
|
`.env` (server-side only), never exposed to the browser.
|
||||||
|
- Standard sanitization already applies on the received-invoice render/PDF paths;
|
||||||
|
AI-extracted text flows through the same validated `received_invoices` create.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Server-side, real test DB, **Anthropic call stubbed** (no real API in CI):
|
||||||
|
|
||||||
|
- **Spend tracker:** `cost_usd` computed correctly from tokens for Sonnet 4.6;
|
||||||
|
monthly sum only counts the current month.
|
||||||
|
- **Budget guard:** under budget → allowed; at/over budget → `402` with the Czech
|
||||||
|
message; the guard reads the DB budget value.
|
||||||
|
- **Permissions:** all `/ai/*` routes return `403` without `ai.use`.
|
||||||
|
- **Not-configured:** with no `ANTHROPIC_API_KEY`, POST routes return `503`.
|
||||||
|
- **extract field-mapping:** given a stubbed AI response, the route returns the
|
||||||
|
expected `received_invoices` field shape (and computes nothing that the existing
|
||||||
|
create doesn't already).
|
||||||
|
|
||||||
|
Frontend has no component-test harness → gated by `tsc -b` + `build` + a manual
|
||||||
|
check (the dashboard widget renders for admins, hidden otherwise).
|
||||||
|
|
||||||
|
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Ships as **v2.1.0** (notable new capability) via the standard process. **Requires a
|
||||||
|
migration**, so the dev server must be stopped for `prisma migrate dev` (ask first),
|
||||||
|
and the release runs `prisma migrate deploy` on prod. `ANTHROPIC_API_KEY` must be
|
||||||
|
set in the production `.env` before the feature works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred: Phase 2 (system-data access) — captured
|
||||||
|
|
||||||
|
A later, separate spec + cycle. The AI gains **read-only tools** (function calling)
|
||||||
|
to answer questions about your data — e.g. "co je po splatnosti?", "kolik jsme
|
||||||
|
fakturovali v květnu?". Key design work for Phase 2:
|
||||||
|
|
||||||
|
- A curated set of **read tools** (search invoices, overdue list, project lookup,
|
||||||
|
attendance summary, …), each **scoped to the calling user's permissions** so the
|
||||||
|
AI never returns data the user can't see.
|
||||||
|
- The **agentic tool-loop** (Claude requests a tool → app executes against Prisma →
|
||||||
|
returns results → Claude continues), with a per-conversation tool-call cap to
|
||||||
|
bound cost (within the same monthly budget).
|
||||||
|
- **Prompt-injection hardening:** because the AI will both read untrusted invoice
|
||||||
|
content _and_ hold tools, tools stay **read-only**, results are treated as data,
|
||||||
|
and any state-changing action remains human-confirmed.
|
||||||
188
docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md
Normal file
188
docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# Design — Bulk plan creation (Feature B)
|
||||||
|
|
||||||
|
**Date:** 2026-06-08
|
||||||
|
**Status:** Approved (brainstorming) — ready for implementation plan
|
||||||
|
**Builds on:** Feature A (multi-record plan cells, max 3 per cell), shipped in v2.0.5. Spec: `docs/superpowers/specs/2026-06-08-multi-record-plan-cells-design.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let a planner assign one project/category to **many employees over a date range
|
||||||
|
in a single action**, instead of creating each person's plan record one by one.
|
||||||
|
A "Hromadné přiřazení" button on **/plan-work** opens a modal that mirrors the
|
||||||
|
existing single-record create form, plus an employee checklist and an "include
|
||||||
|
weekends" toggle.
|
||||||
|
|
||||||
|
## Decisions (locked in brainstorming)
|
||||||
|
|
||||||
|
- **Modal mirrors single-create** (`Datum od` + "Více dní" → `Datum do`, Projekt,
|
||||||
|
Kategorie, Text poznámky) **plus** a multi-select employee checklist and a
|
||||||
|
**"Zahrnout víkendy"** checkbox.
|
||||||
|
- **Storage = range entries** (`work_plan_entries`), one per contiguous run of
|
||||||
|
eligible days per employee — the same row shape the single-create form produces.
|
||||||
|
- **Weekends:** default off (Mon–Fri only). Only Sat/Sun are excluded — **no
|
||||||
|
separate holiday handling**.
|
||||||
|
- **Cap:** governed by Feature A's `MAX_RECORDS_PER_CELL = 3`. A day already at
|
||||||
|
the cap for an employee is **skipped** (never overwritten), which also splits
|
||||||
|
that employee's range around the skipped day.
|
||||||
|
- **Past dates blocked** (no `force` exposed in the bulk UI).
|
||||||
|
- **Category** picker defaults to **Práce**; **Project** is optional ("bez
|
||||||
|
projektu"); **note** optional, applied to all created entries.
|
||||||
|
|
||||||
|
## Non-goals / out of scope
|
||||||
|
|
||||||
|
- No DB migration (reuses `work_plan_entries` and Feature A's cap logic).
|
||||||
|
- No overwrite/replace of existing records (purely additive under the cap).
|
||||||
|
- No holiday/`svátky` exclusion (only weekends).
|
||||||
|
- No bulk creation of **overrides** — bulk always creates **entries** (the normal
|
||||||
|
layer); single-day exceptions remain the per-cell day-panel's job.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage & weekend behaviour
|
||||||
|
|
||||||
|
For each selected employee, the service computes the **eligible days** in
|
||||||
|
`[date_from, date_to]` and groups **contiguous** eligible days into range
|
||||||
|
entries.
|
||||||
|
|
||||||
|
A day `D` is **eligible** for employee `U` when all hold:
|
||||||
|
|
||||||
|
1. `D` is within `[date_from, date_to]` (inclusive).
|
||||||
|
2. `include_weekends` is true, **or** `D` is a weekday (Mon–Fri).
|
||||||
|
3. `U` has fewer than `MAX_RECORDS_PER_CELL` active entries covering `D` (under
|
||||||
|
the cap).
|
||||||
|
|
||||||
|
Contiguous runs of eligible days become one range entry each
|
||||||
|
(`date_from = run start`, `date_to = run end`). Consequences:
|
||||||
|
|
||||||
|
- **Víkendy ON, no cap conflicts:** one continuous range per employee — identical
|
||||||
|
to creating a single multi-day record for everyone.
|
||||||
|
- **Víkendy OFF:** one range per work-week (Mon–Fri chunks), because a weekend day
|
||||||
|
breaks the run.
|
||||||
|
- **A cap-full day** breaks the run too, so the range splits around it (and that
|
||||||
|
`(employee, day)` is counted as skipped).
|
||||||
|
|
||||||
|
In the grid these are ordinary range entries — they show the "součást rozsahu"
|
||||||
|
badge and open the range-vs-this-day chooser on click, exactly like
|
||||||
|
manually-created ranges.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Route — `POST /api/admin/plan/entries/bulk`
|
||||||
|
|
||||||
|
- Guard: `requirePermission("attendance.manage")` (same as single entry create).
|
||||||
|
- Body (validated by a new Zod schema `BulkPlanEntrySchema` in
|
||||||
|
`src/schemas/plan.schema.ts`):
|
||||||
|
```
|
||||||
|
{
|
||||||
|
user_ids: number[] // ≥1
|
||||||
|
date_from: string // YYYY-MM-DD
|
||||||
|
date_to: string // YYYY-MM-DD (≥ date_from)
|
||||||
|
include_weekends: boolean
|
||||||
|
project_id?: number | null
|
||||||
|
category: string // default applied client-side; validated server-side
|
||||||
|
note?: string // default ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- On success: one **summary audit** row (`logAudit`, action `create`, entityType
|
||||||
|
`work_plan_entry`, description e.g. `Hromadné přiřazení: <kategorie> <projekt> —
|
||||||
|
N zaměstnanců, <from>–<to> (<created_days> dní)`), then `success(reply,
|
||||||
|
summary, 201, "Hromadné přiřazení dokončeno")`.
|
||||||
|
|
||||||
|
### Service — `bulkCreateEntries(input, actorUserId)` in `src/services/plan.service.ts`
|
||||||
|
|
||||||
|
Up-front validation (fail fast, nothing created):
|
||||||
|
|
||||||
|
- `user_ids.length >= 1` (else `{ error, status: 400 }`).
|
||||||
|
- `date_to >= date_from` (else 400).
|
||||||
|
- `assertNotPastDate(date_from, false)` and `assertNotPastDate(date_to, false)`
|
||||||
|
(bulk blocks past dates; 403).
|
||||||
|
- `assertActiveCategory(category)` (400 if inactive/unknown).
|
||||||
|
|
||||||
|
Then, per employee:
|
||||||
|
|
||||||
|
- Load the employee's active entries overlapping `[date_from, date_to]` once;
|
||||||
|
compute per-day coverage counts.
|
||||||
|
- Walk the days, mark eligible ones (weekday filter + under-cap), group contiguous
|
||||||
|
runs.
|
||||||
|
- For each run, create the range by **reusing `createEntry`**
|
||||||
|
(`{ user_id, date_from: runStart, date_to: runEnd, project_id, category, note }`,
|
||||||
|
`actorUserId`, `force: false`). Reusing `createEntry` keeps cap / past-date /
|
||||||
|
category validation DRY; its cap re-check passes because every day in the run is
|
||||||
|
already under the cap. A `createEntry` that nonetheless returns `{ error }` (rare
|
||||||
|
race) is counted as skipped, not fatal.
|
||||||
|
|
||||||
|
Aggregate and return:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
created_entries: number // range rows created
|
||||||
|
created_days: number // (employee, day) records created
|
||||||
|
skipped_days: number // weekday-eligible (employee, day) slots skipped at the cap
|
||||||
|
users: number // employees processed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`skipped_days` counts only days that passed the weekday filter but were at the
|
||||||
|
cap — weekend days (when excluded) are out of scope, not "skipped".
|
||||||
|
|
||||||
|
Best-effort across employees/segments (no cross-entry transaction): up-front
|
||||||
|
validation already rejects bad input, and the cap-skip is by design. Partial
|
||||||
|
results are reported in the summary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Modal — `src/admin/components/BulkPlanModal.tsx`
|
||||||
|
|
||||||
|
Mirrors the single-create `EditForm` fields + bulk-attendance's employee picker
|
||||||
|
(`BulkAttendanceModal.tsx` is the pattern to follow):
|
||||||
|
|
||||||
|
- `Datum od` (DateField) + "Více dní" CheckboxField → `Datum do` (DateField).
|
||||||
|
- **Zaměstnanci** — scrollable `CheckboxField` list (source: the plan users
|
||||||
|
already loaded for the grid, `/plan/users`), with a "Vybrat vše" / "Odznačit
|
||||||
|
vše" toggle and a "Vybráno X z Y" caption; submit disabled when none selected.
|
||||||
|
- **Zahrnout víkendy** CheckboxField (default off).
|
||||||
|
- **Projekt** Select (optional — "— bez projektu —"), **Kategorie** Select
|
||||||
|
(active categories, default `work`/Práce), **Text poznámky** TextField (optional).
|
||||||
|
- A short helper line, e.g. _"Vytvoří záznamy pro vybrané dny u zvolených
|
||||||
|
zaměstnanců. Dny nad limit 3 záznamů se přeskočí."_
|
||||||
|
|
||||||
|
### Wiring — `src/admin/pages/PlanWork.tsx`
|
||||||
|
|
||||||
|
- A "Hromadné přiřazení" Button in the toolbar (next to "Správa kategorií"),
|
||||||
|
rendered only when `canEdit` (`attendance.manage`).
|
||||||
|
- A `bulkCreate` mutation (React Query) → `POST /api/admin/plan/entries/bulk`;
|
||||||
|
on success show an alert summarising the returned counts (e.g. _"Vytvořeno 18
|
||||||
|
záznamů pro 5 zaměstnanců; 4 dny přeskočeny (limit 3 na den)."_), close the
|
||||||
|
modal, and `invalidate: ["plan"]` so the grid refreshes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing (`src/__tests__/plan.test.ts` or a new `bulkPlan.test.ts`)
|
||||||
|
|
||||||
|
Backend service tests against the real test DB:
|
||||||
|
|
||||||
|
- **Weekday filter:** weekends-off over a Mon–Sun span creates only Mon–Fri days
|
||||||
|
(and as the expected number of per-work-week ranges); weekends-on creates one
|
||||||
|
continuous range.
|
||||||
|
- **Segmentation:** a cap-full day in the middle splits the range into two; the
|
||||||
|
skipped day is counted in `skipped_days`.
|
||||||
|
- **Cap skip:** an employee already at 3 entries on some days gets those days
|
||||||
|
skipped; the summary reflects `created_days` vs `skipped_days`.
|
||||||
|
- **Multi-employee aggregation:** `users`, `created_entries`, `created_days` sum
|
||||||
|
correctly across the selected employees.
|
||||||
|
- **Validation:** empty `user_ids` → 400; past `date_from` → 403; inactive
|
||||||
|
category → 400; `date_to < date_from` → 400.
|
||||||
|
|
||||||
|
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Ships as **v2.0.6** via the standard process (commit → tag → Gitea → tarball →
|
||||||
|
prod deploy → health check). No migration step. The grid refreshes via
|
||||||
|
`["plan"]` invalidation.
|
||||||
@@ -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).
|
||||||
218
docs/superpowers/specs/2026-06-08-odin-conversations-design.md
Normal file
218
docs/superpowers/specs/2026-06-08-odin-conversations-design.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Odin — Multi-Conversation AI Chat — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-08
|
||||||
|
**Status:** Approved (design), pending spec review
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the single-thread Odin assistant with a multi-conversation chat: a
|
||||||
|
purpose-built UI on the `/odin` page where the user keeps several named
|
||||||
|
conversations (like topics), switches between them via top tabs, and can
|
||||||
|
rename or delete them. All existing assistant capabilities (chat, PDF invoice
|
||||||
|
extraction → review → save, monthly budget) carry over unchanged.
|
||||||
|
|
||||||
|
## Background (current state)
|
||||||
|
|
||||||
|
- `ai_chat_messages` is a single rolling thread per user (`user_id, role,
|
||||||
|
content, created_at`). Endpoints `GET/POST/DELETE /api/admin/ai/history`
|
||||||
|
read/append/clear that one thread.
|
||||||
|
- The chat lives in `DashAssistant.tsx`, rendered on the `/odin` page (moved
|
||||||
|
off the dashboard in the prior change, branch `feat/odin-page`).
|
||||||
|
- Stateless AI endpoints (`/chat`, `/extract-invoices`, `/usage`, `/budget`)
|
||||||
|
are unaffected by conversations — they don't persist anything themselves.
|
||||||
|
|
||||||
|
## Decisions (from brainstorming)
|
||||||
|
|
||||||
|
- **Titling:** new conversation auto-titles from its first user message; user
|
||||||
|
can rename.
|
||||||
|
- **Per-conversation actions:** rename + delete.
|
||||||
|
- **Existing history:** migrate the current single thread into one conversation
|
||||||
|
per user ("keep as first conversation"); nothing is lost.
|
||||||
|
- **Layout:** top tabs + chat (not a left sidebar).
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
### `ai_conversations` (new)
|
||||||
|
|
||||||
|
| column | type | notes |
|
||||||
|
| ---------- | --------------------- | ----------------------------------- |
|
||||||
|
| id | INT PK AUTO_INCREMENT | |
|
||||||
|
| user_id | INT NOT NULL | owner; isolation key |
|
||||||
|
| title | VARCHAR(255) NOT NULL | default "Nová konverzace" |
|
||||||
|
| created_at | TIMESTAMP(0) NOT NULL | default CURRENT_TIMESTAMP |
|
||||||
|
| updated_at | TIMESTAMP(0) NOT NULL | bumped on each append; sort/recency |
|
||||||
|
|
||||||
|
Index: `idx_ai_conv_user (user_id, id)`.
|
||||||
|
|
||||||
|
### `ai_chat_messages` (modified)
|
||||||
|
|
||||||
|
- Add `conversation_id INT NOT NULL` (FK → `ai_conversations(id)` `ON DELETE
|
||||||
|
CASCADE`). Keep `user_id` (denormalized, harmless; simplifies cleanup).
|
||||||
|
- Replace index `idx_ai_chat_user (user_id, id)` with
|
||||||
|
`idx_ai_chat_conv (conversation_id, id)` (messages are read per conversation).
|
||||||
|
|
||||||
|
### Prisma
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model ai_conversations {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
title String @db.VarChar(255)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
|
||||||
|
messages ai_chat_messages[]
|
||||||
|
|
||||||
|
@@index([user_id, id], map: "idx_ai_conv_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ai_chat_messages {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
conversation_id Int
|
||||||
|
role String @db.VarChar(20)
|
||||||
|
content String @db.Text
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([conversation_id, id], map: "idx_ai_chat_conv")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration (hand-written SQL; one tracked migration)
|
||||||
|
|
||||||
|
1. `CREATE TABLE ai_conversations (...)`.
|
||||||
|
2. `ALTER TABLE ai_chat_messages ADD COLUMN conversation_id INT NULL AFTER user_id`.
|
||||||
|
3. Backfill one conversation per user who has messages:
|
||||||
|
`INSERT INTO ai_conversations (user_id, title, created_at, updated_at)
|
||||||
|
SELECT user_id, 'Konverzace', NOW(), NOW() FROM ai_chat_messages GROUP BY user_id;`
|
||||||
|
4. Point messages at it:
|
||||||
|
`UPDATE ai_chat_messages m JOIN ai_conversations c ON c.user_id = m.user_id
|
||||||
|
SET m.conversation_id = c.id;`
|
||||||
|
5. Drop the old index, enforce NOT NULL, add the FK + new index:
|
||||||
|
`ALTER TABLE ai_chat_messages DROP INDEX idx_ai_chat_user,
|
||||||
|
MODIFY conversation_id INT NOT NULL,
|
||||||
|
ADD CONSTRAINT fk_ai_chat_conv FOREIGN KEY (conversation_id)
|
||||||
|
REFERENCES ai_conversations(id) ON DELETE CASCADE,
|
||||||
|
ADD INDEX idx_ai_chat_conv (conversation_id, id);`
|
||||||
|
|
||||||
|
The backfill is a no-op when `ai_chat_messages` is empty (prod today), and
|
||||||
|
preserves the dev thread.
|
||||||
|
|
||||||
|
## Service layer (`ai.service.ts`)
|
||||||
|
|
||||||
|
Replace the single-thread helpers (`getChatHistory` / `appendChatMessages` /
|
||||||
|
`clearChatHistory`) with conversation-scoped ones. Every function takes
|
||||||
|
`userId` and verifies the conversation belongs to the user.
|
||||||
|
|
||||||
|
- `listConversations(userId)` → `{id, title, updated_at}[]`, ordered by `id` asc
|
||||||
|
(stable tab order).
|
||||||
|
- `createConversation(userId, title?)` → the new conversation (default title
|
||||||
|
"Nová konverzace").
|
||||||
|
- `getConversation(userId, id)` → conversation or null (ownership check helper).
|
||||||
|
- `getConversationMessages(userId, id)` → `StoredChatMessage[]` oldest→newest,
|
||||||
|
or `{ error, status }` if not owned/found.
|
||||||
|
- `appendConversationMessages(userId, id, msgs)` → append; **bump `updated_at`**;
|
||||||
|
**auto-title**: if the conversation's title is still the default and this batch
|
||||||
|
has a user message, set `title` = first ~60 chars of that user message.
|
||||||
|
- `renameConversation(userId, id, title)` / `deleteConversation(userId, id)` —
|
||||||
|
ownership-checked; delete cascades messages.
|
||||||
|
|
||||||
|
Auto-title keeps titling server-side and robust (no client title logic).
|
||||||
|
|
||||||
|
## Routes (`routes/admin/ai.ts`, all `requirePermission("ai.use")`)
|
||||||
|
|
||||||
|
| Method | Path | Body / result |
|
||||||
|
| ------ | ----------------------------- | ------------------------------------------ |
|
||||||
|
| GET | `/conversations` | → `{ conversations: [...] }` |
|
||||||
|
| POST | `/conversations` | `{ title? }` → `{ conversation }` |
|
||||||
|
| GET | `/conversations/:id/messages` | → `{ messages: [...] }` (404 if not owned) |
|
||||||
|
| POST | `/conversations/:id/messages` | `{ messages:[{role,content}] }` (1..20) |
|
||||||
|
| PATCH | `/conversations/:id` | `{ title }` (1..255) |
|
||||||
|
| DELETE | `/conversations/:id` | → `{ ok: true }` |
|
||||||
|
|
||||||
|
`:id` parsed via `parseId`; bodies via `parseBody`; ownership 404 from the
|
||||||
|
service surfaced with `error(reply, ...)`. The old `/history` routes are removed.
|
||||||
|
`/chat`, `/extract-invoices`, `/usage`, `/budget` are unchanged.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Queries (`lib/queries/ai.ts`)
|
||||||
|
|
||||||
|
- `aiConversationsOptions()` → `["ai","conversations"]`, `GET /conversations`.
|
||||||
|
- `aiConversationMessagesOptions(id)` → `["ai","conversation",id,"messages"]`,
|
||||||
|
`GET /conversations/:id/messages`, `gcTime: 0` (each mount re-reads the DB,
|
||||||
|
same rationale as today: no stale-cache resurrection).
|
||||||
|
- Types: `Conversation { id, title, updated_at }`, reuse `StoredChatMessage`.
|
||||||
|
|
||||||
|
### Components (`src/admin/components/odin/`)
|
||||||
|
|
||||||
|
- **`OdinChat.tsx`** — orchestrator. Owns: active conversation id, input, busy,
|
||||||
|
staged attachments, review list. Loads the conversation list; ensures one
|
||||||
|
exists (creates a default if the user has none); loads the active thread;
|
||||||
|
routes send/extract/save through the active conversation. Full-height flex
|
||||||
|
layout: header → tabs → thread (flex-grow, scroll) → review → composer.
|
||||||
|
- **`OdinTabs.tsx`** — horizontally-scrollable tabs (one per conversation) +
|
||||||
|
a "+" new-chat button + a ⋮ menu on the active tab (Přejmenovat / Smazat).
|
||||||
|
Rename uses a small Modal with a text field; delete uses ConfirmDialog.
|
||||||
|
- **`OdinThread.tsx`** — message list with an Odin avatar on assistant turns,
|
||||||
|
user turns right-aligned; the "Pracuji…" indicator; empty-state greeting.
|
||||||
|
(Message text colour set on the `Typography` — GlobalStyles pins `p` to
|
||||||
|
text.secondary.)
|
||||||
|
- **`InvoiceReviewCard.tsx`** — the editable extract card (supplier, number,
|
||||||
|
Částka s DPH, měna, sazba, dates, popis) + Uložit / Zahodit.
|
||||||
|
- **`OdinComposer.tsx`** — staged-attachment chips + attach button + message
|
||||||
|
field (Enter to send) + send button; disabled until the active thread loads.
|
||||||
|
|
||||||
|
`DashAssistant.tsx` is deleted; `pages/Odin.tsx` renders `OdinChat`.
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
1. Mount → load `aiConversationsOptions`. If empty, `POST /conversations` to
|
||||||
|
create a default and select it; else select the first (or last-active).
|
||||||
|
2. Selecting a tab sets the active id → `aiConversationMessagesOptions(id)`
|
||||||
|
loads that thread into the displayed turns (seed-once per id; submit marks
|
||||||
|
the thread live so a late load can't clobber — same guard as today).
|
||||||
|
3. **Send (chat):** optimistic user turn → `POST /chat` → append assistant
|
||||||
|
reply → `POST /conversations/:id/messages` persists both → bump usage. On
|
||||||
|
error: roll back, keep input.
|
||||||
|
4. **Send (attachments):** `POST /extract-invoices` → review cards →
|
||||||
|
client-side summary turn → persist the user+summary turns. Save a card →
|
||||||
|
`POST /received-invoices` (gross/VAT-inclusive, unchanged).
|
||||||
|
5. **New / rename / delete** → mutations that invalidate `["ai","conversations"]`
|
||||||
|
(and reset the active id appropriately on delete).
|
||||||
|
|
||||||
|
### Reuse
|
||||||
|
|
||||||
|
All proven logic carries over verbatim: staged-attach (no auto-send), the three
|
||||||
|
bug fixes (no clear-on-error, rollback dangling turn, stable ids — `nextUid`,
|
||||||
|
NOT `crypto.randomUUID`), the model-context trimming (last 20, leading-user),
|
||||||
|
budget indicator, gross/VAT save. The only structural change is "one thread" →
|
||||||
|
"active conversation thread", plus the tab/CRUD shell.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Ownership: service returns `{ error: "Konverzace nenalezena", status: 404 }`
|
||||||
|
for a conversation the user doesn't own; routes surface it. No cross-user read
|
||||||
|
or write is possible (every query filters by `user_id`).
|
||||||
|
- Persistence is best-effort and logged (never swallowed), as today.
|
||||||
|
- Auto-create-default and create/rename/delete failures surface a Czech toast.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Backend (Vitest, real DB):
|
||||||
|
|
||||||
|
- Conversation CRUD: create → list → rename → delete (cascade removes messages).
|
||||||
|
- **Isolation:** user A cannot read/append/rename/delete user B's conversation
|
||||||
|
(404), and `listConversations(A)` excludes B's.
|
||||||
|
- Append: ordering oldest→newest; `updated_at` bumps; **auto-title** sets the
|
||||||
|
title from the first user message and does not overwrite a renamed title.
|
||||||
|
- HTTP: routes 403 without `ai.use`; 404 for a foreign/:id; 400 on bad body.
|
||||||
|
|
||||||
|
Frontend: no component tests (consistent with the codebase); gated by
|
||||||
|
`tsc -b` + `npm run build`. Live verification by the user/controller.
|
||||||
|
|
||||||
|
## Out of scope (Phase 2+)
|
||||||
|
|
||||||
|
- Querying system data inside chat (the original deferred Phase-2 scope).
|
||||||
|
- Conversation search, pinning, folders, sharing.
|
||||||
|
- Streaming responses.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Odin → General Assistant (Phase 2+) — Forward-Looking Notes
|
||||||
|
|
||||||
|
**Date:** 2026-06-08 · **Status:** NOT scheduled — design notes only, recorded so Phase 1 choices don't box us in.
|
||||||
|
|
||||||
|
The vision: Odin grows from "chat + invoice import" into a **general assistant** that can **query system data** (invoices, projects, attendance, vehicles…) and **take actions** (create an order, mark paid, draft a quote…). This note assesses whether the Phase-1 architecture supports that, and what to carry forward deliberately.
|
||||||
|
|
||||||
|
## Bottom line
|
||||||
|
|
||||||
|
Phase 1 sets us up well; it does **not** box us in. Two things to carry forward on purpose:
|
||||||
|
|
||||||
|
1. **Structured message storage** (content blocks, not a plain string) — cheap to plan now, costly to retrofit later.
|
||||||
|
2. **The assistant acts strictly as the authenticated user** — every tool runs through the existing permission/ownership layer.
|
||||||
|
|
||||||
|
Everything else (conversations, budget/usage, thin services) is already pointing the right way.
|
||||||
|
|
||||||
|
## What Phase 1 already gets right
|
||||||
|
|
||||||
|
- **Conversations** — multi-turn, persisted, per-user, isolated. Exactly the context substrate an agent needs.
|
||||||
|
- **Budget + usage tracking + `ai.use` gate** — the cost/access spine. Tool loops spend more, so this matters _more_, not less.
|
||||||
|
- **Thin service layer** (`{ data } | { error, status }`, ownership-checked) — ideal to wrap as tools without rewriting business logic.
|
||||||
|
- **The extract → review → save flow** — a perfect template for "propose action → human confirms → execute." The `canSave`/`invoices.create` gate added in Phase 1 is the one-tool version of per-action authorization.
|
||||||
|
|
||||||
|
## The one schema thing to note now
|
||||||
|
|
||||||
|
`ai_chat_messages.content` is a **plain string**. Tool use requires persisting **content blocks** (`text` + `tool_use` + `tool_result`), because the model must see the full tool-call history to continue. When Phase 2 lands, store the **raw content-block array** (JSON column, e.g. `content_json`/`blocks`) and keep a derived plain-text for display/search. Don't change it now — just design Phase 2 persistence as "store the blocks," treating the current string as a lossy projection.
|
||||||
|
|
||||||
|
## The core shift (Phase 2)
|
||||||
|
|
||||||
|
1. **Tool use over the existing services.** Define tools (`list_invoices`, `get_project`, `create_order`, …) whose handlers call the **same service functions the routes use**. The SDK tool-runner loops; we execute. Reuses all validation/business logic and — critically — permissions.
|
||||||
|
2. **Authorization is the hard part.** Every tool MUST run **as the user**, through `requirePermission` / service ownership checks. The assistant must never do what the user can't. It is the user's _delegate_, not a privileged actor.
|
||||||
|
3. **Read vs write.** Read/query tools may run autonomously. Write/destructive tools **propose → confirm → execute** (generalize the invoice review card into a generic "action card"). Aligns with the assistant safety rules (confirm side-effecting actions).
|
||||||
|
4. **Audit.** Every action the assistant takes should `logAudit` like a manual action, attributed to the user with a marker (e.g. `via: "odin"`), for a real trail.
|
||||||
|
5. **Cost.** Tool loops are multi-round-trip → 3–10× the tokens of a chat turn. The $50 cap bites faster; consider per-conversation cost display and per-action budget re-checks (the inter-file budget re-check in `extract-invoices` is the pattern).
|
||||||
|
|
||||||
|
## Data access: tools-over-services, NOT RAG
|
||||||
|
|
||||||
|
Recommendation: **function-calling against the real services**, not embeddings/RAG. The data is structured, live, and permissioned — tools give correct, current, authorized answers. RAG over a snapshot would be stale, would leak across permission boundaries, and would duplicate logic. Reserve embeddings for genuinely unstructured document search if it ever comes up.
|
||||||
|
|
||||||
|
## Other forward notes
|
||||||
|
|
||||||
|
- **Model / effort:** a true agentic assistant wants a stronger model and possibly `effort` tuning; Phase-1 Sonnet single-shot is right for now.
|
||||||
|
- **Streaming:** multi-step tool use feels slow without it — add SSE when Phase 2 lands.
|
||||||
|
- **System prompt:** describe the user's role + available tools + the confirm-before-write rule.
|
||||||
|
- **Managed Agents vs self-hosted loop:** Anthropic's Managed Agents host the loop, but for a self-hosted business app with our own services + permissions, **Claude API + tool use with a self-hosted loop** is the better fit — authorization stays in our stack.
|
||||||
|
|
||||||
|
## What NOT to do prematurely
|
||||||
|
|
||||||
|
- Don't add a tool framework, embeddings, or streaming now — Phase 1 is plain chat + a fixed-prompt extractor, and that's the right scope.
|
||||||
|
- Don't widen `ai.use` to non-admins until the per-action authorization story (point 2) is in place.
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
|
|||||||
977
package-lock.json
generated
977
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "1.9.3",
|
"version": "2.1.5",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,15 +28,20 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@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 +79,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,29 @@
|
|||||||
|
-- AI assistant (Phase 1): usage-tracking table, monthly budget column, ai.use permission.
|
||||||
|
|
||||||
|
CREATE TABLE `ai_usage` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INTEGER NULL,
|
||||||
|
`kind` VARCHAR(20) NOT NULL,
|
||||||
|
`model` VARCHAR(50) NOT NULL,
|
||||||
|
`input_tokens` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`output_tokens` INTEGER NOT NULL DEFAULT 0,
|
||||||
|
`cost_usd` DECIMAL(10, 6) NOT NULL DEFAULT 0,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_ai_usage_created` (`created_at`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `company_settings`
|
||||||
|
ADD COLUMN `ai_monthly_budget_usd` DECIMAL(10, 2) NULL DEFAULT 50.00;
|
||||||
|
|
||||||
|
-- ai.use permission + grant to admin (INSERT IGNORE → idempotent), mirroring
|
||||||
|
-- the warehouse-permissions migration.
|
||||||
|
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||||
|
('ai.use', 'AI asistent', 'ai', 'Používat AI asistenta (chat a import faktur)', NOW());
|
||||||
|
|
||||||
|
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM `roles` r
|
||||||
|
CROSS JOIN `permissions` p
|
||||||
|
WHERE r.name = 'admin'
|
||||||
|
AND p.name = 'ai.use';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- AI assistant (Phase 1): per-user chat history (server-side, permanent).
|
||||||
|
|
||||||
|
CREATE TABLE `ai_chat_messages` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INTEGER NOT NULL,
|
||||||
|
`role` VARCHAR(20) NOT NULL,
|
||||||
|
`content` TEXT NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_ai_chat_user` (`user_id`, `id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Multi-conversation Odin: conversations table + conversation_id on messages.
|
||||||
|
|
||||||
|
CREATE TABLE `ai_conversations` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` INTEGER NOT NULL,
|
||||||
|
`title` VARCHAR(255) NOT NULL,
|
||||||
|
`created_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
`updated_at` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_ai_conv_user` (`user_id`, `id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
ALTER TABLE `ai_chat_messages`
|
||||||
|
ADD COLUMN `conversation_id` INTEGER NULL AFTER `user_id`;
|
||||||
|
|
||||||
|
-- Backfill: one conversation per user who has messages ("keep as first conversation").
|
||||||
|
INSERT INTO `ai_conversations` (`user_id`, `title`, `created_at`, `updated_at`)
|
||||||
|
SELECT `user_id`, 'Konverzace', NOW(), NOW()
|
||||||
|
FROM `ai_chat_messages`
|
||||||
|
GROUP BY `user_id`;
|
||||||
|
|
||||||
|
UPDATE `ai_chat_messages` m
|
||||||
|
JOIN `ai_conversations` c ON c.`user_id` = m.`user_id`
|
||||||
|
SET m.`conversation_id` = c.`id`;
|
||||||
|
|
||||||
|
-- Enforce: drop old per-user index, NOT NULL, FK (cascade), per-conversation index.
|
||||||
|
ALTER TABLE `ai_chat_messages`
|
||||||
|
DROP INDEX `idx_ai_chat_user`,
|
||||||
|
MODIFY `conversation_id` INTEGER NOT NULL,
|
||||||
|
ADD CONSTRAINT `fk_ai_chat_conv` FOREIGN KEY (`conversation_id`)
|
||||||
|
REFERENCES `ai_conversations`(`id`) ON DELETE CASCADE,
|
||||||
|
ADD INDEX `idx_ai_chat_conv` (`conversation_id`, `id`);
|
||||||
@@ -76,6 +76,42 @@ model audit_logs {
|
|||||||
@@fulltext([description], map: "idx_audit_search")
|
@@fulltext([description], map: "idx_audit_search")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ai_usage {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int?
|
||||||
|
kind String @db.VarChar(20)
|
||||||
|
model String @db.VarChar(50)
|
||||||
|
input_tokens Int @default(0)
|
||||||
|
output_tokens Int @default(0)
|
||||||
|
cost_usd Decimal @default(0) @db.Decimal(10, 6)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
|
||||||
|
@@index([created_at], map: "idx_ai_usage_created")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ai_conversations {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
title String @db.VarChar(255)
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
|
||||||
|
messages ai_chat_messages[]
|
||||||
|
|
||||||
|
@@index([user_id, id], map: "idx_ai_conv_user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ai_chat_messages {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
user_id Int
|
||||||
|
conversation_id Int
|
||||||
|
role String @db.VarChar(20)
|
||||||
|
content String @db.Text
|
||||||
|
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||||
|
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([conversation_id, id], map: "idx_ai_chat_conv")
|
||||||
|
}
|
||||||
|
|
||||||
model bank_accounts {
|
model bank_accounts {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
account_name String? @db.VarChar(255)
|
account_name String? @db.VarChar(255)
|
||||||
@@ -134,6 +170,7 @@ model company_settings {
|
|||||||
warehouse_issue_number_pattern String? @db.VarChar(100)
|
warehouse_issue_number_pattern String? @db.VarChar(100)
|
||||||
warehouse_inventory_prefix String? @db.VarChar(20)
|
warehouse_inventory_prefix String? @db.VarChar(20)
|
||||||
warehouse_inventory_number_pattern String? @db.VarChar(100)
|
warehouse_inventory_number_pattern String? @db.VarChar(100)
|
||||||
|
ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
model customers {
|
model customers {
|
||||||
|
|||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
448
src/__tests__/ai.test.ts
Normal file
448
src/__tests__/ai.test.ts
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest";
|
||||||
|
import Fastify from "fastify";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import rateLimit from "@fastify/rate-limit";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import prisma from "../config/database";
|
||||||
|
import { config as appConfig } from "../config/env";
|
||||||
|
import { securityHeaders } from "../middleware/security";
|
||||||
|
import aiRoutes from "../routes/admin/ai";
|
||||||
|
import {
|
||||||
|
computeCostUsd,
|
||||||
|
recordUsage,
|
||||||
|
getMonthSpendUsd,
|
||||||
|
getBudgetUsd,
|
||||||
|
assertBudgetAvailable,
|
||||||
|
isConfigured,
|
||||||
|
listConversations,
|
||||||
|
createConversation,
|
||||||
|
getConversationMessages,
|
||||||
|
appendConversationMessages,
|
||||||
|
renameConversation,
|
||||||
|
deleteConversation,
|
||||||
|
} from "../services/ai.service";
|
||||||
|
|
||||||
|
const KIND = "test_ai"; // marker so we only clean our own rows
|
||||||
|
const CONV_UID_A = 999999991; // synthetic, FK-free owner ids
|
||||||
|
const CONV_UID_B = 999999992;
|
||||||
|
const HIST_MARKER = "「test-conv」"; // marks HTTP-test conversations on real users
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.ai_usage.deleteMany({ where: { kind: KIND } });
|
||||||
|
await prisma.ai_conversations.deleteMany({
|
||||||
|
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
||||||
|
});
|
||||||
|
await prisma.ai_conversations.deleteMany({
|
||||||
|
where: { title: { contains: HIST_MARKER } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ai.service cost + budget", () => {
|
||||||
|
it("computes Sonnet 4.6 cost from tokens", () => {
|
||||||
|
// 1,000,000 input @ $3 + 1,000,000 output @ $15 = $18
|
||||||
|
expect(
|
||||||
|
computeCostUsd("claude-sonnet-4-6", 1_000_000, 1_000_000),
|
||||||
|
).toBeCloseTo(18, 6);
|
||||||
|
expect(computeCostUsd("claude-sonnet-4-6", 4000, 500)).toBeCloseTo(
|
||||||
|
0.0195,
|
||||||
|
6,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records usage with the computed cost", async () => {
|
||||||
|
await recordUsage({
|
||||||
|
userId: null,
|
||||||
|
kind: KIND,
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
inputTokens: 4000,
|
||||||
|
outputTokens: 500,
|
||||||
|
});
|
||||||
|
const rows = await prisma.ai_usage.findMany({ where: { kind: KIND } });
|
||||||
|
expect(rows.length).toBe(1);
|
||||||
|
expect(Number(rows[0].cost_usd)).toBeCloseTo(0.0195, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums only the current month's spend", async () => {
|
||||||
|
await recordUsage({
|
||||||
|
userId: null,
|
||||||
|
kind: KIND,
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
inputTokens: 1_000_000,
|
||||||
|
outputTokens: 0,
|
||||||
|
}); // $3 this month
|
||||||
|
// An old row (last year) must NOT count.
|
||||||
|
await prisma.ai_usage.create({
|
||||||
|
data: {
|
||||||
|
kind: KIND,
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
input_tokens: 1_000_000,
|
||||||
|
output_tokens: 0,
|
||||||
|
cost_usd: 3,
|
||||||
|
created_at: new Date("2000-01-01T00:00:00Z"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const spend = await getMonthSpendUsd();
|
||||||
|
expect(spend).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(spend).toBeLessThan(6); // the year-2000 $3 is excluded
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assertBudgetAvailable blocks at/over budget, allows under", async () => {
|
||||||
|
const budget = await getBudgetUsd();
|
||||||
|
// Under budget → null (allowed)
|
||||||
|
expect(await assertBudgetAvailable()).toBeNull();
|
||||||
|
// Push spend over budget for this month, then it must block with 402.
|
||||||
|
await prisma.ai_usage.create({
|
||||||
|
data: {
|
||||||
|
kind: KIND,
|
||||||
|
model: "claude-sonnet-4-6",
|
||||||
|
input_tokens: 0,
|
||||||
|
output_tokens: 0,
|
||||||
|
cost_usd: budget + 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const blocked = await assertBudgetAvailable();
|
||||||
|
expect(blocked).not.toBeNull();
|
||||||
|
expect(blocked?.status).toBe(402);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isConfigured reflects the API key presence", () => {
|
||||||
|
expect(typeof isConfigured()).toBe("boolean");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ai.service conversations", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.ai_conversations.deleteMany({
|
||||||
|
where: { user_id: { in: [CONV_UID_A, CONV_UID_B] } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Narrow a service result to its error branch with a clear failure message.
|
||||||
|
const expectError = (
|
||||||
|
r: { error: string; status: number } | { data: unknown },
|
||||||
|
status: number,
|
||||||
|
) => {
|
||||||
|
if (!("error" in r))
|
||||||
|
throw new Error(`expected an error result, got ${JSON.stringify(r)}`);
|
||||||
|
expect(r.status).toBe(status);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("creates, lists, appends (auto-title), and reads oldest→newest", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
expect(conv.title).toBe("Nová konverzace");
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "Kolik je hodin v Praze?" },
|
||||||
|
{ role: "assistant", content: "Nevím, ale…" },
|
||||||
|
]);
|
||||||
|
const list = await listConversations(CONV_UID_A);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].title).toBe("Kolik je hodin v Praze?"); // auto-titled
|
||||||
|
const msgs = await getConversationMessages(CONV_UID_A, conv.id);
|
||||||
|
if (!("data" in msgs)) throw new Error("expected messages, got an error");
|
||||||
|
expect(msgs.data.map((m) => m.content)).toEqual([
|
||||||
|
"Kolik je hodin v Praze?",
|
||||||
|
"Nevím, ale…",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not overwrite a renamed title on later appends", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
await renameConversation(CONV_UID_A, conv.id, "Moje téma");
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "tohle by nemělo přepsat název" },
|
||||||
|
]);
|
||||||
|
const list = await listConversations(CONV_UID_A);
|
||||||
|
expect(list[0].title).toBe("Moje téma");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bumps updated_at on append", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
// Force an old timestamp, then prove append refreshes it (TIMESTAMP(0) is
|
||||||
|
// second-precision, so compare against a year rather than ms deltas).
|
||||||
|
await prisma.ai_conversations.update({
|
||||||
|
where: { id: conv.id },
|
||||||
|
data: { updated_at: new Date("2000-01-01T00:00:00Z") },
|
||||||
|
});
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "ping" },
|
||||||
|
]);
|
||||||
|
const after = (await listConversations(CONV_UID_A))[0].updated_at;
|
||||||
|
expect(after.getFullYear()).toBeGreaterThan(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isolates conversations per user (404 on every op across owners)", async () => {
|
||||||
|
const a = await createConversation(CONV_UID_A);
|
||||||
|
expect(await listConversations(CONV_UID_B)).toHaveLength(0);
|
||||||
|
expectError(await getConversationMessages(CONV_UID_B, a.id), 404);
|
||||||
|
expectError(
|
||||||
|
await appendConversationMessages(CONV_UID_B, a.id, [
|
||||||
|
{ role: "user", content: "x" },
|
||||||
|
]),
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
expectError(await renameConversation(CONV_UID_B, a.id, "hack"), 404);
|
||||||
|
expectError(await deleteConversation(CONV_UID_B, a.id), 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cascade-deletes messages with the conversation", async () => {
|
||||||
|
const conv = await createConversation(CONV_UID_A);
|
||||||
|
await appendConversationMessages(CONV_UID_A, conv.id, [
|
||||||
|
{ role: "user", content: "x" },
|
||||||
|
]);
|
||||||
|
await deleteConversation(CONV_UID_A, conv.id);
|
||||||
|
const remaining = await prisma.ai_chat_messages.count({
|
||||||
|
where: { conversation_id: conv.id },
|
||||||
|
});
|
||||||
|
expect(remaining).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTTP /api/admin/ai", () => {
|
||||||
|
let app: Awaited<ReturnType<typeof buildAiApp>>;
|
||||||
|
let adminToken: string;
|
||||||
|
let noPermToken: string;
|
||||||
|
let noPermRoleId: number;
|
||||||
|
let noPermUserId: number;
|
||||||
|
let savedKey: string;
|
||||||
|
|
||||||
|
async function buildAiApp() {
|
||||||
|
const a = Fastify({ logger: false });
|
||||||
|
await a.register(cookie);
|
||||||
|
await a.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
|
||||||
|
a.addHook("onRequest", securityHeaders);
|
||||||
|
await a.register(aiRoutes, { prefix: "/api/admin/ai" });
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
function token(user: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
roleName: string | null;
|
||||||
|
}) {
|
||||||
|
return jwt.sign(
|
||||||
|
{ sub: user.id, username: user.username, role: user.roleName },
|
||||||
|
appConfig.jwt.secret,
|
||||||
|
{ expiresIn: "15m" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
savedKey = appConfig.anthropic.apiKey;
|
||||||
|
app = await buildAiApp();
|
||||||
|
const admin = await prisma.users.findFirst({
|
||||||
|
where: { roles: { name: "admin" } },
|
||||||
|
include: { roles: true },
|
||||||
|
});
|
||||||
|
if (!admin) throw new Error("admin not found");
|
||||||
|
adminToken = token({
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
roleName: admin.roles?.name ?? null,
|
||||||
|
});
|
||||||
|
const stamp = Date.now();
|
||||||
|
const role = await prisma.roles.create({
|
||||||
|
data: { name: `noperm_ai_${stamp}`, display_name: "No Perm AI" },
|
||||||
|
});
|
||||||
|
noPermRoleId = role.id;
|
||||||
|
const u = await prisma.users.create({
|
||||||
|
data: {
|
||||||
|
username: `noperm_ai_${stamp}`,
|
||||||
|
first_name: "No",
|
||||||
|
last_name: "Perm",
|
||||||
|
email: `noperm_ai_${stamp}@test.local`,
|
||||||
|
password_hash:
|
||||||
|
"$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
|
||||||
|
role_id: role.id,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
noPermUserId = u.id;
|
||||||
|
noPermToken = token({
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
roleName: role.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
|
||||||
|
if (noPermUserId)
|
||||||
|
await prisma.users
|
||||||
|
.deleteMany({ where: { id: noPermUserId } })
|
||||||
|
.catch(() => {});
|
||||||
|
if (noPermRoleId)
|
||||||
|
await prisma.roles
|
||||||
|
.deleteMany({ where: { id: noPermRoleId } })
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /usage requires ai.use", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/ai/usage",
|
||||||
|
headers: { Authorization: `Bearer ${noPermToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /usage returns spend + budget for an admin", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/ai/usage",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(typeof body.data.budget_usd).toBe("number");
|
||||||
|
expect(typeof body.data.month_spend_usd).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /chat returns 503 when AI is not configured", async () => {
|
||||||
|
(appConfig.anthropic as { apiKey: string }).apiKey = "";
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/ai/chat",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { messages: [{ role: "user", content: "ahoj" }] },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(503);
|
||||||
|
(appConfig.anthropic as { apiKey: string }).apiKey = savedKey;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /conversations requires ai.use", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/ai/conversations",
|
||||||
|
headers: { Authorization: `Bearer ${noPermToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conversation lifecycle over HTTP (create → append → list → rename → delete)", async () => {
|
||||||
|
const created = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/ai/conversations",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { title: `${HIST_MARKER} t` },
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(200);
|
||||||
|
const id = created.json().data.conversation.id as number;
|
||||||
|
|
||||||
|
const appended = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/admin/ai/conversations/${id}/messages`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { messages: [{ role: "user", content: "ahoj" }] },
|
||||||
|
});
|
||||||
|
expect(appended.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const msgs = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/admin/ai/conversations/${id}/messages`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
msgs.json().data.messages.map((m: { content: string }) => m.content),
|
||||||
|
).toContain("ahoj");
|
||||||
|
|
||||||
|
const renamed = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/admin/ai/conversations/${id}`,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { title: `${HIST_MARKER} renamed` },
|
||||||
|
});
|
||||||
|
expect(renamed.json().data.conversation.title).toBe(
|
||||||
|
`${HIST_MARKER} renamed`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const del = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/admin/ai/conversations/${id}`,
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(del.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET messages of a non-existent conversation → 404", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/ai/conversations/999999000/messages",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /conversations requires ai.use", async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/ai/conversations",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${noPermToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { title: "x" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /conversations lists the user's conversations", async () => {
|
||||||
|
const created = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/ai/conversations",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { title: `${HIST_MARKER} listed` },
|
||||||
|
});
|
||||||
|
const id = created.json().data.conversation.id as number;
|
||||||
|
const list = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/admin/ai/conversations",
|
||||||
|
headers: { Authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(list.statusCode).toBe(200);
|
||||||
|
expect(
|
||||||
|
list.json().data.conversations.map((c: { id: number }) => c.id),
|
||||||
|
).toContain(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty title (PATCH) and empty messages (POST) with 400", async () => {
|
||||||
|
const patch = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: "/api/admin/ai/conversations/999999000",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { title: "" },
|
||||||
|
});
|
||||||
|
expect(patch.statusCode).toBe(400);
|
||||||
|
|
||||||
|
const append = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/admin/ai/conversations/999999000/messages",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
payload: { messages: [] },
|
||||||
|
});
|
||||||
|
expect(append.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
deleteOverride,
|
deleteOverride,
|
||||||
listEntries,
|
listEntries,
|
||||||
listOverrides,
|
listOverrides,
|
||||||
|
bulkCreateEntries,
|
||||||
} from "../services/plan.service";
|
} from "../services/plan.service";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -58,6 +59,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 +90,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 +107,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 +135,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 +195,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 +216,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 +244,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 +462,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 +590,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 +605,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 () => {
|
||||||
@@ -635,6 +789,184 @@ describe("plan.service.listOverrides (employee scoping)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// bulkCreateEntries
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("plan.service.bulkCreateEntries", () => {
|
||||||
|
it("creates one continuous range per employee when weekends are included", async () => {
|
||||||
|
// 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one
|
||||||
|
// range 06-01..06-03 covering all 3 days.
|
||||||
|
const res = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-06-01",
|
||||||
|
date_to: "2096-06-03",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-wknd`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("data" in res).toBe(true);
|
||||||
|
if (!("data" in res)) return;
|
||||||
|
expect(res.data.created_entries).toBe(1);
|
||||||
|
expect(res.data.created_days).toBe(3);
|
||||||
|
expect(res.data.skipped_days).toBe(0);
|
||||||
|
expect(res.data.users).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("splits into per-work-week ranges and skips weekends when excluded", async () => {
|
||||||
|
// 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue.
|
||||||
|
// Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped.
|
||||||
|
const res = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-06-01",
|
||||||
|
date_to: "2096-06-05",
|
||||||
|
include_weekends: false,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-week`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("data" in res).toBe(true);
|
||||||
|
if (!("data" in res)) return;
|
||||||
|
expect(res.data.created_entries).toBe(2);
|
||||||
|
expect(res.data.created_days).toBe(3);
|
||||||
|
expect(res.data.skipped_days).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips days already at the cap and splits the range around them", async () => {
|
||||||
|
// Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on
|
||||||
|
// bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips
|
||||||
|
// 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await prisma.work_plan_entries.create({
|
||||||
|
data: {
|
||||||
|
user_id: adminUserId,
|
||||||
|
date_from: new Date("2096-07-02"),
|
||||||
|
date_to: new Date("2096-07-02"),
|
||||||
|
category: "work",
|
||||||
|
note: `${N}cap${i}`,
|
||||||
|
created_by: adminUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const res = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-07-01",
|
||||||
|
date_to: "2096-07-03",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-cap`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("data" in res).toBe(true);
|
||||||
|
if (!("data" in res)) return;
|
||||||
|
expect(res.data.created_entries).toBe(2);
|
||||||
|
expect(res.data.created_days).toBe(2);
|
||||||
|
expect(res.data.skipped_days).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aggregates across multiple employees", async () => {
|
||||||
|
const res = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId, noPermUserId],
|
||||||
|
date_from: "2096-08-03", // Friday
|
||||||
|
date_to: "2096-08-03",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-multi`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("data" in res).toBe(true);
|
||||||
|
if (!("data" in res)) return;
|
||||||
|
expect(res.data.users).toBe(2);
|
||||||
|
expect(res.data.created_entries).toBe(2);
|
||||||
|
expect(res.data.created_days).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty user_ids, past dates, bad range, and inactive category", async () => {
|
||||||
|
const empty = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [],
|
||||||
|
date_from: "2096-06-01",
|
||||||
|
date_to: "2096-06-01",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in empty && empty.status).toBe(400);
|
||||||
|
|
||||||
|
const tooLong = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-01-01",
|
||||||
|
date_to: "2096-12-31", // > 92 days
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in tooLong && tooLong.status).toBe(400);
|
||||||
|
|
||||||
|
const past = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2000-01-01",
|
||||||
|
date_to: "2000-01-02",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in past && past.status).toBe(403);
|
||||||
|
|
||||||
|
const badRange = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-06-10",
|
||||||
|
date_to: "2096-06-01",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in badRange && badRange.status).toBe(400);
|
||||||
|
|
||||||
|
const badCat = await bulkCreateEntries(
|
||||||
|
{
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2096-06-01",
|
||||||
|
date_to: "2096-06-01",
|
||||||
|
include_weekends: true,
|
||||||
|
project_id: null,
|
||||||
|
category: "__nope__",
|
||||||
|
note: `${N}x`,
|
||||||
|
},
|
||||||
|
adminUserId,
|
||||||
|
);
|
||||||
|
expect("error" in badCat && badCat.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// HTTP route tests (Fastify inject)
|
// HTTP route tests (Fastify inject)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -810,7 +1142,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`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -883,4 +1215,32 @@ describe("HTTP /api/admin/plan", () => {
|
|||||||
);
|
);
|
||||||
expect(res.statusCode).toBe(201);
|
expect(res.statusCode).toBe(201);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /entries/bulk requires attendance.manage", async () => {
|
||||||
|
const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, {
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2095-06-01",
|
||||||
|
date_to: "2095-06-01",
|
||||||
|
include_weekends: true,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-forbidden`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /entries/bulk creates entries for an admin and returns a summary", async () => {
|
||||||
|
const res = await authPost("/api/admin/plan/entries/bulk", adminToken, {
|
||||||
|
user_ids: [adminUserId],
|
||||||
|
date_from: "2095-06-05", // Sunday; weekends on → 1 day
|
||||||
|
date_to: "2095-06-05",
|
||||||
|
include_weekends: true,
|
||||||
|
category: "work",
|
||||||
|
note: `${N}bulk-http`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.created_days).toBe(1);
|
||||||
|
expect(body.data.users).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
26
src/__tests__/received-invoices-vat.test.ts
Normal file
26
src/__tests__/received-invoices-vat.test.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { vatFromGross } from "../routes/admin/received-invoices";
|
||||||
|
|
||||||
|
// `amount` on a received invoice is the GROSS total (VAT included). The VAT is
|
||||||
|
// the portion contained within it: gross * rate / (100 + rate).
|
||||||
|
describe("vatFromGross (VAT contained in a gross total)", () => {
|
||||||
|
it("derives VAT from a real gross total at 21%", () => {
|
||||||
|
// 22 542,91 @ 21% → 3 912,41 (base 18 630,50). User-confirmed figures.
|
||||||
|
expect(vatFromGross(22542.91, 21)).toBeCloseTo(3912.41, 2);
|
||||||
|
expect(22542.91 - vatFromGross(22542.91, 21)).toBeCloseTo(18630.5, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives VAT at other rates", () => {
|
||||||
|
expect(vatFromGross(1210, 21)).toBeCloseTo(210, 2); // base 1000
|
||||||
|
expect(vatFromGross(1100, 10)).toBeCloseTo(100, 2); // base 1000
|
||||||
|
expect(vatFromGross(1150, 15)).toBeCloseTo(150, 2); // base 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when there is no VAT", () => {
|
||||||
|
expect(vatFromGross(5000, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for a zero amount", () => {
|
||||||
|
expect(vatFromGross(0, 21)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,30 +5,14 @@ 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 Odin = lazy(() => import("./pages/Odin"));
|
||||||
const Users = lazy(() => import("./pages/Users"));
|
const Users = lazy(() => import("./pages/Users"));
|
||||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
const Attendance = lazy(() => import("./pages/Attendance"));
|
||||||
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory"));
|
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory"));
|
||||||
@@ -81,25 +65,25 @@ 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="odin" element={<Odin />} />
|
||||||
<Route path="users" element={<Users />} />
|
<Route path="users" element={<Users />} />
|
||||||
<Route path="attendance" element={<Attendance />} />
|
<Route path="attendance" element={<Attendance />} />
|
||||||
<Route
|
<Route
|
||||||
@@ -156,7 +140,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 +220,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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
191
src/admin/components/BulkPlanModal.tsx
Normal file
191
src/admin/components/BulkPlanModal.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Field,
|
||||||
|
DateField,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
CheckboxField,
|
||||||
|
} from "../ui";
|
||||||
|
import type { PlanUser, PlanCategory } from "../lib/queries/plan";
|
||||||
|
import type { Project } from "../lib/queries/projects";
|
||||||
|
|
||||||
|
export interface BulkPlanForm {
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
is_range: boolean;
|
||||||
|
user_ids: string[];
|
||||||
|
include_weekends: boolean;
|
||||||
|
project_id: string;
|
||||||
|
category: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
form: BulkPlanForm;
|
||||||
|
setForm: (form: BulkPlanForm) => void;
|
||||||
|
users: PlanUser[];
|
||||||
|
projects: Project[];
|
||||||
|
categories: PlanCategory[];
|
||||||
|
onSubmit: () => void;
|
||||||
|
submitting: boolean;
|
||||||
|
toggleUser: (userId: number) => void;
|
||||||
|
toggleAllUsers: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BulkPlanModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
users,
|
||||||
|
projects,
|
||||||
|
categories,
|
||||||
|
onSubmit,
|
||||||
|
submitting,
|
||||||
|
toggleUser,
|
||||||
|
toggleAllUsers,
|
||||||
|
}: Props) {
|
||||||
|
const activeCategories = categories.filter((c) => c.is_active);
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Hromadné přiřazení"
|
||||||
|
maxWidth="lg"
|
||||||
|
loading={submitting}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
submitText="Vytvořit"
|
||||||
|
submitDisabled={form.user_ids.length === 0}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ mt: 0.25, mb: 2 }}
|
||||||
|
>
|
||||||
|
Vytvoří záznamy pro vybrané dny u zvolených zaměstnanců. Dny, které už
|
||||||
|
mají 3 záznamy, se přeskočí.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Field label="Datum od">
|
||||||
|
<DateField
|
||||||
|
value={form.date_from}
|
||||||
|
onChange={(val) => setForm({ ...form, date_from: val })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Rozsah dnů">
|
||||||
|
<CheckboxField
|
||||||
|
label="Více dní"
|
||||||
|
checked={form.is_range}
|
||||||
|
onChange={(checked) => setForm({ ...form, is_range: checked })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{form.is_range && (
|
||||||
|
<Field label="Datum do">
|
||||||
|
<DateField
|
||||||
|
value={form.date_to}
|
||||||
|
onChange={(val) => setForm({ ...form, date_to: val })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="Víkendy">
|
||||||
|
<CheckboxField
|
||||||
|
label="Zahrnout víkendy"
|
||||||
|
checked={form.include_weekends}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setForm({ ...form, include_weekends: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 0.5 }}>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
Zaměstnanci
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={toggleAllUsers}
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
p: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "primary.main",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{users.length > 0 && form.user_ids.length === users.length
|
||||||
|
? "Odznačit vše"
|
||||||
|
: "Vybrat vše"}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 0.25,
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: "auto",
|
||||||
|
p: 1.5,
|
||||||
|
bgcolor: "action.hover",
|
||||||
|
borderRadius: 2,
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{users.map((u) => (
|
||||||
|
<CheckboxField
|
||||||
|
key={u.id}
|
||||||
|
label={u.full_name}
|
||||||
|
checked={form.user_ids.includes(String(u.id))}
|
||||||
|
onChange={() => toggleUser(u.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Vybráno: {form.user_ids.length} z {users.length}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Field label="Projekt">
|
||||||
|
<Select
|
||||||
|
value={form.project_id}
|
||||||
|
onChange={(val) => setForm({ ...form, project_id: val })}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "— bez projektu —" },
|
||||||
|
...projects.map((p) => ({ value: String(p.id), label: p.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Kategorie">
|
||||||
|
<Select
|
||||||
|
value={form.category}
|
||||||
|
onChange={(val) => setForm({ ...form, category: val })}
|
||||||
|
options={activeCategories.map((c) => ({
|
||||||
|
value: c.key,
|
||||||
|
label: c.label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Text poznámky (volitelný)">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={form.note}
|
||||||
|
onChange={(e) => setForm({ ...form, note: e.target.value })}
|
||||||
|
inputProps={{ maxLength: 500 }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,50 @@ 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,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -255,6 +632,6 @@ export default function PlanGrid({
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</PlanGridRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +1,50 @@
|
|||||||
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";
|
||||||
|
|
||||||
|
// Copy helper. navigator.clipboard is SECURE-CONTEXT ONLY (undefined over plain
|
||||||
|
// HTTP on a LAN), so we fall back to the legacy execCommand path — which works
|
||||||
|
// in insecure contexts — and report whether the copy actually succeeded. The
|
||||||
|
// caller MUST gate its success toast on the returned boolean (otherwise it lies
|
||||||
|
// to the user about copying 2FA codes / the TOTP secret).
|
||||||
|
async function copyToClipboard(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to the legacy path
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand("copy");
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface DashProfileProps {
|
interface DashProfileProps {
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
totpLoading: boolean;
|
totpLoading: boolean;
|
||||||
@@ -37,6 +75,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 +127,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 +141,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 +203,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 +305,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 +475,137 @@ 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={async () => {
|
||||||
navigator.clipboard?.writeText(
|
const ok = await copyToClipboard(backupCodes.join("\n"));
|
||||||
backupCodes.join("\n"),
|
if (ok) alert.success("Kódy zkopírovány");
|
||||||
);
|
else alert.error("Kopírování selhalo – zkopírujte ručně");
|
||||||
alert.success("Kódy zkopírovány");
|
|
||||||
}}
|
}}
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
|
startIcon={CopyIcon}
|
||||||
>
|
>
|
||||||
<svg
|
|
||||||
width="14"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x="9"
|
|
||||||
y="9"
|
|
||||||
width="13"
|
|
||||||
height="13"
|
|
||||||
rx="2"
|
|
||||||
ry="2"
|
|
||||||
/>
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
||||||
</svg>
|
|
||||||
Kopírovat kódy
|
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={async () => {
|
||||||
navigator.clipboard?.writeText(totpSecret);
|
const ok = await copyToClipboard(totpSecret);
|
||||||
alert.success("Klíč zkopírován");
|
if (ok) alert.success("Klíč zkopírován");
|
||||||
|
else
|
||||||
|
alert.error("Kopírování selhalo – zkopírujte ruč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 +613,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/admin/components/odin/InvoiceReviewCard.tsx
Normal file
121
src/admin/components/odin/InvoiceReviewCard.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import { Card, Button, TextField } from "../../ui";
|
||||||
|
import type { ReviewInvoice, EditableField } from "./types";
|
||||||
|
|
||||||
|
interface InvoiceReviewCardProps {
|
||||||
|
inv: ReviewInvoice;
|
||||||
|
busy: boolean;
|
||||||
|
canSave: boolean;
|
||||||
|
onChange: (uid: string, field: EditableField, value: string) => void;
|
||||||
|
onSave: (inv: ReviewInvoice) => void;
|
||||||
|
onDiscard: (uid: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldCols = { xs: "1fr", sm: "1fr 1fr" };
|
||||||
|
|
||||||
|
export default function InvoiceReviewCard({
|
||||||
|
inv,
|
||||||
|
busy,
|
||||||
|
canSave,
|
||||||
|
onChange,
|
||||||
|
onSave,
|
||||||
|
onDiscard,
|
||||||
|
}: InvoiceReviewCardProps) {
|
||||||
|
return (
|
||||||
|
<Card variant="outlined" sx={{ p: 1.5 }}>
|
||||||
|
{/* Header: chip + filename */}
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 1 }}>
|
||||||
|
<Chip label="Faktura" size="small" color="primary" />
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
noWrap
|
||||||
|
title={inv.file_name}
|
||||||
|
>
|
||||||
|
{inv.file_name}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 2-column field grid */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: fieldCols,
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Dodavatel"
|
||||||
|
value={inv.supplier_name}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => onChange(inv.uid, "supplier_name", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Číslo faktury"
|
||||||
|
value={inv.invoice_number}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => onChange(inv.uid, "invoice_number", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Částka s DPH"
|
||||||
|
value={inv.amount}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => onChange(inv.uid, "amount", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Měna"
|
||||||
|
value={inv.currency}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => onChange(inv.uid, "currency", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Sazba DPH %"
|
||||||
|
value={inv.vat_rate}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => onChange(inv.uid, "vat_rate", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Datum vystavení"
|
||||||
|
value={inv.issue_date}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => onChange(inv.uid, "issue_date", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Datum splatnosti"
|
||||||
|
value={inv.due_date}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => onChange(inv.uid, "due_date", e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Popis"
|
||||||
|
value={inv.description}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => onChange(inv.uid, "description", e.target.value)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Box sx={{ display: "flex", gap: 1, mt: 1.5 }}>
|
||||||
|
<Button
|
||||||
|
onClick={() => onSave(inv)}
|
||||||
|
disabled={busy || !canSave}
|
||||||
|
title={
|
||||||
|
!canSave ? "Nemáte oprávnění k uložení přijatých faktur" : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Uložit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => onDiscard(inv.uid)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Zahodit
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
548
src/admin/components/odin/OdinChat.tsx
Normal file
548
src/admin/components/odin/OdinChat.tsx
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
import apiFetch from "../../utils/api";
|
||||||
|
import { useAlert } from "../../context/AlertContext";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import {
|
||||||
|
aiUsageOptions,
|
||||||
|
aiConversationsOptions,
|
||||||
|
aiConversationMessagesOptions,
|
||||||
|
type ExtractedInvoice,
|
||||||
|
} from "../../lib/queries/ai";
|
||||||
|
import type {
|
||||||
|
ChatTurn,
|
||||||
|
ReviewInvoice,
|
||||||
|
EditableField,
|
||||||
|
StagedFile,
|
||||||
|
} from "./types";
|
||||||
|
import OdinSidebar from "./OdinSidebar";
|
||||||
|
import OdinThread from "./OdinThread";
|
||||||
|
import InvoiceReviewCard from "./InvoiceReviewCard";
|
||||||
|
import OdinComposer from "./OdinComposer";
|
||||||
|
|
||||||
|
// Stable, monotonic id (NOT crypto.randomUUID — undefined over plain HTTP).
|
||||||
|
let uidSeq = 0;
|
||||||
|
const nextUid = () => `ai-${(uidSeq += 1)}`;
|
||||||
|
const MODEL_CONTEXT = 20; // recent turns sent to the model
|
||||||
|
const MAX_STORE = 8000; // server caps a stored message at 8000 chars
|
||||||
|
|
||||||
|
const plural = (n: number) =>
|
||||||
|
n === 1 ? "fakturu" : n >= 2 && n <= 4 ? "faktury" : "faktur";
|
||||||
|
|
||||||
|
function summaryNote(reviews: ReviewInvoice[]): string {
|
||||||
|
if (reviews.length === 0) return "V přílohách jsem nenašel žádnou fakturu.";
|
||||||
|
const suppliers = [
|
||||||
|
...new Set(reviews.map((r) => r.supplier_name).filter(Boolean)),
|
||||||
|
];
|
||||||
|
const supTxt =
|
||||||
|
suppliers.length === 1
|
||||||
|
? ` od ${suppliers[0]}`
|
||||||
|
: suppliers.length > 1
|
||||||
|
? ` od ${suppliers[0]} a dalších`
|
||||||
|
: "";
|
||||||
|
const totals: Record<string, number> = {};
|
||||||
|
for (const r of reviews) {
|
||||||
|
const cur = r.currency || "CZK";
|
||||||
|
totals[cur] = (totals[cur] || 0) + (Number(r.amount) || 0);
|
||||||
|
}
|
||||||
|
const totalsTxt = Object.entries(totals)
|
||||||
|
.map(
|
||||||
|
([cur, sum]) =>
|
||||||
|
`${sum.toLocaleString("cs-CZ", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})} ${cur}`,
|
||||||
|
)
|
||||||
|
.join(" + ");
|
||||||
|
return `Načetl jsem ${reviews.length} ${plural(reviews.length)}${supTxt}, celkem ${totalsTxt}. Zkontrolujte údaje a uložte.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OdinChat() {
|
||||||
|
const alert = useAlert();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { user, hasPermission } = useAuth();
|
||||||
|
// Saving an extracted invoice hits /received-invoices (needs invoices.create);
|
||||||
|
// Odin itself only needs ai.use, so the Save affordance is gated separately.
|
||||||
|
const canSave = hasPermission("invoices.create");
|
||||||
|
// Time-based Czech greeting + the user's first name (client-local time).
|
||||||
|
const greetingHour = new Date().getHours();
|
||||||
|
const greetingPhrase =
|
||||||
|
greetingHour < 9
|
||||||
|
? "Dobré ráno"
|
||||||
|
: greetingHour < 12
|
||||||
|
? "Dobrý den"
|
||||||
|
: greetingHour < 18
|
||||||
|
? "Dobré odpoledne"
|
||||||
|
: "Dobrý večer";
|
||||||
|
const userName = (user?.fullName || user?.username || "")
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)[0];
|
||||||
|
const { data: usage } = useQuery(aiUsageOptions());
|
||||||
|
const { data: convData, isPending: convPending } = useQuery(
|
||||||
|
aiConversationsOptions(),
|
||||||
|
);
|
||||||
|
const conversations = convData?.conversations ?? [];
|
||||||
|
|
||||||
|
const [activeId, setActiveId] = useState<number | null>(null);
|
||||||
|
const { data: messagesData } = useQuery({
|
||||||
|
...aiConversationMessagesOptions(activeId ?? 0),
|
||||||
|
enabled: activeId != null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
||||||
|
const [review, setReview] = useState<ReviewInvoice[]>([]);
|
||||||
|
const [attachments, setAttachments] = useState<StagedFile[]>([]);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const threadRef = useRef<HTMLDivElement>(null);
|
||||||
|
const seededId = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Below md the conversation list collapses into a slide-in drawer.
|
||||||
|
const isMobile = useMediaQuery((t) => t.breakpoints.down("md"), {
|
||||||
|
noSsr: true,
|
||||||
|
});
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
// No auto-select: like claude.ai, we land on a fresh "new chat" (activeId
|
||||||
|
// null) and the sidebar lists existing conversations to open. A conversation
|
||||||
|
// row in the DB is created lazily on the first message (see submit), so
|
||||||
|
// pressing "Nová konverzace" repeatedly never spawns empty conversations.
|
||||||
|
|
||||||
|
// Seed the thread from the active conversation's messages, once per id.
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeId != null && messagesData && seededId.current !== activeId) {
|
||||||
|
seededId.current = activeId;
|
||||||
|
setTurns(
|
||||||
|
messagesData.messages.map((m) => ({
|
||||||
|
role: m.role as "user" | "assistant",
|
||||||
|
content: m.content,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setReview([]);
|
||||||
|
}
|
||||||
|
}, [activeId, messagesData]);
|
||||||
|
|
||||||
|
// Keep the thread scrolled to the latest message.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = threadRef.current;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
}, [turns, busy]);
|
||||||
|
|
||||||
|
// AI is hidden entirely when the backend isn't configured.
|
||||||
|
if (usage && usage.configured === false) return null;
|
||||||
|
|
||||||
|
const messagesLoading =
|
||||||
|
activeId != null && !messagesData && seededId.current !== activeId;
|
||||||
|
|
||||||
|
const persist = (convId: number, msgs: ChatTurn[]) => {
|
||||||
|
apiFetch(`/api/admin/ai/conversations/${convId}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
messages: msgs.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content.slice(0, MAX_STORE),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) return r.json().then((b) => Promise.reject(b?.error));
|
||||||
|
// The append applies the server-side auto-title and bumps updated_at,
|
||||||
|
// so refresh the list now (a new conversation gets its real title here).
|
||||||
|
qc.invalidateQueries({ queryKey: ["ai", "conversations"] });
|
||||||
|
})
|
||||||
|
.catch((e) => console.error("[odin] history persist failed", e));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attaching only stages files; nothing is sent until Odeslat.
|
||||||
|
const onFiles = (files: FileList | null) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
setAttachments((a) => [
|
||||||
|
...a,
|
||||||
|
...Array.from(files).map((file) => ({ id: nextUid(), file })),
|
||||||
|
]);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
};
|
||||||
|
const removeAttachment = (id: string) =>
|
||||||
|
setAttachments((a) => a.filter((s) => s.id !== id));
|
||||||
|
|
||||||
|
const onSelect = (id: number) => {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
if (id === activeId || busy) return;
|
||||||
|
setActiveId(id);
|
||||||
|
setTurns([]);
|
||||||
|
setReview([]);
|
||||||
|
seededId.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns the active conversation id, creating one if none is selected yet.
|
||||||
|
const ensureActive = async (): Promise<number | null> => {
|
||||||
|
if (activeId != null) return activeId;
|
||||||
|
const res = await apiFetch("/api/admin/ai/conversations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const b = await res.json();
|
||||||
|
if (!res.ok || !b?.data?.conversation) return null;
|
||||||
|
const id = b.data.conversation.id as number;
|
||||||
|
seededId.current = id;
|
||||||
|
setActiveId(id);
|
||||||
|
// Don't refresh the list yet — the conversation is still the default
|
||||||
|
// "Nová konverzace" here; persist() invalidates after the auto-title lands.
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
!convPending && !busy && (!!input.trim() || attachments.length > 0);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (busy) return;
|
||||||
|
const text = input.trim();
|
||||||
|
const files = attachments.map((a) => a.file);
|
||||||
|
if (!text && files.length === 0) return;
|
||||||
|
|
||||||
|
// Guard: Odin is scoped to invoice processing only (for now). A text-only
|
||||||
|
// message makes NO AI call — it gets a canned reply locally, so open-ended
|
||||||
|
// chat can't burn API credits. The invoice path (attachments) runs normally.
|
||||||
|
// (Removing this guard re-enables the general /chat path below for Phase 2.)
|
||||||
|
if (files.length === 0) {
|
||||||
|
setTurns((t) => [
|
||||||
|
...t,
|
||||||
|
{ role: "user", content: text },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content:
|
||||||
|
"Momentálně umím zpracovat pouze přijaté faktury. Přiložte prosím fakturu (PDF) a já z ní načtu údaje k uložení.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setInput("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
const prevTurns = turns;
|
||||||
|
|
||||||
|
const userContent = [
|
||||||
|
text,
|
||||||
|
files.length > 0 ? `📎 ${files.map((f) => f.name).join(", ")}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
const optimistic: ChatTurn[] = [
|
||||||
|
...turns,
|
||||||
|
{ role: "user", content: userContent },
|
||||||
|
];
|
||||||
|
setTurns(optimistic);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (files.length > 0) {
|
||||||
|
const fd = new FormData();
|
||||||
|
files.forEach((f) => fd.append("files", f));
|
||||||
|
const res = await apiFetch("/api/admin/ai/extract-invoices", {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok) throw new Error(body?.error || "Chyba čtení faktur");
|
||||||
|
const reviews: ReviewInvoice[] = (
|
||||||
|
body.data.invoices as Array<{
|
||||||
|
file_name: string;
|
||||||
|
fields?: ExtractedInvoice;
|
||||||
|
error?: string;
|
||||||
|
}>
|
||||||
|
)
|
||||||
|
.map((r, i): ReviewInvoice | null => {
|
||||||
|
const f = r.fields;
|
||||||
|
return f
|
||||||
|
? {
|
||||||
|
uid: nextUid(),
|
||||||
|
supplier_name: f.supplier_name ?? "",
|
||||||
|
invoice_number: f.invoice_number ?? "",
|
||||||
|
amount: f.amount != null ? String(f.amount) : "",
|
||||||
|
currency: f.currency ?? "",
|
||||||
|
vat_rate: f.vat_rate != null ? String(f.vat_rate) : "",
|
||||||
|
issue_date: f.issue_date ?? "",
|
||||||
|
due_date: f.due_date ?? "",
|
||||||
|
description: f.description ?? "",
|
||||||
|
file_name: r.file_name,
|
||||||
|
file: files[i],
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
.filter((x): x is ReviewInvoice => x !== null);
|
||||||
|
setReview((prev) => [...prev, ...reviews]);
|
||||||
|
const note = summaryNote(reviews);
|
||||||
|
setTurns((t) => [...t, { role: "assistant", content: note }]);
|
||||||
|
setInput("");
|
||||||
|
setAttachments([]);
|
||||||
|
// Create the conversation now (first successful message) and persist.
|
||||||
|
const convId = await ensureActive();
|
||||||
|
if (convId != null)
|
||||||
|
persist(convId, [
|
||||||
|
{ role: "user", content: userContent },
|
||||||
|
{ role: "assistant", content: note },
|
||||||
|
]);
|
||||||
|
qc.invalidateQueries({ queryKey: ["ai", "usage"] });
|
||||||
|
} else {
|
||||||
|
let ctx = optimistic.slice(-MODEL_CONTEXT);
|
||||||
|
while (ctx.length && ctx[0].role !== "user") ctx = ctx.slice(1);
|
||||||
|
const res = await apiFetch("/api/admin/ai/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ messages: ctx }),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok) throw new Error(body?.error || "Chyba AI");
|
||||||
|
const reply: string = body.data.reply;
|
||||||
|
setTurns((t) => [...t, { role: "assistant", content: reply }]);
|
||||||
|
setInput("");
|
||||||
|
// Create the conversation now (first successful message) and persist.
|
||||||
|
const convId = await ensureActive();
|
||||||
|
if (convId != null)
|
||||||
|
persist(convId, [
|
||||||
|
{ role: "user", content: userContent },
|
||||||
|
{ role: "assistant", content: reply },
|
||||||
|
]);
|
||||||
|
qc.invalidateQueries({ queryKey: ["ai", "usage"] });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setTurns(prevTurns);
|
||||||
|
const fallback = files.length > 0 ? "Chyba čtení faktur" : "Chyba AI";
|
||||||
|
alert.error(e instanceof Error ? e.message : fallback);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveInvoice = async (inv: ReviewInvoice) => {
|
||||||
|
if (busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("files", inv.file, inv.file_name);
|
||||||
|
fd.append(
|
||||||
|
"invoices",
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
supplier_name: inv.supplier_name,
|
||||||
|
invoice_number: inv.invoice_number || undefined,
|
||||||
|
description: inv.description || undefined,
|
||||||
|
amount: inv.amount === "" ? undefined : Number(inv.amount),
|
||||||
|
currency: inv.currency,
|
||||||
|
vat_rate: inv.vat_rate === "" ? undefined : Number(inv.vat_rate),
|
||||||
|
issue_date: inv.issue_date || undefined,
|
||||||
|
due_date: inv.due_date || undefined,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const res = await apiFetch("/api/admin/received-invoices", {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok) throw new Error(body?.error || "Uložení selhalo");
|
||||||
|
alert.success("Faktura uložena");
|
||||||
|
setReview((r) => r.filter((x) => x.uid !== inv.uid));
|
||||||
|
qc.invalidateQueries({ queryKey: ["invoices"] });
|
||||||
|
} catch (e) {
|
||||||
|
alert.error(e instanceof Error ? e.message : "Uložení selhalo");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// "Nová konverzace" just opens a fresh, empty composer — no DB row is created
|
||||||
|
// until the first message, so repeated clicks never spawn empty conversations.
|
||||||
|
const onNew = () => {
|
||||||
|
setSidebarOpen(false);
|
||||||
|
if (busy) return;
|
||||||
|
setActiveId(null);
|
||||||
|
setTurns([]);
|
||||||
|
setReview([]);
|
||||||
|
setInput("");
|
||||||
|
setAttachments([]);
|
||||||
|
seededId.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRename = async (id: number, title: string) => {
|
||||||
|
const res = await apiFetch(`/api/admin/ai/conversations/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
});
|
||||||
|
if (res.ok) qc.invalidateQueries({ queryKey: ["ai", "conversations"] });
|
||||||
|
else alert.error("Přejmenování selhalo");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (id: number) => {
|
||||||
|
const res = await apiFetch(`/api/admin/ai/conversations/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
alert.error("Smazání selhalo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (id === activeId) {
|
||||||
|
// Deleting the open conversation returns to a fresh new chat.
|
||||||
|
setActiveId(null);
|
||||||
|
setTurns([]);
|
||||||
|
setReview([]);
|
||||||
|
seededId.current = null;
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: ["ai", "conversations"] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const patch = (uid: string, field: EditableField, value: string) =>
|
||||||
|
setReview((r) =>
|
||||||
|
r.map((inv) => (inv.uid === uid ? { ...inv, [field]: value } : inv)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeTitle =
|
||||||
|
conversations.find((c) => c.id === activeId)?.title ?? "Nová konverzace";
|
||||||
|
|
||||||
|
const sidebar = (
|
||||||
|
<OdinSidebar
|
||||||
|
conversations={conversations}
|
||||||
|
activeId={activeId}
|
||||||
|
busy={busy}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onNew={onNew}
|
||||||
|
onRename={onRename}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: "calc(100dvh - 100px)",
|
||||||
|
display: "flex",
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isMobile ? (
|
||||||
|
<Drawer
|
||||||
|
open={sidebarOpen}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
|
>
|
||||||
|
{sidebar}
|
||||||
|
</Drawer>
|
||||||
|
) : (
|
||||||
|
sidebar
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chat column */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 1.5,
|
||||||
|
p: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
aria-label="Konverzace"
|
||||||
|
size="small"
|
||||||
|
sx={{ ml: -0.5, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
sx={{ width: 22, height: 22 }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</Box>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
noWrap
|
||||||
|
sx={{ fontWeight: 600, minWidth: 0, flex: 1 }}
|
||||||
|
>
|
||||||
|
{activeTitle}
|
||||||
|
</Typography>
|
||||||
|
{usage && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ flexShrink: 0, display: { xs: "none", sm: "block" } }}
|
||||||
|
>
|
||||||
|
Utraceno: ${usage.month_spend_usd.toFixed(2)} / $
|
||||||
|
{usage.budget_usd.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<OdinThread
|
||||||
|
turns={turns}
|
||||||
|
busy={busy}
|
||||||
|
loading={messagesLoading}
|
||||||
|
greetingPhrase={greetingPhrase}
|
||||||
|
userName={userName}
|
||||||
|
threadRef={threadRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{review.length > 0 && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
maxHeight: "35vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{review.map((inv) => (
|
||||||
|
<InvoiceReviewCard
|
||||||
|
key={inv.uid}
|
||||||
|
inv={inv}
|
||||||
|
busy={busy}
|
||||||
|
canSave={canSave}
|
||||||
|
onChange={patch}
|
||||||
|
onSave={saveInvoice}
|
||||||
|
onDiscard={(uid) =>
|
||||||
|
setReview((r) => r.filter((x) => x.uid !== uid))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OdinComposer
|
||||||
|
input={input}
|
||||||
|
attachments={attachments}
|
||||||
|
busy={busy}
|
||||||
|
canSubmit={canSubmit}
|
||||||
|
fileRef={fileRef}
|
||||||
|
onInput={setInput}
|
||||||
|
onFiles={onFiles}
|
||||||
|
onRemoveAttachment={removeAttachment}
|
||||||
|
onSubmit={submit}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/admin/components/odin/OdinComposer.tsx
Normal file
147
src/admin/components/odin/OdinComposer.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import type { StagedFile } from "./types";
|
||||||
|
|
||||||
|
interface OdinComposerProps {
|
||||||
|
input: string;
|
||||||
|
attachments: StagedFile[];
|
||||||
|
busy: boolean;
|
||||||
|
canSubmit: boolean;
|
||||||
|
fileRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
onInput: (v: string) => void;
|
||||||
|
onFiles: (files: FileList | null) => void;
|
||||||
|
onRemoveAttachment: (id: string) => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OdinComposer({
|
||||||
|
input,
|
||||||
|
attachments,
|
||||||
|
busy,
|
||||||
|
canSubmit,
|
||||||
|
fileRef,
|
||||||
|
onInput,
|
||||||
|
onFiles,
|
||||||
|
onRemoveAttachment,
|
||||||
|
onSubmit,
|
||||||
|
}: OdinComposerProps) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Staged attachment chips */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5, mb: 1 }}>
|
||||||
|
{attachments.map((s) => (
|
||||||
|
<Chip
|
||||||
|
key={s.id}
|
||||||
|
label={s.file.name}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onDelete={busy ? undefined : () => onRemoveAttachment(s.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileRef as React.RefObject<HTMLInputElement>}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={(e) => onFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Composer pill — full-width growing input with attach + send icons */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
borderRadius: 3.5,
|
||||||
|
bgcolor: "background.default",
|
||||||
|
pl: 0.75,
|
||||||
|
pr: 0.75,
|
||||||
|
py: 0.5,
|
||||||
|
transition: "border-color 0.15s ease",
|
||||||
|
"&:focus-within": { borderColor: "primary.main" },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label="Přiložit fakturu"
|
||||||
|
title="Přiložit fakturu (PDF)"
|
||||||
|
sx={{ flexShrink: 0, color: "text.secondary" }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
sx={{ width: 21, height: 21 }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||||
|
</Box>
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
maxRows={8}
|
||||||
|
variant="standard"
|
||||||
|
placeholder="Napište zprávu…"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => onInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
slotProps={{ input: { disableUnderline: true } }}
|
||||||
|
sx={{ flex: 1, "& textarea": { py: 0.75, lineHeight: 1.5 } }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
aria-label="Odeslat"
|
||||||
|
title="Odeslat (Enter)"
|
||||||
|
sx={{
|
||||||
|
flexShrink: 0,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
color: "common.white",
|
||||||
|
bgcolor: "primary.main",
|
||||||
|
"&:hover": { bgcolor: "primary.dark" },
|
||||||
|
"&.Mui-disabled": {
|
||||||
|
bgcolor: "action.disabledBackground",
|
||||||
|
color: "action.disabled",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
sx={{ width: 18, height: 18 }}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="5" />
|
||||||
|
<polyline points="5 12 12 5 19 12" />
|
||||||
|
</Box>
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/admin/components/odin/OdinMark.tsx
Normal file
119
src/admin/components/odin/OdinMark.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { keyframes, styled } from "@mui/material/styles";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import { useReducedMotion } from "framer-motion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Odin's animated brand mark: an AI "spark" (Odin) with a data node orbiting a
|
||||||
|
* ring — the assistant at the centre of the business system, in BOHA red. The
|
||||||
|
* spark slow-rotates and breathes; the node orbits. In the "thinking" state
|
||||||
|
* everything speeds up and the tile glows. Honours prefers-reduced-motion.
|
||||||
|
*
|
||||||
|
* The animations live in a `styled("svg")` (not inline style): Emotion only
|
||||||
|
* injects the @keyframes rule when the keyframes object is used inside
|
||||||
|
* sx/css/styled, and the child selectors keep the SVG elements plain/typed.
|
||||||
|
*/
|
||||||
|
const breathe = keyframes`
|
||||||
|
0%, 100% { transform: scale(0.9); opacity: 0.9; }
|
||||||
|
50% { transform: scale(1.08); opacity: 1; }
|
||||||
|
`;
|
||||||
|
const spin = keyframes`
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
`;
|
||||||
|
// Reduce-motion safe: opacity only (no transforms / no travel).
|
||||||
|
const glow = keyframes`
|
||||||
|
0%, 100% { opacity: 0.55; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Glyph = styled("svg", {
|
||||||
|
shouldForwardProp: (p) => p !== "thinking" && p !== "reduce",
|
||||||
|
})<{ thinking: boolean; reduce: boolean }>(({ thinking, reduce }) => ({
|
||||||
|
// !important so an ancestor `& svg { width }` rule (e.g. SidebarNav forces
|
||||||
|
// 18px) can't override the mark's own sizing — `size` stays authoritative.
|
||||||
|
width: "70% !important",
|
||||||
|
height: "70% !important",
|
||||||
|
overflow: "visible",
|
||||||
|
display: "block",
|
||||||
|
...(reduce
|
||||||
|
? {
|
||||||
|
// Reduce-motion: keep a calm opacity glow (no spin/orbit/travel). The
|
||||||
|
// `!important` + class specificity intentionally overrides the app-wide
|
||||||
|
// reduced-motion reset in GlobalStyles for this one small mark, since an
|
||||||
|
// opacity pulse is accessibility-safe (it isn't vestibular motion).
|
||||||
|
"& .odin-spark": {
|
||||||
|
animation: `${glow} ${thinking ? "1.1s" : "3s"} ease-in-out infinite !important`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
"& .odin-orbit": {
|
||||||
|
transformBox: "view-box",
|
||||||
|
transformOrigin: "12px 12px",
|
||||||
|
animation: `${spin} ${thinking ? "1.6s" : "5.5s"} linear infinite`,
|
||||||
|
},
|
||||||
|
"& .odin-spin": {
|
||||||
|
transformBox: "view-box",
|
||||||
|
transformOrigin: "12px 12px",
|
||||||
|
animation: `${spin} ${thinking ? "4s" : "11s"} linear infinite`,
|
||||||
|
},
|
||||||
|
"& .odin-spark": {
|
||||||
|
transformBox: "fill-box",
|
||||||
|
transformOrigin: "center",
|
||||||
|
animation: `${breathe} ${thinking ? "1s" : "2.8s"} ease-in-out infinite`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export interface OdinMarkProps {
|
||||||
|
size?: number;
|
||||||
|
state?: "idle" | "thinking";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OdinMark({ size = 28, state = "idle" }: OdinMarkProps) {
|
||||||
|
const reduce = !!useReducedMotion();
|
||||||
|
const thinking = state === "thinking";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={(t) => ({
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: "32%",
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: `linear-gradient(135deg, ${t.vars!.palette.primary.main}, ${t.vars!.palette.primary.dark})`,
|
||||||
|
boxShadow: thinking
|
||||||
|
? `0 0 ${Math.round(size * 0.5)}px 0 rgba(${t.vars!.palette.primary.mainChannel} / 0.55)`
|
||||||
|
: "none",
|
||||||
|
transition: "box-shadow 0.3s ease",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Glyph viewBox="0 0 24 24" thinking={thinking} reduce={reduce}>
|
||||||
|
{/* orbit ring */}
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="9.2"
|
||||||
|
fill="none"
|
||||||
|
stroke="#fff"
|
||||||
|
strokeOpacity="0.28"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
{/* orbiting data node */}
|
||||||
|
<g className="odin-orbit">
|
||||||
|
<circle cx="12" cy="2.8" r="1.4" fill="#fff" />
|
||||||
|
</g>
|
||||||
|
{/* spark — group slow-rotates, the star breathes about its own centre */}
|
||||||
|
<g className="odin-spin">
|
||||||
|
<path
|
||||||
|
className="odin-spark"
|
||||||
|
d="M12 4.5 C12.5 9, 13 9.5, 17.5 12 C13 14.5, 12.5 15, 12 19.5 C11.5 15, 11 14.5, 6.5 12 C11 9.5, 11.5 9, 12 4.5 Z"
|
||||||
|
fill="#fff"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</Glyph>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
src/admin/components/odin/OdinSidebar.tsx
Normal file
221
src/admin/components/odin/OdinSidebar.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import Menu from "@mui/material/Menu";
|
||||||
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import { Button, TextField, Modal, ConfirmDialog } from "../../ui";
|
||||||
|
|
||||||
|
interface OdinSidebarProps {
|
||||||
|
conversations: { id: number; title: string }[];
|
||||||
|
activeId: number | null;
|
||||||
|
busy: boolean;
|
||||||
|
onSelect: (id: number) => void;
|
||||||
|
onNew: () => void;
|
||||||
|
onRename: (id: number, title: string) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OdinSidebar({
|
||||||
|
conversations,
|
||||||
|
activeId,
|
||||||
|
busy,
|
||||||
|
onSelect,
|
||||||
|
onNew,
|
||||||
|
onRename,
|
||||||
|
onDelete,
|
||||||
|
}: OdinSidebarProps) {
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
|
const [menuConv, setMenuConv] = useState<{
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [renameDraft, setRenameDraft] = useState("");
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
|
const openMenu = (
|
||||||
|
e: React.MouseEvent<HTMLElement>,
|
||||||
|
conv: { id: number; title: string },
|
||||||
|
) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setMenuConv(conv);
|
||||||
|
setAnchorEl(e.currentTarget);
|
||||||
|
};
|
||||||
|
const closeMenu = () => setAnchorEl(null);
|
||||||
|
|
||||||
|
const handleRenameOpen = () => {
|
||||||
|
closeMenu();
|
||||||
|
setRenameDraft(menuConv?.title ?? "");
|
||||||
|
setRenameOpen(true);
|
||||||
|
};
|
||||||
|
const handleRenameSubmit = () => {
|
||||||
|
if (menuConv) onRename(menuConv.id, renameDraft.trim() || menuConv.title);
|
||||||
|
setRenameOpen(false);
|
||||||
|
};
|
||||||
|
const handleDeleteOpen = () => {
|
||||||
|
closeMenu();
|
||||||
|
setDeleteOpen(true);
|
||||||
|
};
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (menuConv) onDelete(menuConv.id);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: { xs: 280, md: 264 },
|
||||||
|
flexShrink: 0,
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
borderRight: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
bgcolor: "action.hover",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Brand + New chat */}
|
||||||
|
<Box sx={{ p: 1.5, display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={(t) => ({
|
||||||
|
px: 0.5,
|
||||||
|
fontSize: "1.7rem",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
backgroundImage: `linear-gradient(120deg, ${t.vars!.palette.primary.light}, ${t.vars!.palette.primary.main})`,
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
backgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Odin
|
||||||
|
</Box>
|
||||||
|
<Button fullWidth disabled={busy} onClick={onNew}>
|
||||||
|
+ Nová konverzace
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Conversation list */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
px: 1,
|
||||||
|
pb: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 0.25,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{conversations.length === 0 && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ px: 1, py: 1 }}
|
||||||
|
>
|
||||||
|
Zatím žádné konverzace.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{conversations.map((conv) => {
|
||||||
|
const isActive = conv.id === activeId;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={conv.id}
|
||||||
|
onClick={() => !busy && onSelect(conv.id)}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.5,
|
||||||
|
px: 1,
|
||||||
|
py: 0.75,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
cursor: busy ? "default" : "pointer",
|
||||||
|
bgcolor: isActive ? "action.selected" : "transparent",
|
||||||
|
"&:hover": {
|
||||||
|
bgcolor: isActive ? "action.selected" : "action.hover",
|
||||||
|
},
|
||||||
|
"&:hover .odin-conv-menu": { opacity: 1 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
noWrap
|
||||||
|
title={conv.title}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
color: "text.primary",
|
||||||
|
fontWeight: isActive ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{conv.title}
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
className="odin-conv-menu"
|
||||||
|
size="small"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={(e) => openMenu(e, conv)}
|
||||||
|
aria-label="Možnosti konverzace"
|
||||||
|
title="Možnosti konverzace"
|
||||||
|
sx={{
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: { xs: 1, md: 0 },
|
||||||
|
transition: "opacity 0.15s",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Per-conversation menu */}
|
||||||
|
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={closeMenu}>
|
||||||
|
<MenuItem onClick={handleRenameOpen}>Přejmenovat</MenuItem>
|
||||||
|
<MenuItem onClick={handleDeleteOpen}>Smazat</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{/* Rename modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={renameOpen}
|
||||||
|
onClose={() => setRenameOpen(false)}
|
||||||
|
onSubmit={handleRenameSubmit}
|
||||||
|
title="Přejmenovat konverzaci"
|
||||||
|
submitText="Přejmenovat"
|
||||||
|
maxWidth="xs"
|
||||||
|
>
|
||||||
|
<Box sx={{ pt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Název"
|
||||||
|
fullWidth
|
||||||
|
value={renameDraft}
|
||||||
|
onChange={(e) => setRenameDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRenameSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete confirm */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={deleteOpen}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
title="Smazat konverzaci"
|
||||||
|
message={`Opravdu chcete smazat konverzaci „${menuConv?.title ?? ""}"? Tuto akci nelze vrátit.`}
|
||||||
|
confirmText="Smazat"
|
||||||
|
confirmVariant="danger"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
src/admin/components/odin/OdinThread.tsx
Normal file
238
src/admin/components/odin/OdinThread.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||||
|
import type { ChatTurn } from "./types";
|
||||||
|
import OdinMark from "./OdinMark";
|
||||||
|
|
||||||
|
const MotionBox = motion.create(Box);
|
||||||
|
|
||||||
|
// Staggered hero entrance (skipped under prefers-reduced-motion).
|
||||||
|
const heroContainer: Variants = {
|
||||||
|
hidden: {},
|
||||||
|
show: { transition: { staggerChildren: 0.12, delayChildren: 0.06 } },
|
||||||
|
};
|
||||||
|
const heroItem: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 14 },
|
||||||
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.55, ease: [0.16, 1, 0.3, 1] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OdinThreadProps {
|
||||||
|
turns: ChatTurn[];
|
||||||
|
busy: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
greetingPhrase?: string;
|
||||||
|
userName?: string;
|
||||||
|
threadRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small Odin avatar shown to the left of assistant bubbles. */
|
||||||
|
function OdinAvatar({ size = 28 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: "50%",
|
||||||
|
bgcolor: "primary.main",
|
||||||
|
color: "common.white",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: size * 0.5,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
O
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OdinThread({
|
||||||
|
turns,
|
||||||
|
busy,
|
||||||
|
loading,
|
||||||
|
greetingPhrase,
|
||||||
|
userName,
|
||||||
|
threadRef,
|
||||||
|
}: OdinThreadProps) {
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
const containerMotion = reduce
|
||||||
|
? {}
|
||||||
|
: { variants: heroContainer, initial: "hidden", animate: "show" };
|
||||||
|
const itemMotion = reduce ? {} : { variants: heroItem };
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={threadRef}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 1.5,
|
||||||
|
p: 2,
|
||||||
|
bgcolor: "action.hover",
|
||||||
|
borderRadius: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Loading state — history is being fetched */}
|
||||||
|
{loading && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
Načítám…
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{turns.length === 0 && !busy && !loading && (
|
||||||
|
<MotionBox
|
||||||
|
{...containerMotion}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 1.75,
|
||||||
|
textAlign: "center",
|
||||||
|
px: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MotionBox {...itemMotion}>
|
||||||
|
<Typography
|
||||||
|
component="h1"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "'Newsreader', Georgia, 'Times New Roman', serif",
|
||||||
|
fontOpticalSizing: "auto",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: "clamp(2.2rem, 1.3rem + 2.6vw, 3.3rem)",
|
||||||
|
lineHeight: 1.05,
|
||||||
|
letterSpacing: "-0.01em",
|
||||||
|
color: "text.primary",
|
||||||
|
m: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{greetingPhrase || "Dobrý den"}
|
||||||
|
{userName && (
|
||||||
|
<>
|
||||||
|
,{" "}
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={(t) => ({
|
||||||
|
fontStyle: "italic",
|
||||||
|
backgroundImage: `linear-gradient(120deg, ${t.vars!.palette.primary.light}, ${t.vars!.palette.primary.main})`,
|
||||||
|
WebkitBackgroundClip: "text",
|
||||||
|
backgroundClip: "text",
|
||||||
|
WebkitTextFillColor: "transparent",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{userName}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
</MotionBox>
|
||||||
|
<MotionBox {...itemMotion}>
|
||||||
|
<Typography
|
||||||
|
component="p"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "'Newsreader', Georgia, serif",
|
||||||
|
fontStyle: "italic",
|
||||||
|
fontWeight: 400,
|
||||||
|
fontSize: "clamp(1.05rem, 0.95rem + 0.45vw, 1.3rem)",
|
||||||
|
lineHeight: 1.55,
|
||||||
|
color: "text.secondary",
|
||||||
|
maxWidth: 540,
|
||||||
|
m: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Zeptejte se na cokoli, nebo přiložte fakturu — Odin ji přečte a
|
||||||
|
připraví k uložení.
|
||||||
|
</Typography>
|
||||||
|
</MotionBox>
|
||||||
|
</MotionBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Message bubbles */}
|
||||||
|
{turns.map((t, i) => {
|
||||||
|
const isUser = t.role === "user";
|
||||||
|
return (
|
||||||
|
<MotionBox
|
||||||
|
key={i}
|
||||||
|
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: reduce ? 0.3 : 0.34,
|
||||||
|
ease: [0.16, 1, 0.3, 1],
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: 1,
|
||||||
|
alignSelf: isUser ? "flex-end" : "flex-start",
|
||||||
|
maxWidth: "80%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isUser && <OdinAvatar size={28} />}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.5,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow: 1,
|
||||||
|
bgcolor: isUser ? "primary.main" : "background.paper",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Color MUST sit on the Typography: GlobalStyles pins `p` to
|
||||||
|
text.secondary, which beats a color merely inherited from the
|
||||||
|
Box. An sx class on the element wins over that element rule. */}
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
color: isUser ? "common.white" : "text.primary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.content}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</MotionBox>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Busy indicator */}
|
||||||
|
{busy && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
alignSelf: "flex-start",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
px: 1.5,
|
||||||
|
py: 1,
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OdinMark size={22} state="thinking" />
|
||||||
|
<Typography variant="caption" sx={{ color: "inherit" }}>
|
||||||
|
Pracuji…
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/admin/components/odin/types.ts
Normal file
33
src/admin/components/odin/types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export interface ChatTurn {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewInvoice {
|
||||||
|
uid: string;
|
||||||
|
supplier_name: string;
|
||||||
|
invoice_number: string;
|
||||||
|
amount: string;
|
||||||
|
currency: string;
|
||||||
|
vat_rate: string;
|
||||||
|
issue_date: string;
|
||||||
|
due_date: string;
|
||||||
|
description: string;
|
||||||
|
file_name: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditableField =
|
||||||
|
| "supplier_name"
|
||||||
|
| "invoice_number"
|
||||||
|
| "amount"
|
||||||
|
| "currency"
|
||||||
|
| "vat_rate"
|
||||||
|
| "issue_date"
|
||||||
|
| "due_date"
|
||||||
|
| "description";
|
||||||
|
|
||||||
|
export interface StagedFile {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -464,6 +488,20 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bulkCreate = useMutation({
|
||||||
|
mutationFn: (body: any) =>
|
||||||
|
apiCall("/api/admin/plan/entries/bulk", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
// No optimistic patch — bulk can touch many cells; just invalidate and
|
||||||
|
// let the grid refetch the authoritative state.
|
||||||
|
invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Roll back an optimistic patch on mutation error. Called from
|
// Roll back an optimistic patch on mutation error. Called from
|
||||||
// PlanWork's mutation wrappers via `rollbackMutation(mutation, key)`.
|
// PlanWork's mutation wrappers via `rollbackMutation(mutation, key)`.
|
||||||
// `mutation` is a TanStack mutation result with a `_rolled` snapshot stashed
|
// `mutation` is a TanStack mutation result with a `_rolled` snapshot stashed
|
||||||
@@ -472,15 +510,15 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
// directly without the "weak type" mismatch a `{ _rolled? }` param causes.
|
// 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,
|
||||||
@@ -510,6 +548,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
createOverride,
|
createOverride,
|
||||||
updateOverride,
|
updateOverride,
|
||||||
deleteOverride,
|
deleteOverride,
|
||||||
|
bulkCreate,
|
||||||
invalidate,
|
invalidate,
|
||||||
// Exposed so PlanWork can roll back an optimistic patch when a
|
// Exposed so PlanWork can roll back an optimistic patch when a
|
||||||
// mutation throws. Pass the failed mutation and the userId it
|
// mutation throws. Pass the failed mutation and the userId it
|
||||||
@@ -518,10 +557,18 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCells(
|
||||||
|
grid: GridData | undefined,
|
||||||
|
userId: number,
|
||||||
|
date: string,
|
||||||
|
): ResolvedCell[] {
|
||||||
|
return grid?.cells?.[userId]?.[date] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
export function getCell(
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
61
src/admin/lib/queries/ai.ts
Normal file
61
src/admin/lib/queries/ai.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
|
import { jsonQuery } from "../apiAdapter";
|
||||||
|
|
||||||
|
export interface AiUsage {
|
||||||
|
configured: boolean;
|
||||||
|
month_spend_usd: number;
|
||||||
|
budget_usd: number;
|
||||||
|
remaining_usd: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractedInvoice {
|
||||||
|
supplier_name: string;
|
||||||
|
invoice_number: string | null;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
vat_rate: number;
|
||||||
|
issue_date: string | null;
|
||||||
|
due_date: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredChatMessage {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiUsageOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ai", "usage"],
|
||||||
|
queryFn: () => jsonQuery<AiUsage>("/api/admin/ai/usage"),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const aiConversationsOptions = () =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ai", "conversations"],
|
||||||
|
queryFn: () =>
|
||||||
|
jsonQuery<{ conversations: Conversation[] }>(
|
||||||
|
"/api/admin/ai/conversations",
|
||||||
|
),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiConversationMessagesOptions = (id: number) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["ai", "conversation", id, "messages"],
|
||||||
|
queryFn: () =>
|
||||||
|
jsonQuery<{ messages: StoredChatMessage[] }>(
|
||||||
|
`/api/admin/ai/conversations/${id}/messages`,
|
||||||
|
),
|
||||||
|
// Re-read the DB on each mount/selection (no stale-cache resurrection).
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: 0,
|
||||||
|
});
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,13 +46,25 @@ 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 {
|
||||||
id: number;
|
id: number;
|
||||||
leave_type: string;
|
leave_type: string;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user