Compare commits
232 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 | ||
|
|
103908cfb8 | ||
|
|
449a1a2243 | ||
|
|
337feb8b3a | ||
|
|
bc62d32efc | ||
|
|
30dd5a3f3b | ||
|
|
5140d23d73 | ||
|
|
a373102f3a | ||
|
|
49ec3482ae | ||
|
|
f6cdfb9801 | ||
|
|
bd9dc8d103 | ||
|
|
0c5d0ee2f7 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,3 +13,6 @@ dist-client/
|
||||
# Claude worktrees
|
||||
.claude/worktrees/
|
||||
.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 |
|
||||
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
|
||||
| Validation | Zod 4.3.6 |
|
||||
| Frontend | React 18.3.1 + Vite 8.0.0 |
|
||||
| Frontend | React 18.3.1 + Vite 8.0.0 + Material UI v7 (Emotion) |
|
||||
| Testing | Vitest 4.1.0 + Supertest |
|
||||
| PDF | Puppeteer 24.x |
|
||||
| Email | nodemailer 8.x |
|
||||
@@ -35,17 +35,21 @@ src/
|
||||
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
|
||||
├── config/ # env.ts (config singleton, Date.toJSON override)
|
||||
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
|
||||
├── admin/ # React 18 frontend (57 .tsx files)
|
||||
├── admin/ # React 18 + Material UI frontend (~105 .tsx files)
|
||||
│ ├── AdminApp.tsx # Router + lazy-loaded pages
|
||||
│ ├── contexts/ # AuthContext, AlertContext
|
||||
│ ├── components/ # Layout, modals, tables, editors
|
||||
│ ├── theme.ts # MUI theme — light/dark color schemes, tokens, component defaults
|
||||
│ ├── GlobalStyles.tsx # App-wide global styles via MUI <GlobalStyles> (reset, typography, utilities)
|
||||
│ ├── ui/ # MUI component kit (AppShell, Button, DataTable, Modal, …) — pages import from here
|
||||
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
|
||||
│ ├── components/ # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets
|
||||
│ ├── pages/ # One file per page/feature
|
||||
│ ├── hooks/ # useApiCall, useListData, useTableSort, etc.
|
||||
│ ├── lib/ # React Query options & mutations (queries/) + shared label maps
|
||||
│ ├── hooks/ # usePaginatedQuery, useTableSort, useDebounce, useReducedMotion, …
|
||||
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
|
||||
└── __tests__/ # Vitest tests (auth, numbering)
|
||||
└── __tests__/ # Vitest tests (~14 files: auth, numbering, warehouse, plan, invoices, …)
|
||||
|
||||
prisma/
|
||||
├── schema.prisma # 32 models, MySQL, snake_case columns
|
||||
├── schema.prisma # 49 models, MySQL, snake_case columns
|
||||
└── migrations/ # Applied migrations
|
||||
|
||||
dist/ # Compiled server (CommonJS, ES2022)
|
||||
@@ -236,7 +240,7 @@ if ("error" in body) return error(reply, body.error, 400);
|
||||
|
||||
Tests live in `src/__tests__/`. They use Vitest + Supertest against a real test database (`.env.test`).
|
||||
|
||||
- Test coverage is minimal: only `auth` and `numbering` are tested.
|
||||
- The suite spans ~14 files / 152 tests — auth, numbering, warehouse, plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create. Server-side only (no component tests yet).
|
||||
- Use `buildApp()` helper to spin up the Fastify instance for tests.
|
||||
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout.
|
||||
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
|
||||
@@ -250,9 +254,9 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
|
||||
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
|
||||
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
|
||||
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
|
||||
- API calls go through `src/admin/utils/api.ts` which handles token refresh automatically (deduplicates concurrent refresh calls).
|
||||
- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
|
||||
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
|
||||
- Data fetching uses **React Query** (query options + mutations in `src/admin/lib/queries/`); `src/admin/utils/api.ts` (`apiFetch`) handles token refresh automatically — dedupes concurrent refreshes, and token responses carry `expires_in` so the client refreshes BEFORE expiry (no reactive 401 churn).
|
||||
- Custom hooks: `usePaginatedQuery`, `useTableSort`, `useDebounce`, `useModalLock`, `useReducedMotion`.
|
||||
- Styling: **Material UI v7** (Emotion) — theme in `src/admin/theme.ts`, `sx`/`styled()` in components, app-wide rules in `GlobalStyles.tsx`. **No hand-written `.css` files, no Tailwind.** Use `theme.vars` for colours so light/dark resolve automatically.
|
||||
|
||||
### Query Invalidation Convention
|
||||
|
||||
@@ -274,6 +278,33 @@ When entity A embeds/references entity B, A's mutation handlers invalidate B's d
|
||||
|
||||
---
|
||||
|
||||
## Frontend — Styling & MUI (migration complete, shipped in v2.0.0)
|
||||
|
||||
The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 lines of hand-written CSS are gone — **there are no custom `.css` files in `src/admin`**; the only stylesheets imported anywhere are the two third-party libs (`react-quill-new/dist/quill.snow.css`, `leaflet/dist/leaflet.css`). Look-and-feel is "Soft-SaaS shell + dense tables", dark/light preserved. Full history/decisions/gotchas live in agent memory (`project_mui_migration.md`); the original spec/plans are under `docs/superpowers/`.
|
||||
|
||||
**Where styling lives**
|
||||
|
||||
- **Theme — `src/admin/theme.ts`:** `cssVariables` with `colorSchemeSelector: "[data-theme='%s']"`, light + dark `colorSchemes`, tokens, component defaults. Use **`theme.vars!.palette.*`** (the `vars` field is typed optional → `!`) so colours resolve per scheme; for alpha use the channel tokens — `rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`; for per-scheme one-offs use `theme.applyStyles("dark", { … })`.
|
||||
- **Global rules — `src/admin/GlobalStyles.tsx`:** reset, typography, scrollbar, `::selection`, view-transition timing, and the utility classes (`.text-*`, `.flex-*`, `.mb-*`, …), all theme-aware. (The pre-React bootstrap spinner is inlined in `src/App.tsx` because it mounts before MUI.)
|
||||
- **Component kit — `src/admin/ui/`:** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField/MonthField/TimeField (date-fns v4, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable + mobile card layout + `rowSx`/`rowDanger`/`rowInactive`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField/SwitchField, Field, Alert, PageHeader, FilterBar, StatCard, ProgressBar, FileUpload, EmptyState, LoadingState, ThemeToggle, PageEnter (staggered page entrance), RichEditorRoot/RichTextView (Quill). Dev-only `/ui-kit` showcase route.
|
||||
|
||||
**Building / editing pages**
|
||||
|
||||
- Pages import from the **kit** (`src/admin/ui/`); reach for `@mui/material` (`styled`/`sx`) directly only for one-off layout or infra (theme, GlobalStyles, the Quill/Leaflet wrappers).
|
||||
- Wrap a page's top-level sections in `<PageEnter>`. Filters go in `<FilterBar>` with **bare** controls (no `<Field>` label) at the standard widths.
|
||||
- When refactoring, **preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation.
|
||||
|
||||
**Conventions learned — don't regress**
|
||||
|
||||
- **Status row tints** = subtle channel-alpha washes (`rgba(var(--mui-palette-X-mainChannel) / 0.12)`), NEVER a solid `.light` fill: `.light` is a light colour in BOTH schemes, so white dark-mode text on it is invisible. Voided/disabled rows = `opacity` + muted text.
|
||||
- **Icon badges** = solid `X.main` tile + **white** glyph (not an `X.light` tile + `X.main` glyph — that's low-contrast).
|
||||
- **Theme is single-source:** `src/context/ThemeContext.tsx` owns the `<html data-theme>` attribute + the View-Transitions cross-fade, and persists under MUI's **`mui-mode`** localStorage key (the same key MUI reads on mount). Do NOT add a second theme key — that desyncs page vs toggle on refresh.
|
||||
- **Dialogs** lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroller); Modal/ConfirmDialog freeze title/label/loading through the close fade so nothing flashes. Login renders OUTSIDE AppShell.
|
||||
|
||||
**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`.
|
||||
|
||||
---
|
||||
|
||||
## Database Conventions
|
||||
|
||||
- All models use `snake_case` column names; Prisma maps to camelCase in TypeScript.
|
||||
|
||||
@@ -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.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Urbanist:wght@300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
|
||||
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",
|
||||
"version": "1.9.2",
|
||||
"version": "2.1.5",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
@@ -28,15 +28,20 @@
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@mui/material": "^7.3.11",
|
||||
"@mui/x-date-pickers": "^8.29.0",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"@types/jsdom": "^28.0.1",
|
||||
@@ -74,8 +79,8 @@
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Re-add the projects.create permission (removed in commit 82919d3 when manual
|
||||
-- project creation was deleted). Idempotent: re-running is a no-op.
|
||||
-- Mirrors prisma/seed.ts PERMISSIONS exactly (name, display_name, module, description).
|
||||
|
||||
-- 1. Insert the permission (INSERT IGNORE skips on duplicate name).
|
||||
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
|
||||
('projects.create', 'Vytvořit projekt', 'projects', 'Ručně vytvářet projekty', NOW());
|
||||
|
||||
-- 2. Grant it to the admin role (idempotent via composite PK).
|
||||
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
|
||||
SELECT r.id, p.id
|
||||
FROM `roles` r
|
||||
CROSS JOIN `permissions` p
|
||||
WHERE r.name = 'admin'
|
||||
AND p.name IN ('projects.create');
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
model ai_usage {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int?
|
||||
kind String @db.VarChar(20)
|
||||
model String @db.VarChar(50)
|
||||
input_tokens Int @default(0)
|
||||
output_tokens Int @default(0)
|
||||
cost_usd Decimal @default(0) @db.Decimal(10, 6)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
|
||||
@@index([created_at], map: "idx_ai_usage_created")
|
||||
}
|
||||
|
||||
model ai_conversations {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
title String @db.VarChar(255)
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
updated_at DateTime @default(now()) @updatedAt @db.Timestamp(0)
|
||||
messages ai_chat_messages[]
|
||||
|
||||
@@index([user_id, id], map: "idx_ai_conv_user")
|
||||
}
|
||||
|
||||
model ai_chat_messages {
|
||||
id Int @id @default(autoincrement())
|
||||
user_id Int
|
||||
conversation_id Int
|
||||
role String @db.VarChar(20)
|
||||
content String @db.Text
|
||||
created_at DateTime @default(now()) @db.Timestamp(0)
|
||||
conversation ai_conversations @relation(fields: [conversation_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([conversation_id, id], map: "idx_ai_chat_conv")
|
||||
}
|
||||
|
||||
model bank_accounts {
|
||||
id Int @id @default(autoincrement())
|
||||
account_name String? @db.VarChar(255)
|
||||
@@ -134,6 +170,7 @@ model company_settings {
|
||||
warehouse_issue_number_pattern String? @db.VarChar(100)
|
||||
warehouse_inventory_prefix String? @db.VarChar(20)
|
||||
warehouse_inventory_number_pattern String? @db.VarChar(100)
|
||||
ai_monthly_budget_usd Decimal? @default(50.00) @db.Decimal(10, 2)
|
||||
}
|
||||
|
||||
model customers {
|
||||
|
||||
@@ -172,6 +172,12 @@ const PERMISSIONS: {
|
||||
module: "projects",
|
||||
description: "Prohlížet seznam projektů",
|
||||
},
|
||||
{
|
||||
name: "projects.create",
|
||||
display_name: "Vytvořit projekt",
|
||||
module: "projects",
|
||||
description: "Ručně vytvářet projekty",
|
||||
},
|
||||
{
|
||||
name: "projects.edit",
|
||||
display_name: "Upravit projekt",
|
||||
|
||||
20
src/App.tsx
20
src/App.tsx
@@ -2,7 +2,13 @@ import { Suspense } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import AdminApp from "./admin/AdminApp";
|
||||
|
||||
// Bootstrap loader: renders before MuiProvider (and its global styles) mount,
|
||||
// so it must be fully self-contained — no theme vars, no global CSS classes.
|
||||
// Background follows the persisted theme attribute set by ThemeContext.
|
||||
function AdminLoader() {
|
||||
const light =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.getAttribute("data-theme") === "light";
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -10,10 +16,20 @@ function AdminLoader() {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--bg-primary)",
|
||||
background: light ? "#f4f3f1" : "#0f0f0f",
|
||||
}}
|
||||
>
|
||||
<div className="admin-spinner" />
|
||||
<style>{"@keyframes boha-boot-spin{to{transform:rotate(360deg)}}"}</style>
|
||||
<div
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
border: "2px solid #d63031",
|
||||
borderTopColor: "transparent",
|
||||
borderRadius: "50%",
|
||||
animation: "boha-boot-spin 0.8s linear infinite",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
142
src/__tests__/manual-create.test.ts
Normal file
142
src/__tests__/manual-create.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import prisma from "../config/database";
|
||||
import { createProject } from "../services/projects.service";
|
||||
import { createOrder } from "../services/orders.service";
|
||||
|
||||
const createdProjectIds: number[] = [];
|
||||
const createdOrderIds: number[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const id of createdProjectIds)
|
||||
await prisma.projects.deleteMany({ where: { id } });
|
||||
for (const id of createdOrderIds)
|
||||
await prisma.orders.deleteMany({ where: { id } });
|
||||
createdProjectIds.length = 0;
|
||||
createdOrderIds.length = 0;
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
});
|
||||
|
||||
const baseOrder = {
|
||||
status: "prijata" as const,
|
||||
currency: "CZK",
|
||||
language: "cs",
|
||||
vat_rate: 21,
|
||||
apply_vat: true,
|
||||
exchange_rate: 1,
|
||||
};
|
||||
|
||||
describe("createOrder (manual, optional linked project)", () => {
|
||||
it("creates an order AND a project sharing one number when create_project=true", async () => {
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
scope_title: "Ruční zakázka",
|
||||
create_project: true,
|
||||
});
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
expect(res.project_id).toBeTruthy();
|
||||
const project = await prisma.projects.findUnique({
|
||||
where: { id: res.project_id! },
|
||||
});
|
||||
if (project) createdProjectIds.push(project.id);
|
||||
expect(project?.project_number).toBe(res.order_number);
|
||||
expect(project?.order_id).toBe(res.id);
|
||||
});
|
||||
|
||||
it("creates only the order when create_project=false", async () => {
|
||||
const res = await createOrder({ ...baseOrder, create_project: false });
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
expect(res.project_id).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared pool coherence (tracking)", () => {
|
||||
it("order → standalone project → order produce three distinct shared numbers", async () => {
|
||||
const o1 = await createOrder({ ...baseOrder, create_project: true });
|
||||
if ("id" in o1) {
|
||||
createdOrderIds.push(o1.id);
|
||||
if (o1.project_id) createdProjectIds.push(o1.project_id);
|
||||
}
|
||||
const p = await createProject({ name: "Mezi-projekt", status: "aktivni" });
|
||||
if ("project_number" in p) createdProjectIds.push(p.id);
|
||||
const o2 = await createOrder({ ...baseOrder, create_project: false });
|
||||
if ("id" in o2) createdOrderIds.push(o2.id);
|
||||
|
||||
const n1 = "id" in o1 ? o1.order_number : null;
|
||||
const np = "project_number" in p ? p.project_number : null;
|
||||
const n2 = "id" in o2 ? o2.order_number : null;
|
||||
expect(new Set([n1, np, n2]).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProject (manual, standalone)", () => {
|
||||
it("assigns a shared number and is not linked to an order", async () => {
|
||||
const result = await createProject({
|
||||
name: "Test projekt",
|
||||
status: "aktivni",
|
||||
});
|
||||
expect("project_number" in result).toBe(true);
|
||||
if (!("project_number" in result)) return;
|
||||
createdProjectIds.push(result.id);
|
||||
expect(typeof result.project_number).toBe("string");
|
||||
expect(result.project_number!.length).toBeGreaterThan(0);
|
||||
expect(result.order_id).toBeNull();
|
||||
});
|
||||
|
||||
it("gives consecutive standalone projects distinct numbers", async () => {
|
||||
const a = await createProject({ name: "Projekt A", status: "aktivni" });
|
||||
const b = await createProject({ name: "Projekt B", status: "aktivni" });
|
||||
if ("project_number" in a) createdProjectIds.push(a.id);
|
||||
if ("project_number" in b) createdProjectIds.push(b.id);
|
||||
expect("project_number" in a && "project_number" in b).toBe(true);
|
||||
if ("project_number" in a && "project_number" in b) {
|
||||
expect(a.project_number).not.toBe(b.project_number);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOrder with price line item + attachment", () => {
|
||||
it("stores a single line item with the entered price", async () => {
|
||||
const res = await createOrder({
|
||||
...baseOrder,
|
||||
create_project: false,
|
||||
items: [
|
||||
{
|
||||
description: "Práce",
|
||||
quantity: 1,
|
||||
unit_price: 12345,
|
||||
is_included_in_total: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
const items = await prisma.order_items.findMany({
|
||||
where: { order_id: res.id },
|
||||
});
|
||||
expect(items.length).toBe(1);
|
||||
expect(Number(items[0].unit_price)).toBe(12345);
|
||||
});
|
||||
|
||||
it("stores an uploaded PO attachment", async () => {
|
||||
const buf = Buffer.from("%PDF-1.4 fake");
|
||||
const res = await createOrder(
|
||||
{ ...baseOrder, create_project: false },
|
||||
buf,
|
||||
"po.pdf",
|
||||
);
|
||||
expect("id" in res).toBe(true);
|
||||
if (!("id" in res)) return;
|
||||
createdOrderIds.push(res.id);
|
||||
const order = await prisma.orders.findUnique({
|
||||
where: { id: res.id },
|
||||
select: { attachment_name: true, attachment_data: true },
|
||||
});
|
||||
expect(order?.attachment_name).toBe("po.pdf");
|
||||
expect(order?.attachment_data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
106
src/__tests__/nas-project-folder.test.ts
Normal file
106
src/__tests__/nas-project-folder.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { NasFileManager } from "../services/nas-file-manager";
|
||||
|
||||
/**
|
||||
* Folder auto-creation primitive used by both the manual project-create path
|
||||
* (projects.service createProject) and the order→project paths (orders.service
|
||||
* createOrder / createOrderFromQuotation). Uses an isolated temp dir via the
|
||||
* constructor seam so it is deterministic and passes even where the real NAS
|
||||
* (Z:) is not mounted.
|
||||
*/
|
||||
describe("NasFileManager.createProjectFolder", () => {
|
||||
let tmpBase: string;
|
||||
let nas: NasFileManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-proj-"));
|
||||
nas = new NasFileManager(tmpBase);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("creates a <number>_<name> folder for a new project", () => {
|
||||
const ok = nas.createProjectFolder("26710001", "Rekonstrukce haly");
|
||||
expect(ok).toBe(true);
|
||||
expect(
|
||||
fs.existsSync(path.join(tmpBase, "26710001_Rekonstrukce_haly")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent — a second call does not create a duplicate", () => {
|
||||
expect(nas.createProjectFolder("26710002", "Hala")).toBe(true);
|
||||
expect(nas.createProjectFolder("26710002", "Hala")).toBe(true);
|
||||
const matches = fs
|
||||
.readdirSync(tmpBase)
|
||||
.filter((e) => e.startsWith("26710002_"));
|
||||
expect(matches).toEqual(["26710002_Hala"]);
|
||||
});
|
||||
|
||||
it("preserves Czech diacritics (no transliteration)", () => {
|
||||
expect(nas.createProjectFolder("26710003", "Měření haly")).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710003_Měření_haly"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("strips illegal characters and collapses spaces", () => {
|
||||
expect(nas.createProjectFolder("26710004", "A / B: C")).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710004_A_B_C"))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles an empty name without crashing (trailing underscore)", () => {
|
||||
expect(nas.createProjectFolder("26710005", "")).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710005_"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false and creates nothing when the NAS base is not mounted", () => {
|
||||
const missing = new NasFileManager(
|
||||
path.join(tmpBase, "does-not-exist-subdir"),
|
||||
);
|
||||
expect(missing.createProjectFolder("26710006", "X")).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmpBase, "does-not-exist-subdir"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Folder deletion primitive used by the "delete folder" checkbox on both the
|
||||
* order delete (orders.service deleteOrder) and project delete (projects.service
|
||||
* deleteProject) paths. Matches the project by number prefix.
|
||||
*/
|
||||
describe("NasFileManager.deleteProjectFolder", () => {
|
||||
let tmpBase: string;
|
||||
let nas: NasFileManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "nas-del-"));
|
||||
nas = new NasFileManager(tmpBase);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpBase, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("removes an existing project folder (matched by number prefix)", async () => {
|
||||
nas.createProjectFolder("26710010", "Hala");
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710010_Hala"))).toBe(true);
|
||||
const ok = await nas.deleteProjectFolder("26710010");
|
||||
expect(ok).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpBase, "26710010_Hala"))).toBe(false);
|
||||
});
|
||||
|
||||
it("is a no-op (returns true) when no folder exists for the number", async () => {
|
||||
expect(await nas.deleteProjectFolder("26710011")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the NAS base is not mounted", async () => {
|
||||
const missing = new NasFileManager(path.join(tmpBase, "nope"));
|
||||
expect(await missing.deleteProjectFolder("26710012")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
generateSharedNumber,
|
||||
generateOfferNumber,
|
||||
previewSharedNumber,
|
||||
isSharedNumberTaken,
|
||||
previewOfferNumber,
|
||||
isOfferNumberTaken,
|
||||
} from "../services/numbering.service";
|
||||
import prisma from "../config/database";
|
||||
|
||||
@@ -48,3 +52,83 @@ describe("generateOfferNumber", () => {
|
||||
expect(num1).not.toBe(num2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewSharedNumber", () => {
|
||||
let createdProjectId: number | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (createdProjectId !== null) {
|
||||
await prisma.projects
|
||||
.delete({ where: { id: createdProjectId } })
|
||||
.catch(() => {});
|
||||
createdProjectId = null;
|
||||
}
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "shared" } });
|
||||
});
|
||||
|
||||
it("returns a number not already used by an order or project", async () => {
|
||||
const preview = await previewSharedNumber();
|
||||
expect(typeof preview).toBe("string");
|
||||
expect(await isSharedNumberTaken(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||
// Fresh counter → preview points at the first sequence number. With the
|
||||
// skip-taken logic this is guaranteed free, so we can safely claim it.
|
||||
const first = await previewSharedNumber();
|
||||
const project = await prisma.projects.create({
|
||||
data: { project_number: first, name: "test-preview-skip-taken" },
|
||||
});
|
||||
createdProjectId = project.id;
|
||||
|
||||
// The counter still lags behind (no row was consumed), but `first` is now
|
||||
// taken — the preview must advance past it instead of re-offering it.
|
||||
const second = await previewSharedNumber();
|
||||
expect(second).not.toBe(first);
|
||||
expect(await isSharedNumberTaken(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewOfferNumber", () => {
|
||||
let createdQuotationId: number | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (createdQuotationId !== null) {
|
||||
await prisma.quotations
|
||||
.delete({ where: { id: createdQuotationId } })
|
||||
.catch(() => {});
|
||||
createdQuotationId = null;
|
||||
}
|
||||
await prisma.number_sequences.deleteMany({ where: { type: "offer" } });
|
||||
});
|
||||
|
||||
it("returns a number not already used by a quotation", async () => {
|
||||
const preview = await previewOfferNumber();
|
||||
expect(typeof preview).toBe("string");
|
||||
expect(await isOfferNumberTaken(preview)).toBe(false);
|
||||
});
|
||||
|
||||
it("skips a number already taken when the sequence counter lags behind", async () => {
|
||||
// Fresh counter → preview points at the first sequence number; with the
|
||||
// skip-taken logic it's guaranteed free, so we can claim it.
|
||||
const first = await previewOfferNumber();
|
||||
const quotation = await prisma.quotations.create({
|
||||
data: { quotation_number: first },
|
||||
});
|
||||
createdQuotationId = quotation.id;
|
||||
|
||||
// The counter still lags, but `first` is now taken — the preview must
|
||||
// advance past it instead of re-offering it.
|
||||
const second = await previewOfferNumber();
|
||||
expect(second).not.toBe(first);
|
||||
expect(await isOfferNumberTaken(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
deleteOverride,
|
||||
listEntries,
|
||||
listOverrides,
|
||||
bulkCreateEntries,
|
||||
} from "../services/plan.service";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -58,6 +59,20 @@ beforeEach(async () => {
|
||||
await prisma.work_plan_overrides.deleteMany({
|
||||
where: { note: { contains: N } },
|
||||
});
|
||||
// Also clean up empty-note rows on the specific test dates used by the
|
||||
// "allows an empty note" tests. These rows are not caught by the prefix
|
||||
// filter above, and would cause the per-cell cap check to reject them
|
||||
// once three accumulate across runs.
|
||||
await prisma.work_plan_entries.deleteMany({
|
||||
where: {
|
||||
date_from: {
|
||||
in: [
|
||||
new Date("2099-07-15T00:00:00.000Z"),
|
||||
new Date("2097-08-05T00:00:00.000Z"),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -75,10 +90,9 @@ afterAll(async () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.resolveCell", () => {
|
||||
it("returns null when nothing covers the date", async () => {
|
||||
// No entry, no override for (adminUserId, "2099-01-01")
|
||||
it("returns [] when nothing covers the date", async () => {
|
||||
const result = await resolveCell(adminUserId, "2099-01-01");
|
||||
expect(result).toBeNull();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the entry that covers the date", async () => {
|
||||
@@ -93,17 +107,14 @@ describe("plan.service.resolveCell", () => {
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.source).toBe("entry");
|
||||
expect(result?.note).toBe(`${N}PLC upgrade`);
|
||||
expect(result?.category).toBe("work");
|
||||
expect(result?.rangeFrom).toBe("2099-06-01");
|
||||
expect(result?.rangeTo).toBe("2099-06-10");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].source).toBe("entry");
|
||||
expect(result[0].note).toBe(`${N}PLC upgrade`);
|
||||
expect(result[0].rangeFrom).toBe("2099-06-01");
|
||||
expect(result[0].rangeTo).toBe("2099-06-10");
|
||||
});
|
||||
|
||||
it("returns the override for that day, not the entry", async () => {
|
||||
// An entry covers 2099-06-01..2099-06-10, but an override on 2099-06-05
|
||||
// takes precedence.
|
||||
it("returns overrides (entries hidden) when an override exists", async () => {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
@@ -124,10 +135,51 @@ describe("plan.service.resolveCell", () => {
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.source).toBe("override");
|
||||
expect(result?.note).toBe(`${N}Volno po noční`);
|
||||
expect(result?.category).toBe("leave");
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].source).toBe("override");
|
||||
expect(result[0].note).toBe(`${N}Volno po noční`);
|
||||
});
|
||||
|
||||
it("returns multiple additive entries newest-first", async () => {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-01"),
|
||||
date_to: new Date("2099-06-10"),
|
||||
category: "work",
|
||||
note: `${N}first`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-05"),
|
||||
date_to: new Date("2099-06-05"),
|
||||
category: "work",
|
||||
note: `${N}second`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].note).toBe(`${N}second`); // newest first
|
||||
});
|
||||
|
||||
it("caps the returned records at MAX_RECORDS_PER_CELL", async () => {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await prisma.work_plan_overrides.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
shift_date: new Date("2099-06-06"),
|
||||
category: "leave",
|
||||
note: `${N}cap${i}`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
const result = await resolveCell(adminUserId, "2099-06-06");
|
||||
expect(result.length).toBe(3);
|
||||
});
|
||||
|
||||
it("ignores soft-deleted entries and overrides", async () => {
|
||||
@@ -143,7 +195,7 @@ describe("plan.service.resolveCell", () => {
|
||||
},
|
||||
});
|
||||
const result = await resolveCell(adminUserId, "2099-06-05");
|
||||
expect(result).toBeNull();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,11 +216,11 @@ describe("plan.service.resolveGrid", () => {
|
||||
},
|
||||
});
|
||||
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-05");
|
||||
expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-04"]).toBeNull();
|
||||
expect(cells[adminUserId]["2099-06-05"]).toBeNull();
|
||||
expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-04"]).toEqual([]);
|
||||
expect(cells[adminUserId]["2099-06-05"]).toEqual([]);
|
||||
});
|
||||
|
||||
it("applies override on a covered day", async () => {
|
||||
@@ -192,9 +244,78 @@ describe("plan.service.resolveGrid", () => {
|
||||
},
|
||||
});
|
||||
const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-03");
|
||||
expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}B`);
|
||||
expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-01"][0]?.note).toBe(`${N}A`);
|
||||
expect(cells[adminUserId]["2099-06-02"][0]?.note).toBe(`${N}B`);
|
||||
expect(cells[adminUserId]["2099-06-03"][0]?.note).toBe(`${N}A`);
|
||||
});
|
||||
|
||||
it("stacks additive entries newest-first and caps the day at MAX_RECORDS_PER_CELL", async () => {
|
||||
// Two entries cover 2099-06-12 → both appear, newest-first. A 3rd entry
|
||||
// covering the same day and a 4th overlapping one push past the cap, so the
|
||||
// day must still return exactly 3 (mirrors resolveCell's cap behaviour).
|
||||
//
|
||||
// created_at is @db.Timestamp(0) (second precision, MySQL TIMESTAMP range
|
||||
// tops out at 2038), so rows created in the same second tie on the
|
||||
// `created_at desc` sort. We set explicit, distinct in-range created_at
|
||||
// values so the newest-first assertion is deterministic.
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-11"),
|
||||
date_to: new Date("2099-06-12"),
|
||||
category: "work",
|
||||
note: `${N}g1`,
|
||||
created_by: adminUserId,
|
||||
created_at: new Date("2037-06-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-12"),
|
||||
date_to: new Date("2099-06-12"),
|
||||
category: "work",
|
||||
note: `${N}g2`,
|
||||
created_by: adminUserId,
|
||||
created_at: new Date("2037-06-01T09:00:00.000Z"), // newer than g1
|
||||
},
|
||||
});
|
||||
// Two-entry day: assert length 2 and newest-first ordering.
|
||||
const twoDay = await resolveGrid([adminUserId], "2099-06-11", "2099-06-12");
|
||||
expect(twoDay[adminUserId]["2099-06-12"].length).toBe(2);
|
||||
expect(twoDay[adminUserId]["2099-06-12"][0].note).toBe(`${N}g2`); // newest first
|
||||
// Day with no second entry stays single.
|
||||
expect(twoDay[adminUserId]["2099-06-11"].length).toBe(1);
|
||||
|
||||
// Add a 3rd and a 4th overlapping entry on 2099-06-12 to exceed the cap.
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-12"),
|
||||
date_to: new Date("2099-06-12"),
|
||||
category: "work",
|
||||
note: `${N}g3`,
|
||||
created_by: adminUserId,
|
||||
created_at: new Date("2037-06-01T10:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2099-06-12"),
|
||||
date_to: new Date("2099-06-12"),
|
||||
category: "work",
|
||||
note: `${N}g4`,
|
||||
created_by: adminUserId,
|
||||
created_at: new Date("2037-06-01T11:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
const cappedDay = await resolveGrid(
|
||||
[adminUserId],
|
||||
"2099-06-12",
|
||||
"2099-06-12",
|
||||
);
|
||||
expect(cappedDay[adminUserId]["2099-06-12"].length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,6 +462,36 @@ describe("plan.service.createEntry", () => {
|
||||
expect("error" in result).toBe(true);
|
||||
if ("error" in result) expect(result.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects a 4th entry covering a day already at the cap", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-07-20",
|
||||
date_to: "2099-07-20",
|
||||
category: "work",
|
||||
note: `${N}e${i}`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in r).toBe(true);
|
||||
}
|
||||
const fourth = await createEntry(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
date_from: "2099-07-20",
|
||||
date_to: "2099-07-20",
|
||||
category: "work",
|
||||
note: `${N}e3`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("error" in fourth).toBe(true);
|
||||
if ("error" in fourth) expect(fourth.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -439,7 +590,7 @@ describe("plan.service.deleteEntry", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.createOverride", () => {
|
||||
it("creates an override and returns { data, oldData: null, replacedData: null }", async () => {
|
||||
it("creates an override and returns { data, oldData: null }", async () => {
|
||||
const result = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
@@ -454,42 +605,45 @@ describe("plan.service.createOverride", () => {
|
||||
if ("data" in result) {
|
||||
expect(result.data.note).toBe(`${N}day off`);
|
||||
expect(result.oldData).toBeNull();
|
||||
expect(result.replacedData).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("soft-deletes the existing override and reports it in replacedData", async () => {
|
||||
await createOverride(
|
||||
it("stacks additive overrides and caps at MAX_RECORDS_PER_CELL", async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-10-02",
|
||||
category: "leave",
|
||||
note: `${N}o${i}`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in r).toBe(true);
|
||||
}
|
||||
// A 4th record on the same day is rejected by the cap.
|
||||
const fourth = await createOverride(
|
||||
{
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-10-02",
|
||||
category: "leave",
|
||||
note: `${N}first`,
|
||||
note: `${N}o3`,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
const second = await createOverride(
|
||||
{
|
||||
expect("error" in fourth).toBe(true);
|
||||
if ("error" in fourth) expect(fourth.status).toBe(400);
|
||||
// All three earlier overrides remain active — no replace happened.
|
||||
const active = await prisma.work_plan_overrides.findMany({
|
||||
where: {
|
||||
user_id: adminUserId,
|
||||
shift_date: "2099-10-02",
|
||||
category: "sick",
|
||||
note: `${N}second`,
|
||||
shift_date: new Date("2099-10-02"),
|
||||
is_deleted: false,
|
||||
},
|
||||
adminUserId,
|
||||
false,
|
||||
);
|
||||
expect("data" in second).toBe(true);
|
||||
if ("data" in second) {
|
||||
expect((second.replacedData as any)?.note).toBe(`${N}first`);
|
||||
}
|
||||
|
||||
const all = await prisma.work_plan_overrides.findMany({
|
||||
where: { user_id: adminUserId, shift_date: new Date("2099-10-02") },
|
||||
});
|
||||
// The first should be soft-deleted, the second active
|
||||
expect(all.find((o) => o.note === `${N}first`)?.is_deleted).toBe(true);
|
||||
expect(all.find((o) => o.note === `${N}second`)?.is_deleted).toBe(false);
|
||||
expect(active.length).toBe(3);
|
||||
});
|
||||
|
||||
it("rejects a past date without force", async () => {
|
||||
@@ -635,6 +789,184 @@ describe("plan.service.listOverrides (employee scoping)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bulkCreateEntries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plan.service.bulkCreateEntries", () => {
|
||||
it("creates one continuous range per employee when weekends are included", async () => {
|
||||
// 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one
|
||||
// range 06-01..06-03 covering all 3 days.
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-06-01",
|
||||
date_to: "2096-06-03",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}bulk-wknd`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("data" in res).toBe(true);
|
||||
if (!("data" in res)) return;
|
||||
expect(res.data.created_entries).toBe(1);
|
||||
expect(res.data.created_days).toBe(3);
|
||||
expect(res.data.skipped_days).toBe(0);
|
||||
expect(res.data.users).toBe(1);
|
||||
});
|
||||
|
||||
it("splits into per-work-week ranges and skips weekends when excluded", async () => {
|
||||
// 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue.
|
||||
// Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped.
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-06-01",
|
||||
date_to: "2096-06-05",
|
||||
include_weekends: false,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}bulk-week`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("data" in res).toBe(true);
|
||||
if (!("data" in res)) return;
|
||||
expect(res.data.created_entries).toBe(2);
|
||||
expect(res.data.created_days).toBe(3);
|
||||
expect(res.data.skipped_days).toBe(0);
|
||||
});
|
||||
|
||||
it("skips days already at the cap and splits the range around them", async () => {
|
||||
// Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on
|
||||
// bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips
|
||||
// 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await prisma.work_plan_entries.create({
|
||||
data: {
|
||||
user_id: adminUserId,
|
||||
date_from: new Date("2096-07-02"),
|
||||
date_to: new Date("2096-07-02"),
|
||||
category: "work",
|
||||
note: `${N}cap${i}`,
|
||||
created_by: adminUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-07-01",
|
||||
date_to: "2096-07-03",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}bulk-cap`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("data" in res).toBe(true);
|
||||
if (!("data" in res)) return;
|
||||
expect(res.data.created_entries).toBe(2);
|
||||
expect(res.data.created_days).toBe(2);
|
||||
expect(res.data.skipped_days).toBe(1);
|
||||
});
|
||||
|
||||
it("aggregates across multiple employees", async () => {
|
||||
const res = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId, noPermUserId],
|
||||
date_from: "2096-08-03", // Friday
|
||||
date_to: "2096-08-03",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}bulk-multi`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("data" in res).toBe(true);
|
||||
if (!("data" in res)) return;
|
||||
expect(res.data.users).toBe(2);
|
||||
expect(res.data.created_entries).toBe(2);
|
||||
expect(res.data.created_days).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects empty user_ids, past dates, bad range, and inactive category", async () => {
|
||||
const empty = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [],
|
||||
date_from: "2096-06-01",
|
||||
date_to: "2096-06-01",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in empty && empty.status).toBe(400);
|
||||
|
||||
const tooLong = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-01-01",
|
||||
date_to: "2096-12-31", // > 92 days
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in tooLong && tooLong.status).toBe(400);
|
||||
|
||||
const past = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2000-01-01",
|
||||
date_to: "2000-01-02",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in past && past.status).toBe(403);
|
||||
|
||||
const badRange = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-06-10",
|
||||
date_to: "2096-06-01",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in badRange && badRange.status).toBe(400);
|
||||
|
||||
const badCat = await bulkCreateEntries(
|
||||
{
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2096-06-01",
|
||||
date_to: "2096-06-01",
|
||||
include_weekends: true,
|
||||
project_id: null,
|
||||
category: "__nope__",
|
||||
note: `${N}x`,
|
||||
},
|
||||
adminUserId,
|
||||
);
|
||||
expect("error" in badCat && badCat.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP route tests (Fastify inject)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -810,7 +1142,7 @@ describe("HTTP /api/admin/plan", () => {
|
||||
const body = res.json();
|
||||
expect(body.data.users).toBeDefined();
|
||||
expect(body.data.cells[adminUserId]).toBeDefined();
|
||||
expect(body.data.cells[adminUserId]["2097-06-01"].note).toBe(
|
||||
expect(body.data.cells[adminUserId]["2097-06-01"][0].note).toBe(
|
||||
`${N}grid test`,
|
||||
);
|
||||
});
|
||||
@@ -883,4 +1215,32 @@ describe("HTTP /api/admin/plan", () => {
|
||||
);
|
||||
expect(res.statusCode).toBe(201);
|
||||
});
|
||||
|
||||
it("POST /entries/bulk requires attendance.manage", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, {
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2095-06-01",
|
||||
date_to: "2095-06-01",
|
||||
include_weekends: true,
|
||||
category: "work",
|
||||
note: `${N}bulk-forbidden`,
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("POST /entries/bulk creates entries for an admin and returns a summary", async () => {
|
||||
const res = await authPost("/api/admin/plan/entries/bulk", adminToken, {
|
||||
user_ids: [adminUserId],
|
||||
date_from: "2095-06-05", // Sunday; weekends on → 1 day
|
||||
date_to: "2095-06-05",
|
||||
include_weekends: true,
|
||||
category: "work",
|
||||
note: `${N}bulk-http`,
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.created_days).toBe(1);
|
||||
expect(body.data.users).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
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 { queryClient } from "./lib/queryClient";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import AppShell from "./ui/AppShell";
|
||||
import AlertContainer from "./components/AlertContainer";
|
||||
import MuiProvider from "./ui/MuiProvider";
|
||||
import { LoadingState } from "./ui";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import "./variables.css";
|
||||
import "./base.css";
|
||||
import "./forms.css";
|
||||
import "./buttons.css";
|
||||
import "./layout.css";
|
||||
import "./components.css";
|
||||
import "./tables.css";
|
||||
import "./datepicker.css";
|
||||
import "./filemanager.css";
|
||||
import "./pagination.css";
|
||||
import "./responsive.css";
|
||||
import "./login.css";
|
||||
import "./dashboard.css";
|
||||
import "./attendance.css";
|
||||
import "./plan.css";
|
||||
import "./settings.css";
|
||||
import "./offers.css";
|
||||
import "./invoices.css";
|
||||
import "./warehouse.css";
|
||||
|
||||
const Odin = lazy(() => import("./pages/Odin"));
|
||||
const Users = lazy(() => import("./pages/Users"));
|
||||
const Attendance = lazy(() => import("./pages/Attendance"));
|
||||
const AttendanceHistory = lazy(() => import("./pages/AttendanceHistory"));
|
||||
@@ -81,157 +65,161 @@ const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
|
||||
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
|
||||
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
|
||||
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
|
||||
const UiKit = import.meta.env.DEV ? lazy(() => import("./pages/UiKit")) : null;
|
||||
|
||||
export default function AdminApp() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AlertProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertContainer />
|
||||
<ErrorBoundary>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="attendance" element={<Attendance />} />
|
||||
<Route
|
||||
path="attendance/history"
|
||||
element={<AttendanceHistory />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/admin"
|
||||
element={<AttendanceAdmin />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/balances"
|
||||
element={<AttendanceBalances />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/requests"
|
||||
element={<LeaveRequests />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/approval"
|
||||
element={<LeaveApproval />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/create"
|
||||
element={<AttendanceCreate />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/location/:id"
|
||||
element={<AttendanceLocation />}
|
||||
/>
|
||||
<Route path="plan-work" element={<PlanWork />} />
|
||||
<Route path="trips" element={<Trips />} />
|
||||
<Route path="trips/history" element={<TripsHistory />} />
|
||||
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||
<Route path="vehicles" element={<Vehicles />} />
|
||||
<Route path="offers" element={<Offers />} />
|
||||
<Route path="offers/new" element={<OfferDetail />} />
|
||||
<Route path="offers/:id" element={<OfferDetail />} />
|
||||
<Route
|
||||
path="offers/customers"
|
||||
element={<OffersCustomers />}
|
||||
/>
|
||||
<Route
|
||||
path="offers/templates"
|
||||
element={<OffersTemplates />}
|
||||
/>
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="orders/:id" element={<OrderDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="invoices" element={<Invoices />} />
|
||||
<Route path="invoices/new" element={<InvoiceDetail />} />
|
||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="audit-log" element={<AuditLog />} />
|
||||
<Route path="warehouse" element={<Warehouse />} />
|
||||
<Route path="warehouse/items" element={<WarehouseItems />} />
|
||||
<Route
|
||||
path="warehouse/items/:id"
|
||||
element={<WarehouseItemDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts"
|
||||
element={<WarehouseReceipts />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/new"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id"
|
||||
element={<WarehouseReceiptDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id/edit"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues"
|
||||
element={<WarehouseIssues />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/new"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id"
|
||||
element={<WarehouseIssueDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id/edit"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reservations"
|
||||
element={<WarehouseReservations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory"
|
||||
element={<WarehouseInventory />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/new"
|
||||
element={<WarehouseInventoryForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/:id"
|
||||
element={<WarehouseInventoryDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reports"
|
||||
element={<WarehouseReports />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/suppliers"
|
||||
element={<WarehouseSuppliers />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/locations"
|
||||
element={<WarehouseLocations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/categories"
|
||||
element={<WarehouseCategories />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</AlertProvider>
|
||||
</AuthProvider>
|
||||
<MuiProvider>
|
||||
<AuthProvider>
|
||||
<AlertProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AlertContainer />
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<Routes>
|
||||
<Route path="login" element={<Login />} />
|
||||
{import.meta.env.DEV && UiKit && (
|
||||
<Route path="ui-kit" element={<UiKit />} />
|
||||
)}
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="odin" element={<Odin />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="attendance" element={<Attendance />} />
|
||||
<Route
|
||||
path="attendance/history"
|
||||
element={<AttendanceHistory />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/admin"
|
||||
element={<AttendanceAdmin />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/balances"
|
||||
element={<AttendanceBalances />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/requests"
|
||||
element={<LeaveRequests />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/approval"
|
||||
element={<LeaveApproval />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/create"
|
||||
element={<AttendanceCreate />}
|
||||
/>
|
||||
<Route
|
||||
path="attendance/location/:id"
|
||||
element={<AttendanceLocation />}
|
||||
/>
|
||||
<Route path="plan-work" element={<PlanWork />} />
|
||||
<Route path="trips" element={<Trips />} />
|
||||
<Route path="trips/history" element={<TripsHistory />} />
|
||||
<Route path="trips/admin" element={<TripsAdmin />} />
|
||||
<Route path="vehicles" element={<Vehicles />} />
|
||||
<Route path="offers" element={<Offers />} />
|
||||
<Route path="offers/new" element={<OfferDetail />} />
|
||||
<Route path="offers/:id" element={<OfferDetail />} />
|
||||
<Route
|
||||
path="offers/customers"
|
||||
element={<OffersCustomers />}
|
||||
/>
|
||||
<Route
|
||||
path="offers/templates"
|
||||
element={<OffersTemplates />}
|
||||
/>
|
||||
<Route path="orders" element={<Orders />} />
|
||||
<Route path="orders/:id" element={<OrderDetail />} />
|
||||
<Route path="projects" element={<Projects />} />
|
||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||
<Route path="invoices" element={<Invoices />} />
|
||||
<Route path="invoices/new" element={<InvoiceDetail />} />
|
||||
<Route path="invoices/:id" element={<InvoiceDetail />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="audit-log" element={<AuditLog />} />
|
||||
<Route path="warehouse" element={<Warehouse />} />
|
||||
<Route
|
||||
path="warehouse/items"
|
||||
element={<WarehouseItems />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/items/:id"
|
||||
element={<WarehouseItemDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts"
|
||||
element={<WarehouseReceipts />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/new"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id"
|
||||
element={<WarehouseReceiptDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/receipts/:id/edit"
|
||||
element={<WarehouseReceiptForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues"
|
||||
element={<WarehouseIssues />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/new"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id"
|
||||
element={<WarehouseIssueDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/issues/:id/edit"
|
||||
element={<WarehouseIssueForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reservations"
|
||||
element={<WarehouseReservations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory"
|
||||
element={<WarehouseInventory />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/new"
|
||||
element={<WarehouseInventoryForm />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/inventory/:id"
|
||||
element={<WarehouseInventoryDetail />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/reports"
|
||||
element={<WarehouseReports />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/suppliers"
|
||||
element={<WarehouseSuppliers />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/locations"
|
||||
element={<WarehouseLocations />}
|
||||
/>
|
||||
<Route
|
||||
path="warehouse/categories"
|
||||
element={<WarehouseCategories />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
</AlertProvider>
|
||||
</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 { motion, AnimatePresence } from "framer-motion";
|
||||
import Snackbar from "@mui/material/Snackbar";
|
||||
import MuiAlert from "@mui/material/Alert";
|
||||
import { useAlertState } from "../context/AlertContext";
|
||||
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
success: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function AlertContainer() {
|
||||
const { alerts, removeAlert } = useAlertState();
|
||||
|
||||
return (
|
||||
<div className="admin-alert-container" role="status" aria-live="polite">
|
||||
<AnimatePresence>
|
||||
{alerts.map((alert) => (
|
||||
<motion.div
|
||||
key={alert.id}
|
||||
className={`admin-toast admin-toast-${alert.type}`}
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
<>
|
||||
{alerts.map((alert, index) => (
|
||||
<Snackbar
|
||||
key={alert.id}
|
||||
open
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
sx={{ bottom: { xs: 16 + index * 72 } }}
|
||||
>
|
||||
<MuiAlert
|
||||
severity={alert.type}
|
||||
onClose={() => removeAlert(alert.id)}
|
||||
variant="filled"
|
||||
sx={{ width: "100%", minWidth: 280 }}
|
||||
>
|
||||
<span className="admin-toast-icon">{icons[alert.type]}</span>
|
||||
<span className="admin-toast-message">{alert.message}</span>
|
||||
<button
|
||||
className="admin-toast-close"
|
||||
onClick={() => removeAlert(alert.id)}
|
||||
aria-label="Zavřít"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{alert.message}
|
||||
</MuiAlert>
|
||||
</Snackbar>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import {
|
||||
formatDate,
|
||||
formatDatetime,
|
||||
@@ -6,8 +8,8 @@ import {
|
||||
calculateWorkMinutes,
|
||||
formatMinutes,
|
||||
getLeaveTypeName,
|
||||
getLeaveTypeBadgeClass,
|
||||
} from "../utils/attendanceHelpers";
|
||||
import { DataTable, StatusChip, EmptyState, type DataColumn } from "../ui";
|
||||
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
|
||||
|
||||
interface AttendanceRecord extends HookAttendanceRecord {
|
||||
@@ -23,6 +25,56 @@ interface AttendanceShiftTableProps {
|
||||
onDelete: (record: AttendanceRecord) => void;
|
||||
}
|
||||
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DeleteIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** Map an attendance leave_type to a StatusChip semantic color (mirrors the
|
||||
* legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue),
|
||||
* sick→error(red), unpaid→default(gray). */
|
||||
function leaveTypeChipColor(
|
||||
type: string,
|
||||
): "default" | "success" | "error" | "warning" | "info" {
|
||||
switch (type) {
|
||||
case "vacation":
|
||||
return "info";
|
||||
case "sick":
|
||||
return "error";
|
||||
case "unpaid":
|
||||
return "default";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
|
||||
function formatBreak(record: AttendanceRecord): string {
|
||||
if (record.break_start && record.break_end) {
|
||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
||||
@@ -36,9 +88,7 @@ function formatBreak(record: AttendanceRecord): string {
|
||||
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
if (record.project_logs && record.project_logs.length > 0) {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
|
||||
{record.project_logs.map((log, i) => {
|
||||
let h: number,
|
||||
m: number,
|
||||
@@ -65,33 +115,32 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span
|
||||
<StatusChip
|
||||
key={log.id ?? i}
|
||||
className="admin-badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
display: "inline-block",
|
||||
background: isActive ? "var(--accent-light)" : undefined,
|
||||
}}
|
||||
>
|
||||
{log.project_name || `#${log.project_id}`}{" "}
|
||||
{durationValid
|
||||
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
||||
: "—"}
|
||||
</span>
|
||||
color={isActive ? "info" : "default"}
|
||||
label={`${log.project_name || `#${log.project_id}`} ${
|
||||
durationValid
|
||||
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
||||
: "—"
|
||||
}`}
|
||||
sx={{ fontSize: "0.7rem", maxWidth: "100%" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (record.project_name) {
|
||||
return (
|
||||
<span
|
||||
className="admin-badge admin-badge-wrap"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
{record.project_name}
|
||||
</span>
|
||||
<StatusChip
|
||||
color="default"
|
||||
label={record.project_name}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
height: "auto",
|
||||
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return "—";
|
||||
@@ -102,141 +151,164 @@ export default function AttendanceShiftTable({
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: AttendanceShiftTableProps) {
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
<div className="admin-empty-state">
|
||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<StatusChip
|
||||
color={leaveTypeChipColor(leaveType)}
|
||||
label={getLeaveTypeName(leaveType)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "arrival",
|
||||
header: "Příchod",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatDatetime(record.arrival_time);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "break",
|
||||
header: "Pauza",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatBreak(record);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "departure",
|
||||
header: "Odchod",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatDatetime(record.departure_time);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "hours",
|
||||
header: "Hodiny",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (record.leave_hours != null ? Number(record.leave_hours) : 8) * 60
|
||||
: calculateWorkMinutes({
|
||||
...record,
|
||||
notes: record.notes ?? undefined,
|
||||
});
|
||||
return workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "project",
|
||||
header: "Projekt",
|
||||
width: "13%",
|
||||
render: (record) => renderProjectCell(record),
|
||||
},
|
||||
{
|
||||
key: "gps",
|
||||
header: "GPS",
|
||||
width: "5%",
|
||||
render: (record) => {
|
||||
const hasLocation =
|
||||
(record.arrival_lat && record.arrival_lng) ||
|
||||
(record.departure_lat && record.departure_lng);
|
||||
return hasLocation ? (
|
||||
<Box
|
||||
component={RouterLink}
|
||||
to={`/attendance/location/${record.id}`}
|
||||
title="Zobrazit polohu"
|
||||
aria-label="Zobrazit polohu"
|
||||
sx={{ textDecoration: "none" }}
|
||||
>
|
||||
<span aria-hidden="true">{"📍"}</span>
|
||||
</Box>
|
||||
) : (
|
||||
"—"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "notes",
|
||||
header: "Poznámka",
|
||||
width: "9%",
|
||||
render: (record) => (
|
||||
<Box
|
||||
component="span"
|
||||
title={record.notes || ""}
|
||||
sx={{
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: "8%",
|
||||
align: "right",
|
||||
render: (record) => (
|
||||
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onEdit(record)}
|
||||
aria-label="Upravit"
|
||||
title="Upravit"
|
||||
>
|
||||
{EditIcon}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => onDelete(record)}
|
||||
aria-label="Smazat"
|
||||
title="Smazat"
|
||||
>
|
||||
{DeleteIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Zaměstnanec</th>
|
||||
<th>Typ</th>
|
||||
<th>Příchod</th>
|
||||
<th>Pauza</th>
|
||||
<th>Odchod</th>
|
||||
<th>Hodiny</th>
|
||||
<th>Projekt</th>
|
||||
<th>GPS</th>
|
||||
<th>Poznámka</th>
|
||||
<th>Akce</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
|
||||
60
|
||||
: calculateWorkMinutes({
|
||||
...record,
|
||||
notes: record.notes ?? undefined,
|
||||
});
|
||||
const hasLocation =
|
||||
(record.arrival_lat && record.arrival_lng) ||
|
||||
(record.departure_lat && record.departure_lng);
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td className="admin-mono">{formatDate(record.shift_date)}</td>
|
||||
<td>{record.user_name}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||
>
|
||||
{getLeaveTypeName(leaveType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatDatetime(record.arrival_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatBreak(record)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatDatetime(record.departure_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
|
||||
</td>
|
||||
<td>{renderProjectCell(record)}</td>
|
||||
<td>
|
||||
{hasLocation ? (
|
||||
<Link
|
||||
to={`/attendance/location/${record.id}`}
|
||||
className="attendance-gps-link"
|
||||
title="Zobrazit polohu"
|
||||
aria-label="Zobrazit polohu"
|
||||
>
|
||||
<span aria-hidden="true">{"📍"}</span>
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
maxWidth: "100px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={record.notes || ""}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-table-actions">
|
||||
<button
|
||||
onClick={() => onEdit(record)}
|
||||
className="admin-btn-icon"
|
||||
title="Upravit"
|
||||
aria-label="Upravit"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(record)}
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataTable<AttendanceRecord>
|
||||
columns={columns}
|
||||
rows={records}
|
||||
rowKey={(record) => record.id}
|
||||
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Modal, MonthField, TimeField, Field, CheckboxField } from "../ui";
|
||||
|
||||
interface BulkAttendanceForm {
|
||||
month: string;
|
||||
@@ -39,182 +39,137 @@ export default function BulkAttendanceModal({
|
||||
toggleUser,
|
||||
toggleAllUsers,
|
||||
}: BulkAttendanceModalProps) {
|
||||
useModalLock(isOpen);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Vyplnit docházku za měsíc"
|
||||
maxWidth="lg"
|
||||
loading={submitting}
|
||||
onSubmit={onSubmit}
|
||||
submitText="Vyplnit měsíc"
|
||||
submitDisabled={form.user_ids.length === 0}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 0.25, mb: 2 }}
|
||||
>
|
||||
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
|
||||
Existující záznamy se přeskočí.
|
||||
</Typography>
|
||||
|
||||
<Field label="Měsíc">
|
||||
<MonthField
|
||||
value={form.month}
|
||||
onChange={(val) => setForm({ ...form, month: val })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => !submitting && onClose()}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bulk-attendance-modal-title"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
<Typography
|
||||
component="span"
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2
|
||||
id="bulk-attendance-modal-title"
|
||||
className="admin-modal-title"
|
||||
>
|
||||
Vyplnit docházku za měsíc
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky
|
||||
označí. Existující záznamy se přeskočí.
|
||||
</p>
|
||||
</div>
|
||||
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((user) => (
|
||||
<CheckboxField
|
||||
key={user.id}
|
||||
label={user.name}
|
||||
checked={form.user_ids.includes(String(user.id))}
|
||||
onChange={() => toggleUser(user.id)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Vybráno: {form.user_ids.length} z {users.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Měsíc</label>
|
||||
<AdminDatePicker
|
||||
mode="month"
|
||||
value={form.month}
|
||||
onChange={(val) => setForm({ ...form, month: val })}
|
||||
/>
|
||||
</div>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Příchod">
|
||||
<TimeField
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Odchod">
|
||||
<TimeField
|
||||
value={form.departure_time}
|
||||
onChange={(val) => setForm({ ...form, departure_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Zaměstnanci
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllUsers}
|
||||
style={{
|
||||
marginLeft: "0.75rem",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--accent-color)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8125rem",
|
||||
fontWeight: 500,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
{form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</button>
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.375rem",
|
||||
maxHeight: "200px",
|
||||
overflowY: "auto",
|
||||
padding: "0.75rem",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "var(--border-radius-sm)",
|
||||
border: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<label key={user.id} className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.user_ids.includes(String(user.id))}
|
||||
onChange={() => toggleUser(user.id)}
|
||||
/>
|
||||
<span>{user.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<small className="admin-form-hint">
|
||||
Vybráno: {form.user_ids.length} z {users.length}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Příchod</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, arrival_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, departure_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Začátek pauzy</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_start_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Konec pauzy</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_end_time: val })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={submitting}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={submitting || form.user_ids.length === 0}
|
||||
>
|
||||
{submitting ? "Vytvářím záznamy..." : "Vyplnit měsíc"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Začátek pauzy">
|
||||
<TimeField
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => setForm({ ...form, break_start_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Konec pauzy">
|
||||
<TimeField
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => setForm({ ...form, break_end_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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 Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { Button } from "../ui";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -22,19 +25,25 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div
|
||||
className="admin-empty-state"
|
||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "60vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h2>Něco se pokazilo</h2>
|
||||
<p>{this.state.error?.message}</p>
|
||||
<button
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<Typography variant="h5">Něco se pokazilo</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{this.state.error?.message}
|
||||
</Typography>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Obnovit stránku
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import { EmptyState, Button } from "../ui";
|
||||
|
||||
export default function Forbidden() {
|
||||
return (
|
||||
<div
|
||||
className="admin-empty-state"
|
||||
style={{ minHeight: "60vh", justifyContent: "center" }}
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: "60vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<h2>403</h2>
|
||||
<p>Nemáte oprávnění pro přístup k této stránce.</p>
|
||||
<Link to="/" className="admin-btn admin-btn-primary">
|
||||
Zpět na Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="403"
|
||||
description="Nemáte oprávnění pro přístup k této stránce."
|
||||
action={
|
||||
<Button component={Link} to="/">
|
||||
Zpět na Dashboard
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</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 { motion, AnimatePresence } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Modal, Button, TextField, Field } from "../ui";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
|
||||
interface ConfirmationItem {
|
||||
@@ -35,6 +40,8 @@ export default function OrderConfirmationModal({
|
||||
applyVat,
|
||||
}: OrderConfirmationModalProps) {
|
||||
const alert = useAlert();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const [step, setStep] = useState<"choose" | "edit">("choose");
|
||||
const [lang, setLang] = useState<string>("cs");
|
||||
const [applyVatState, setApplyVatState] = useState(applyVat);
|
||||
@@ -103,294 +110,320 @@ export default function OrderConfirmationModal({
|
||||
}, [defaultVatRate]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||
<motion.div
|
||||
className={
|
||||
step === "edit" ? "admin-modal admin-modal-lg" : "admin-modal"
|
||||
}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="order-confirmation-modal-title"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2
|
||||
id="order-confirmation-modal-title"
|
||||
className="admin-modal-title"
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`Potvrzení objednávky ${orderNumber}`}
|
||||
maxWidth={step === "edit" ? "lg" : "md"}
|
||||
loading={loading}
|
||||
submitText={
|
||||
step === "choose" ? "Použít položky z objednávky" : "Vygenerovat PDF"
|
||||
}
|
||||
submitDisabled={step === "edit" && items.length === 0}
|
||||
onSubmit={step === "choose" ? handleUseExisting : handleEditGenerate}
|
||||
>
|
||||
{step === "choose" ? (
|
||||
<Box>
|
||||
<Field label="Jazyk dokumentu">
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant={lang === "cs" ? "contained" : "outlined"}
|
||||
color={lang === "cs" ? "primary" : "inherit"}
|
||||
onClick={() => setLang("cs")}
|
||||
>
|
||||
Potvrzení objednávky {orderNumber}
|
||||
</h2>
|
||||
</div>
|
||||
Čeština
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={lang === "en" ? "contained" : "outlined"}
|
||||
color={lang === "en" ? "primary" : "inherit"}
|
||||
onClick={() => setLang("en")}
|
||||
>
|
||||
English
|
||||
</Button>
|
||||
</Box>
|
||||
</Field>
|
||||
|
||||
<div className="admin-modal-body">
|
||||
{step === "choose" ? (
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Jazyk dokumentu</label>
|
||||
<div className="flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang("cs")}
|
||||
className={
|
||||
lang === "cs"
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
Čeština
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLang("en")}
|
||||
className={
|
||||
lang === "en"
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Field label="DPH">
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant={applyVatState ? "contained" : "outlined"}
|
||||
color={applyVatState ? "primary" : "inherit"}
|
||||
onClick={() => setApplyVatState(true)}
|
||||
>
|
||||
S DPH
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={!applyVatState ? "contained" : "outlined"}
|
||||
color={!applyVatState ? "primary" : "inherit"}
|
||||
onClick={() => setApplyVatState(false)}
|
||||
>
|
||||
Bez DPH
|
||||
</Button>
|
||||
</Box>
|
||||
</Field>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">DPH</label>
|
||||
<div className="flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApplyVatState(true)}
|
||||
className={
|
||||
applyVatState
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
S DPH
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApplyVatState(false)}
|
||||
className={
|
||||
!applyVatState
|
||||
? "admin-btn admin-btn-primary admin-btn-sm"
|
||||
: "admin-btn admin-btn-secondary admin-btn-sm"
|
||||
}
|
||||
>
|
||||
Bez DPH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Obsah potvrzení</label>
|
||||
<p
|
||||
className="text-secondary"
|
||||
style={{ marginBottom: "0.75rem" }}
|
||||
>
|
||||
Jak chcete připravit potvrzení objednávky?
|
||||
</p>
|
||||
<button
|
||||
onClick={handleUseExisting}
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-primary w-full"
|
||||
style={{ marginBottom: "0.5rem" }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Generuji...
|
||||
</>
|
||||
) : (
|
||||
"Použít položky z objednávky"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setItems(initialItems.length > 0 ? initialItems : []);
|
||||
setStep("edit");
|
||||
}}
|
||||
disabled={loading}
|
||||
className="admin-btn admin-btn-secondary w-full"
|
||||
>
|
||||
Upravit položky
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-form">
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Popis</th>
|
||||
<th>Mn.</th>
|
||||
<th>Jedn.</th>
|
||||
<th>Cena</th>
|
||||
<th>%DPH</th>
|
||||
<th style={{ width: "40px" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ minWidth: "200px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"quantity",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "80px" }}
|
||||
step="0.001"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={item.unit}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "unit", e.target.value)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "60px" }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"unit_price",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "100px" }}
|
||||
step="0.01"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"vat_rate",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
className="admin-form-input"
|
||||
style={{ width: "70px" }}
|
||||
step="1"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
className="admin-btn-icon danger"
|
||||
title="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>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
onClick={addItem}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat položku
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
{step === "edit" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("choose")}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={loading}
|
||||
>
|
||||
Zpět
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEditGenerate}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={loading || items.length === 0}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="admin-spinner admin-spinner-sm" />
|
||||
Generuji...
|
||||
</>
|
||||
) : (
|
||||
"Vygenerovat PDF"
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{step === "choose" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={loading}
|
||||
<Field label="Obsah potvrzení">
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
|
||||
Jak chcete připravit potvrzení objednávky?
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={() => {
|
||||
setItems(initialItems.length > 0 ? initialItems : []);
|
||||
setStep("edit");
|
||||
}}
|
||||
disabled={loading}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
>
|
||||
Upravit položky
|
||||
</Button>
|
||||
</Field>
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={() => setStep("choose")}
|
||||
disabled={loading}
|
||||
>
|
||||
Zpět
|
||||
</Button>
|
||||
</Box>
|
||||
{isMobile ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
{items.map((item, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: 2,
|
||||
p: 1.5,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ color: "text.secondary", fontWeight: 700 }}
|
||||
>
|
||||
Položka {i + 1}
|
||||
</Typography>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => removeItem(i)}
|
||||
title="Odstranit"
|
||||
aria-label="Odstranit"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Popis"
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Množství"
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "quantity", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "0.001" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="Jednotka"
|
||||
value={item.unit}
|
||||
onChange={(e) => updateItem(i, "unit", e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Cena"
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "unit_price", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
<TextField
|
||||
label="%DPH"
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "vat_rate", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
<Box
|
||||
component="table"
|
||||
sx={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
"& th": {
|
||||
textAlign: "left",
|
||||
fontSize: ".7rem",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: ".05em",
|
||||
fontWeight: 700,
|
||||
color: "text.secondary",
|
||||
pb: 1,
|
||||
},
|
||||
"& td": { py: 0.5, pr: 1, verticalAlign: "middle" },
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Popis</th>
|
||||
<th>Mn.</th>
|
||||
<th>Jedn.</th>
|
||||
<th>Cena</th>
|
||||
<th>%DPH</th>
|
||||
<th style={{ width: "40px" }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<TextField
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "description", e.target.value)
|
||||
}
|
||||
sx={{ minWidth: 200 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"quantity",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
sx={{ width: 90 }}
|
||||
slotProps={{ htmlInput: { step: "0.001" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
value={item.unit}
|
||||
onChange={(e) =>
|
||||
updateItem(i, "unit", e.target.value)
|
||||
}
|
||||
sx={{ width: 70 }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.unit_price}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"unit_price",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
sx={{ width: 110 }}
|
||||
slotProps={{ htmlInput: { step: "0.01" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextField
|
||||
type="number"
|
||||
value={item.vat_rate}
|
||||
onChange={(e) =>
|
||||
updateItem(
|
||||
i,
|
||||
"vat_rate",
|
||||
Number(e.target.value) || 0,
|
||||
)
|
||||
}
|
||||
sx={{ width: 80 }}
|
||||
slotProps={{ htmlInput: { step: "1" } }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
onClick={addItem}
|
||||
>
|
||||
+ Přidat položku
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 FormModal from "./FormModal";
|
||||
import { useState, type ChangeEvent, type FocusEvent } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import { Modal, TextField, Button } from "../ui";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { PlanCategory } from "../lib/queries/plan";
|
||||
@@ -91,23 +92,44 @@ export default function PlanCategoriesModal({
|
||||
const busy =
|
||||
createCat.isPending || updateCat.isPending || deleteCat.isPending;
|
||||
|
||||
// Shared layout for a category row: color swatch | text input | actions.
|
||||
const rowSx = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
py: 1,
|
||||
} as const;
|
||||
|
||||
// Native color picker, styled to match the row (was the .plan-cat-color rule).
|
||||
const colorInputSx = {
|
||||
width: 34,
|
||||
height: 34,
|
||||
p: 0,
|
||||
border: 1,
|
||||
borderColor: "divider",
|
||||
borderRadius: "6px",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
flex: "0 0 auto",
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onSubmit={onClose}
|
||||
title="Správa kategorií"
|
||||
hideFooter
|
||||
size="md"
|
||||
submitText="Zavřít"
|
||||
hideCancel
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="plan-cat-manager">
|
||||
<Box>
|
||||
{categories.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`plan-cat-row${c.is_active ? "" : " plan-cat-row--inactive"}`}
|
||||
>
|
||||
<input
|
||||
<Box key={c.id} sx={{ ...rowSx, opacity: c.is_active ? 1 : 0.55 }}>
|
||||
<Box
|
||||
component="input"
|
||||
type="color"
|
||||
className="plan-cat-color"
|
||||
sx={colorInputSx}
|
||||
// Uncontrolled + patch on blur (like the rename input): the
|
||||
// native color picker fires onChange on every drag tick, which
|
||||
// would spam PATCH requests and the audit log. onBlur commits
|
||||
@@ -115,96 +137,102 @@ export default function PlanCategoriesModal({
|
||||
defaultValue={c.color}
|
||||
key={c.color}
|
||||
disabled={busy}
|
||||
onBlur={(e) => {
|
||||
onBlur={(e: FocusEvent<HTMLInputElement>) => {
|
||||
if (e.target.value !== c.color) {
|
||||
patch(c.id, { color: e.target.value });
|
||||
}
|
||||
}}
|
||||
aria-label={`Barva – ${c.label}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
<TextField
|
||||
defaultValue={c.label}
|
||||
disabled={busy}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== c.label) patch(c.id, { label: v });
|
||||
}}
|
||||
aria-label={`Název – ${c.label}`}
|
||||
inputProps={{ "aria-label": `Název – ${c.label}` }}
|
||||
/>
|
||||
{confirmDeleteId === c.id ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-danger admin-btn-sm"
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => handleDelete(c.id)}
|
||||
>
|
||||
Opravdu smazat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => patch(c.id, { is_active: !c.is_active })}
|
||||
>
|
||||
{c.is_active ? "Skrýt" : "Obnovit"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-danger admin-btn-sm plan-cat-delete"
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmDeleteId(c.id)}
|
||||
aria-label={`Smazat – ${c.label}`}
|
||||
title="Smazat kategorii"
|
||||
>
|
||||
Smazat
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<div className="plan-cat-row plan-cat-row--new">
|
||||
<input
|
||||
<Box
|
||||
sx={{ ...rowSx, mt: 1, pt: 2, borderTop: 1, borderColor: "divider" }}
|
||||
>
|
||||
<Box
|
||||
component="input"
|
||||
type="color"
|
||||
className="plan-cat-color"
|
||||
sx={colorInputSx}
|
||||
value={newColor}
|
||||
disabled={busy}
|
||||
onChange={(e) => setNewColor(e.target.value)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setNewColor(e.target.value)
|
||||
}
|
||||
aria-label="Barva nové kategorie"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
<TextField
|
||||
placeholder="Nová kategorie…"
|
||||
value={newLabel}
|
||||
disabled={busy}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
size="small"
|
||||
disabled={busy}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
Přidat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import FormModal from "./FormModal";
|
||||
import FormField from "./FormField";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import ConfirmModal from "./ConfirmModal";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {
|
||||
Modal,
|
||||
ConfirmDialog,
|
||||
Field,
|
||||
TextField,
|
||||
Select,
|
||||
DateField,
|
||||
CheckboxField,
|
||||
Button,
|
||||
Card,
|
||||
} from "../ui";
|
||||
import useReducedMotion from "../hooks/useReducedMotion";
|
||||
import {
|
||||
ResolvedCell,
|
||||
@@ -22,6 +31,7 @@ interface Project {
|
||||
export type PlanCellModalMode =
|
||||
| { kind: "closed" }
|
||||
| { kind: "create"; userId: number; date: string }
|
||||
| { kind: "create-override"; userId: number; date: string }
|
||||
| {
|
||||
kind: "edit-range";
|
||||
entryId: number;
|
||||
@@ -55,7 +65,8 @@ export type PlanCellModalMode =
|
||||
note: string;
|
||||
};
|
||||
}
|
||||
| { kind: "view"; userId: number; date: string; cell: ResolvedCell };
|
||||
| { kind: "view"; userId: number; date: string; cell: ResolvedCell }
|
||||
| { kind: "day"; userId: number; date: string; cells: ResolvedCell[] };
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -75,14 +86,45 @@ interface Props {
|
||||
date: string,
|
||||
) => Promise<void>;
|
||||
onSwitchToEditRange: (entryId: number, userId: number, date: string) => void;
|
||||
/** Open the create form for a brand-new record on (userId, date). The
|
||||
* `source` picks the layer the new record lands in: "entry" in normal mode,
|
||||
* "override" in exception mode (a day whose shown records are overrides). */
|
||||
onAddRecord: (
|
||||
userId: number,
|
||||
date: string,
|
||||
source: "entry" | "override",
|
||||
) => void;
|
||||
/** Open the right editor for one of the day's existing records. */
|
||||
onEditRecord: (cell: ResolvedCell, userId: number, date: string) => void;
|
||||
}
|
||||
|
||||
const TrashIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function PlanCellModal(props: Props) {
|
||||
const { open, mode, onClose } = props;
|
||||
if (!open || mode.kind === "closed") return null;
|
||||
if (mode.kind === "view") return <ViewModal {...props} />;
|
||||
if (mode.kind === "day-in-range")
|
||||
return <DayInRangeModal {...props} mode={mode} />;
|
||||
if (mode.kind === "day") return <DayPanel {...props} mode={mode} />;
|
||||
return <EditForm {...props} />;
|
||||
}
|
||||
|
||||
@@ -102,21 +144,26 @@ function EditForm(props: Props) {
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// Narrow mode to the kinds EditForm actually handles. The call site in
|
||||
// PlanCellModal only passes "create" | "edit-range" | "edit-override" but
|
||||
// the parameter type is the full union, so we narrow explicitly here.
|
||||
// PlanCellModal only passes "create" | "create-override" | "edit-range" |
|
||||
// "edit-override" but the parameter type is the full union, so we narrow
|
||||
// explicitly here.
|
||||
if (
|
||||
mode.kind !== "create" &&
|
||||
mode.kind !== "create-override" &&
|
||||
mode.kind !== "edit-range" &&
|
||||
mode.kind !== "edit-override"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isOverride = mode.kind === "edit-override";
|
||||
// An override is a single-day record — both editing one ("edit-override")
|
||||
// and creating one ("create-override") lock the date and hide the range UI.
|
||||
const isOverride =
|
||||
mode.kind === "edit-override" || mode.kind === "create-override";
|
||||
const activeCategories = props.categories.filter((c) => c.is_active);
|
||||
|
||||
const initial = (() => {
|
||||
if (mode.kind === "create") {
|
||||
if (mode.kind === "create" || mode.kind === "create-override") {
|
||||
return {
|
||||
date_from: mode.date,
|
||||
date_to: mode.date,
|
||||
@@ -161,9 +208,11 @@ function EditForm(props: Props) {
|
||||
const title =
|
||||
mode.kind === "create"
|
||||
? "Nový záznam plánu"
|
||||
: mode.kind === "edit-range"
|
||||
? "Upravit rozsah"
|
||||
: "Upravit přepsání dne";
|
||||
: mode.kind === "create-override"
|
||||
? "Nové přepsání dne"
|
||||
: mode.kind === "edit-range"
|
||||
? "Upravit rozsah"
|
||||
: "Upravit přepsání dne";
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
@@ -178,6 +227,17 @@ function EditForm(props: Props) {
|
||||
category,
|
||||
note,
|
||||
});
|
||||
} else if (mode.kind === "create-override") {
|
||||
// An override is a single day — lock it to mode.date regardless of
|
||||
// any local dateFrom/isRange state (the date field is disabled for
|
||||
// overrides, but be explicit).
|
||||
await onSaveOverride({
|
||||
user_id: mode.userId,
|
||||
shift_date: mode.date,
|
||||
project_id: projectId,
|
||||
category,
|
||||
note,
|
||||
});
|
||||
} else if (mode.kind === "edit-range") {
|
||||
await onUpdateEntry(mode.entryId, {
|
||||
date_from: dateFrom,
|
||||
@@ -213,128 +273,115 @@ function EditForm(props: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
// If the row's current category was retired/hidden, still show it (marked)
|
||||
// so the select reflects reality instead of silently snapping to the first
|
||||
// active option. The server only re-checks the category if it changes, so
|
||||
// keeping it saves fine.
|
||||
const showRetiredCategory =
|
||||
category && !activeCategories.some((c) => c.key === category);
|
||||
|
||||
// Both create flows ("create" entry, "create-override") show "Vytvořit" and
|
||||
// have no Smazat button (there is no existing row to delete yet).
|
||||
const isCreate = mode.kind === "create" || mode.kind === "create-override";
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
|
||||
submitText={isCreate ? "Vytvořit" : "Uložit"}
|
||||
loading={submitting}
|
||||
footerLeft={
|
||||
mode.kind !== "create" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
{!isCreate && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={TrashIcon}
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
Smazat
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<FormField label="Datum od">
|
||||
<AdminDatePicker
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
<Field label="Datum od">
|
||||
<DateField
|
||||
value={dateFrom}
|
||||
onChange={setDateFrom}
|
||||
disabled={isOverride}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
{!isOverride && (
|
||||
<FormField label="Rozsah dnů">
|
||||
<label className="admin-form-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRange}
|
||||
onChange={(e) => setIsRange(e.target.checked)}
|
||||
/>
|
||||
<span>Více dní</span>
|
||||
</label>
|
||||
</FormField>
|
||||
<Field label="Rozsah dnů">
|
||||
<CheckboxField
|
||||
label="Více dní"
|
||||
checked={isRange}
|
||||
onChange={setIsRange}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{!isOverride && isRange && (
|
||||
<FormField label="Datum do">
|
||||
<AdminDatePicker value={dateTo} onChange={setDateTo} />
|
||||
</FormField>
|
||||
<Field label="Datum do">
|
||||
<DateField value={dateTo} onChange={setDateTo} />
|
||||
</Field>
|
||||
)}
|
||||
<FormField label="Projekt">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={projectId ?? ""}
|
||||
onChange={(e) =>
|
||||
setProjectId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
>
|
||||
<option value="">— bez projektu —</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Kategorie">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
<Field label="Projekt">
|
||||
<Select
|
||||
value={projectId === null ? "" : String(projectId)}
|
||||
onChange={(val) => setProjectId(val ? Number(val) : null)}
|
||||
options={[
|
||||
{ value: "", label: "— bez projektu —" },
|
||||
...projects.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: p.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Kategorie">
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
>
|
||||
{/* If the row's current category was retired/hidden, still show it
|
||||
(marked) so the select reflects reality instead of silently
|
||||
snapping to the first active option. The server only re-checks
|
||||
the category if it changes, so keeping it saves fine. */}
|
||||
{category && !activeCategories.some((c) => c.key === category) && (
|
||||
<option value={category}>
|
||||
{planCategoryLabel(
|
||||
category,
|
||||
Object.fromEntries(props.categories.map((x) => [x.key, x])),
|
||||
)}{" "}
|
||||
(skrytá)
|
||||
</option>
|
||||
)}
|
||||
{activeCategories.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Text poznámky (volitelný)">
|
||||
<textarea
|
||||
className="admin-form-textarea"
|
||||
onChange={(val) => setCategory(val)}
|
||||
options={[
|
||||
...(showRetiredCategory
|
||||
? [
|
||||
{
|
||||
value: category,
|
||||
label: `${planCategoryLabel(
|
||||
category,
|
||||
Object.fromEntries(
|
||||
props.categories.map((x) => [x.key, x]),
|
||||
),
|
||||
)} (skrytá)`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...activeCategories.map((c) => ({
|
||||
value: c.key,
|
||||
label: c.label,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Text poznámky (volitelný)">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
inputProps={{ maxLength: 500 }}
|
||||
/>
|
||||
</FormField>
|
||||
</FormModal>
|
||||
</Field>
|
||||
</Modal>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={confirmDelete}
|
||||
title="Smazat záznam plánu?"
|
||||
message="Tato akce je nevratná (záznam bude soft-delete)."
|
||||
confirmText="Smazat"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={submitting}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
@@ -376,26 +423,26 @@ function DayInRangeModal(
|
||||
];
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
<Modal
|
||||
isOpen
|
||||
title="Den je součástí rozsahu"
|
||||
onClose={onClose}
|
||||
hideFooter
|
||||
size="md"
|
||||
onSubmit={onClose}
|
||||
submitText="Zavřít — ponechat rozsah beze změny"
|
||||
maxWidth="md"
|
||||
>
|
||||
<p className="plan-modal-intro">
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
Den <strong>{mode.date}</strong> je součástí rozsahu{" "}
|
||||
<strong>
|
||||
{mode.range.date_from} – {mode.range.date_to}
|
||||
</strong>
|
||||
. Vyberte, co chcete udělat:
|
||||
</p>
|
||||
<ul className="plan-choices">
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<AnimatePresence>
|
||||
{choices.map((c, i) => (
|
||||
<motion.li
|
||||
<motion.div
|
||||
key={c.key}
|
||||
className={`plan-choice plan-choice-${c.tone}`}
|
||||
initial={reducedMotion ? false : { opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={reducedMotion ? undefined : { opacity: 0, y: -4 }}
|
||||
@@ -405,35 +452,42 @@ function DayInRangeModal(
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
<div className="plan-choice-body">
|
||||
<div className="plan-choice-title">{c.title}</div>
|
||||
<div className="plan-choice-desc">{c.desc}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
c.tone === "primary"
|
||||
? "admin-btn admin-btn-primary"
|
||||
: "admin-btn admin-btn-secondary"
|
||||
}
|
||||
onClick={c.onClick}
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderColor:
|
||||
c.tone === "primary" ? "primary.main" : "divider",
|
||||
}}
|
||||
>
|
||||
{c.cta}
|
||||
</button>
|
||||
</motion.li>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||
<Typography sx={{ fontWeight: 600 }}>{c.title}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{c.desc}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant={c.tone === "primary" ? "contained" : "outlined"}
|
||||
color={c.tone === "primary" ? "primary" : "inherit"}
|
||||
onClick={c.onClick}
|
||||
>
|
||||
{c.cta}
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
<div className="plan-modal-actions plan-modal-actions--solo">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Zavřít — ponechat rozsah beze změny
|
||||
</button>
|
||||
</div>
|
||||
</FormModal>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -454,29 +508,150 @@ function ViewModal(props: Props) {
|
||||
: fallback.name
|
||||
: null);
|
||||
return (
|
||||
<FormModal isOpen title="Detail plánu" onClose={onClose} hideFooter>
|
||||
<p>
|
||||
<Modal
|
||||
isOpen
|
||||
title="Detail plánu"
|
||||
onClose={onClose}
|
||||
onSubmit={onClose}
|
||||
submitText="Zavřít"
|
||||
hideCancel
|
||||
>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Datum:</strong> {formatDate(c.shift_date)}
|
||||
</p>
|
||||
<p>
|
||||
</Typography>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Kategorie:</strong>{" "}
|
||||
{planCategoryLabel(
|
||||
c.category,
|
||||
Object.fromEntries(categories.map((x) => [x.key, x])),
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
</Typography>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Projekt:</strong> {projectLabel || "—"}
|
||||
</p>
|
||||
<p>
|
||||
</Typography>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Text:</strong> {c.note || "—"}
|
||||
</p>
|
||||
</Typography>
|
||||
{c.rangeFrom && c.rangeTo && (
|
||||
<p>
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
<strong>Patří do rozsahu:</strong> {formatDate(c.rangeFrom)} –{" "}
|
||||
{formatDate(c.rangeTo)}
|
||||
</p>
|
||||
</Typography>
|
||||
)}
|
||||
</FormModal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const EditIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function DayPanel(
|
||||
props: Props & { mode: Extract<PlanCellModalMode, { kind: "day" }> },
|
||||
) {
|
||||
const { mode, onClose, onAddRecord, onEditRecord, categories } = props;
|
||||
const catMap = Object.fromEntries(categories.map((x) => [x.key, x]));
|
||||
const atCap = mode.cells.length >= 3;
|
||||
const free = 3 - mode.cells.length;
|
||||
// A day is in "exception mode" when its shown records are overrides. Cells
|
||||
// are single-layer (all overrides OR all entries), so cells[0] is enough.
|
||||
// "+ Přidat záznam" must add to the layer that's showing — an override here,
|
||||
// otherwise resolveCell would hide a freshly-created entry behind the
|
||||
// existing override after refetch.
|
||||
const isOverrideMode = mode.cells[0]?.source === "override";
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
title={`Plán — ${formatDate(mode.date)}`}
|
||||
onClose={onClose}
|
||||
onSubmit={onClose}
|
||||
submitText="Zavřít"
|
||||
hideCancel
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25, mb: 2 }}>
|
||||
{mode.cells.map((c, i) => {
|
||||
const color =
|
||||
catMap[c.category]?.color || "var(--mui-palette-divider)";
|
||||
const projectLabel = cellProjectLabel(c);
|
||||
return (
|
||||
<Card
|
||||
key={
|
||||
c.entryId != null
|
||||
? c.entryId
|
||||
: c.overrideId != null
|
||||
? `o${c.overrideId}`
|
||||
: i
|
||||
}
|
||||
variant="outlined"
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 4,
|
||||
alignSelf: "stretch",
|
||||
minHeight: 28,
|
||||
borderRadius: 2,
|
||||
bgcolor: color,
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: "0.85rem" }}>
|
||||
{planCategoryLabel(c.category, catMap)}
|
||||
{projectLabel ? ` · ${projectLabel}` : ""}
|
||||
</Typography>
|
||||
{c.note && (
|
||||
<Typography variant="body2" color="text.secondary" noWrap>
|
||||
{c.note}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={EditIcon}
|
||||
onClick={() => onEditRecord(c, mode.userId, mode.date)}
|
||||
>
|
||||
Upravit
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={atCap}
|
||||
onClick={() =>
|
||||
onAddRecord(
|
||||
mode.userId,
|
||||
mode.date,
|
||||
isOverrideMode ? "override" : "entry",
|
||||
)
|
||||
}
|
||||
>
|
||||
+ Přidat záznam
|
||||
</Button>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{atCap
|
||||
? "Maximum 3 záznamy na den"
|
||||
: `${free} ${free === 1 ? "volné místo" : "volná místa"}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { styled } from "@mui/material/styles";
|
||||
import Box from "@mui/material/Box";
|
||||
import {
|
||||
GridData,
|
||||
ResolvedCell,
|
||||
@@ -9,6 +11,368 @@ import {
|
||||
} from "../lib/queries/plan";
|
||||
import type { Project } from "../lib/queries/projects";
|
||||
import PlanRangeChips from "./PlanRangeChips";
|
||||
import { LoadingState } from "../ui";
|
||||
import { fonts } from "../theme";
|
||||
|
||||
/**
|
||||
* The planner surface, fully MUI/theme-driven (replaces the bespoke plan.css
|
||||
* grid styling). All colors come from the theme palette via `theme.vars`, so it
|
||||
* adapts to light/dark automatically; category colors come from the DB and are
|
||||
* injected per cell as the `--cat-color` CSS variable. Class names from the JSX
|
||||
* below are styled here as scoped nested selectors.
|
||||
*/
|
||||
const PlanGridRoot = styled(Box)(({ theme }) => ({
|
||||
position: "relative",
|
||||
overflow: "auto",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
overscrollBehavior: "contain",
|
||||
background: theme.vars!.palette.background.default,
|
||||
border: `1px solid ${theme.vars!.palette.divider}`,
|
||||
borderRadius: 14,
|
||||
boxShadow: theme.shadows[2],
|
||||
maxHeight: "calc(100dvh - 240px)",
|
||||
isolation: "isolate",
|
||||
|
||||
"& .plan-grid": {
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
borderCollapse: "separate",
|
||||
borderSpacing: 0,
|
||||
width: "100%",
|
||||
fontSize: "12.5px",
|
||||
color: theme.vars!.palette.text.primary,
|
||||
fontFamily: fonts.body,
|
||||
},
|
||||
"& col.plan-grid-date-col": { width: 88 },
|
||||
"& tbody tr td + td": {
|
||||
borderLeft: `1px solid ${theme.vars!.palette.divider}`,
|
||||
},
|
||||
|
||||
// Sticky header
|
||||
"& thead th": {
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 3,
|
||||
padding: "14px 12px 12px",
|
||||
background: theme.vars!.palette.background.paper,
|
||||
color: theme.vars!.palette.text.primary,
|
||||
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
|
||||
textAlign: "left",
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
"& thead th.plan-grid-date-col": {
|
||||
left: 0,
|
||||
zIndex: 4,
|
||||
minWidth: 88,
|
||||
padding: "12px 10px 10px 14px",
|
||||
fontSize: "10.5px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
color: theme.vars!.palette.text.secondary,
|
||||
},
|
||||
"& .plan-person-head": {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
minWidth: 0,
|
||||
},
|
||||
"& .plan-person-dot": {
|
||||
flexShrink: 0,
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: "50%",
|
||||
background: theme.vars!.palette.action.disabled,
|
||||
},
|
||||
"& .plan-person-name": {
|
||||
display: "inline-flex",
|
||||
flexDirection: "column",
|
||||
minWidth: 0,
|
||||
lineHeight: 1.1,
|
||||
},
|
||||
"& .plan-person-name strong": {
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
color: theme.vars!.palette.text.primary,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
maxWidth: 180,
|
||||
},
|
||||
"& .plan-person-name small": {
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: theme.vars!.palette.text.disabled,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
fontFamily: fonts.mono,
|
||||
marginTop: 1,
|
||||
},
|
||||
|
||||
// Sticky date column
|
||||
"& tbody td.plan-grid-date-col": {
|
||||
position: "sticky",
|
||||
left: 0,
|
||||
zIndex: 1,
|
||||
background: theme.vars!.palette.background.paper,
|
||||
fontWeight: 500,
|
||||
color: theme.vars!.palette.text.secondary,
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 88,
|
||||
padding: "9px 10px 9px 14px",
|
||||
borderRight: `1px solid ${theme.vars!.palette.divider}`,
|
||||
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
|
||||
},
|
||||
"& .plan-date-stamp": {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
lineHeight: 1.1,
|
||||
gap: 1,
|
||||
},
|
||||
"& .plan-date-daynum": {
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
color: theme.vars!.palette.text.primary,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
lineHeight: 1.05,
|
||||
},
|
||||
"& .plan-date-dow": {
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase",
|
||||
color: theme.vars!.palette.text.disabled,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
|
||||
// Today
|
||||
"& tbody tr.is-today td.plan-grid-date-col": {
|
||||
background: theme.vars!.palette.background.paper,
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 6,
|
||||
bottom: 6,
|
||||
width: 3,
|
||||
borderRadius: "0 2px 2px 0",
|
||||
background: theme.vars!.palette.primary.main,
|
||||
boxShadow: `0 0 14px rgba(${theme.vars!.palette.primary.mainChannel} / 0.3)`,
|
||||
},
|
||||
"& .plan-date-daynum": { color: theme.vars!.palette.primary.main },
|
||||
},
|
||||
"& tbody tr.is-today td:not(.plan-grid-date-col)": {
|
||||
background: `rgba(${theme.vars!.palette.primary.mainChannel} / 0.06)`,
|
||||
},
|
||||
|
||||
// Body cells
|
||||
"& tbody td": {
|
||||
padding: 0,
|
||||
borderBottom: `1px solid ${theme.vars!.palette.divider}`,
|
||||
verticalAlign: "stretch",
|
||||
minWidth: 168,
|
||||
background: theme.vars!.palette.background.paper,
|
||||
},
|
||||
|
||||
// Weekend
|
||||
"& tr.plan-grid-weekend td": {
|
||||
background: `rgba(${theme.vars!.palette.text.primaryChannel} / 0.03)`,
|
||||
},
|
||||
"& tr.plan-grid-weekend td.plan-grid-date-col": {
|
||||
background: theme.vars!.palette.background.paper,
|
||||
color: theme.vars!.palette.text.disabled,
|
||||
"& .plan-date-dow": { color: theme.vars!.palette.warning.main },
|
||||
"& .plan-date-daynum": { color: theme.vars!.palette.text.secondary },
|
||||
},
|
||||
|
||||
// The cell button + marker tape
|
||||
"& .plan-cell": {
|
||||
position: "relative",
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
minHeight: 60,
|
||||
textAlign: "left",
|
||||
background: "transparent",
|
||||
border: 0,
|
||||
borderRadius: 0,
|
||||
padding: "9px 11px 10px 16px",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
transition: "background 150ms ease, transform 150ms ease",
|
||||
"&::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
left: 4,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
width: 3,
|
||||
borderRadius: 2,
|
||||
background: `var(--cat-color, ${theme.vars!.palette.divider})`,
|
||||
transition:
|
||||
"background 150ms ease, box-shadow 150ms ease, transform 150ms ease",
|
||||
},
|
||||
"&:hover": { background: theme.vars!.palette.action.hover },
|
||||
"&:hover::before": {
|
||||
transform: "scaleX(1.4)",
|
||||
boxShadow: "0 0 12px currentColor",
|
||||
},
|
||||
"&:active": { transform: "translateY(0.5px)" },
|
||||
"&:focus-visible": {
|
||||
outline: "none",
|
||||
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, inset 0 0 0 4px ${theme.vars!.palette.background.paper}`,
|
||||
},
|
||||
},
|
||||
"& .plan-cell--readonly": {
|
||||
cursor: "default",
|
||||
"&:hover": { background: "transparent" },
|
||||
"&:hover::before": { transform: "none", boxShadow: "none" },
|
||||
},
|
||||
// A read-only cell that HAS data is still clickable (view mode).
|
||||
"& .plan-cell--readonly:not(.plan-cell--empty):not(.plan-cell--past)": {
|
||||
cursor: "pointer",
|
||||
"&:hover": { background: theme.vars!.palette.action.hover },
|
||||
"&:hover::before": {
|
||||
transform: "scaleX(1.4)",
|
||||
boxShadow: "0 0 12px currentColor",
|
||||
},
|
||||
},
|
||||
"& .plan-cell--past": {
|
||||
cursor: "default",
|
||||
opacity: 0.55,
|
||||
"&:hover": { background: "transparent" },
|
||||
"&:hover::before": { transform: "none", boxShadow: "none" },
|
||||
},
|
||||
// Empty cells: dashed tape + a "+" hover hint.
|
||||
"& .plan-cell--empty::before": {
|
||||
background: "transparent",
|
||||
border: `1px dashed ${theme.vars!.palette.divider}`,
|
||||
width: 2,
|
||||
left: 5,
|
||||
},
|
||||
"& .plan-cell--empty:hover::before": {
|
||||
background: theme.vars!.palette.primary.main,
|
||||
borderColor: theme.vars!.palette.primary.main,
|
||||
transform: "scaleX(1.6)",
|
||||
},
|
||||
"& .plan-cell--empty::after": {
|
||||
content: '"+"',
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: theme.vars!.palette.text.disabled,
|
||||
opacity: 0,
|
||||
fontSize: 18,
|
||||
fontWeight: 300,
|
||||
pointerEvents: "none",
|
||||
transition: "opacity 150ms ease",
|
||||
},
|
||||
"& .plan-cell--empty:hover::after": {
|
||||
opacity: 0.7,
|
||||
color: theme.vars!.palette.primary.main,
|
||||
},
|
||||
"& .plan-cell--empty.plan-cell--readonly::after": { content: "none" },
|
||||
|
||||
// One-shot mutation pulse
|
||||
"@keyframes plan-cell-pulse": {
|
||||
"0%": {
|
||||
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, 0 0 0 0 rgba(${theme.vars!.palette.primary.mainChannel} / 0.4)`,
|
||||
},
|
||||
"60%": {
|
||||
boxShadow: `inset 0 0 0 2px ${theme.vars!.palette.primary.main}, 0 0 0 6px transparent`,
|
||||
},
|
||||
"100%": { boxShadow: "inset 0 0 0 2px transparent, 0 0 0 0 transparent" },
|
||||
},
|
||||
"& .plan-cell--pulse": { animation: "plan-cell-pulse 600ms ease-out" },
|
||||
|
||||
"& .plan-cell-record": {
|
||||
display: "block",
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
},
|
||||
"& .plan-cell-record + .plan-cell-record": {
|
||||
marginTop: 5,
|
||||
paddingTop: 5,
|
||||
borderTop: `1px dashed ${theme.vars!.palette.divider}`,
|
||||
},
|
||||
|
||||
// Chips inside a cell
|
||||
"& .plan-chip-block": {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
textAlign: "left",
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
},
|
||||
"& .plan-chip-line": {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
flexWrap: "wrap",
|
||||
minWidth: 0,
|
||||
},
|
||||
"& .plan-chip": {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 5,
|
||||
borderRadius: 4,
|
||||
padding: "2px 7px",
|
||||
fontSize: "10.5px",
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.04em",
|
||||
textTransform: "uppercase",
|
||||
color: `var(--cat-color, ${theme.vars!.palette.text.primary})`,
|
||||
background: `color-mix(in srgb, var(--cat-color, transparent) 12%, ${theme.vars!.palette.background.paper})`,
|
||||
border: `1px solid color-mix(in srgb, var(--cat-color, ${theme.vars!.palette.divider}) 35%, transparent)`,
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
"& .plan-chip-project": {
|
||||
fontFamily: fonts.mono,
|
||||
fontSize: "12.5px",
|
||||
fontWeight: 600,
|
||||
color: theme.vars!.palette.text.primary,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: "100%",
|
||||
},
|
||||
"& .plan-cell-note": {
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
marginTop: 1,
|
||||
fontSize: "11.5px",
|
||||
color: theme.vars!.palette.text.secondary,
|
||||
lineHeight: 1.35,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
|
||||
"@media (max-width: 480px)": {
|
||||
"& col.plan-grid-date-col": { width: 72 },
|
||||
"& tbody td": { minWidth: 132 },
|
||||
"& .plan-cell": { minHeight: 56 },
|
||||
},
|
||||
// Touch: show a faint persistent "+" on empty editable cells.
|
||||
"@media (hover: none)": {
|
||||
"& .plan-cell--empty:not(.plan-cell--readonly)::after": {
|
||||
opacity: 0.26,
|
||||
color: theme.vars!.palette.text.disabled,
|
||||
},
|
||||
},
|
||||
"@media (prefers-reduced-motion: reduce)": {
|
||||
"& .plan-cell, & .plan-cell::before, & .plan-cell::after": {
|
||||
transition: "none",
|
||||
},
|
||||
"& .plan-cell--pulse": { animation: "none" },
|
||||
},
|
||||
}));
|
||||
|
||||
interface Props {
|
||||
data: GridData | undefined;
|
||||
@@ -20,11 +384,7 @@ interface Props {
|
||||
// the value so back-to-back mutations on the same cell re-trigger
|
||||
// the animation (React needs a new key to re-mount the class).
|
||||
pulseKey?: { userId: number; date: string; nonce: number } | null;
|
||||
onCellClick: (
|
||||
userId: number,
|
||||
date: string,
|
||||
cell: ResolvedCell | null,
|
||||
) => void;
|
||||
onCellClick: (userId: number, date: string, cells: ResolvedCell[]) => void;
|
||||
}
|
||||
|
||||
// Full Czech weekday names, indexed by Date.getUTCDay() (0 = Sunday).
|
||||
@@ -107,12 +467,7 @@ export default function PlanGrid({
|
||||
const today = useMemo(() => todayIso(), []);
|
||||
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||
|
||||
if (!data)
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
if (!data) return <LoadingState />;
|
||||
const days = eachDay(data.date_from, data.date_to);
|
||||
const users = data.users;
|
||||
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
||||
@@ -124,7 +479,7 @@ export default function PlanGrid({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="plan-grid-wrap" data-pulse={pulseAttr}>
|
||||
<PlanGridRoot data-pulse={pulseAttr}>
|
||||
<table className="plan-grid">
|
||||
{/* The colgroup is what actually controls column width in a
|
||||
table — `min-width` on `<th>`/`<td>` is just a floor that
|
||||
@@ -181,7 +536,8 @@ export default function PlanGrid({
|
||||
</span>
|
||||
</td>
|
||||
{users.map((u) => {
|
||||
const cell = data.cells[u.id]?.[date] ?? null;
|
||||
const cellArr = data.cells[u.id]?.[date] ?? [];
|
||||
const cell = cellArr[0] ?? null;
|
||||
const past = isPastDate(date, today);
|
||||
// Past-day cells are read-only in the UI: an empty past
|
||||
// cell is non-interactive (no create modal, no "+" hint),
|
||||
@@ -223,29 +579,50 @@ export default function PlanGrid({
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onCellClick(u.id, date, cell)}
|
||||
onClick={() => onCellClick(u.id, date, cellArr)}
|
||||
aria-label={
|
||||
cell
|
||||
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
|
||||
? cellArr.length === 1
|
||||
? `${u.full_name}, ${date}, ${planCategoryLabel(cell.category, catMap)}`
|
||||
: `${u.full_name}, ${date}, ${cellArr.length} záznamy`
|
||||
: isLocked
|
||||
? `${u.full_name}, ${date}, ${past ? "uplynulý den" : "prázdné"} — bez záznamu`
|
||||
: `${u.full_name}, ${date}, prázdné — přidat záznam`
|
||||
}
|
||||
>
|
||||
<PlanRangeChips
|
||||
cell={cell}
|
||||
project={
|
||||
cell?.project_id
|
||||
? (projects.find(
|
||||
(p) => p.id === cell.project_id,
|
||||
) ?? null)
|
||||
: null
|
||||
}
|
||||
readonly={!canEdit}
|
||||
categoryLabel={
|
||||
cell ? planCategoryLabel(cell.category, catMap) : ""
|
||||
}
|
||||
/>
|
||||
{cellArr.map((c, i) => (
|
||||
<Box
|
||||
key={
|
||||
c.entryId != null
|
||||
? c.entryId
|
||||
: c.overrideId != null
|
||||
? `o${c.overrideId}`
|
||||
: i
|
||||
}
|
||||
className="plan-cell-record"
|
||||
style={
|
||||
{
|
||||
"--cat-color": catMap[c.category]?.color,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<PlanRangeChips
|
||||
cell={c}
|
||||
project={
|
||||
c.project_id
|
||||
? (projects.find(
|
||||
(p) => p.id === c.project_id,
|
||||
) ?? null)
|
||||
: null
|
||||
}
|
||||
readonly={!canEdit}
|
||||
categoryLabel={planCategoryLabel(
|
||||
c.category,
|
||||
catMap,
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
@@ -255,6 +632,6 @@ export default function PlanGrid({
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PlanGridRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { projectFilesOptions } from "../lib/queries/projects";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import ConfirmModal from "./ConfirmModal";
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
TextField,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
DataTable,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
import apiFetch from "../utils/api";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
@@ -122,7 +135,7 @@ function getFileIcon(type: string, extension?: string) {
|
||||
};
|
||||
|
||||
const cfg = iconMap[ext] || {
|
||||
color: "var(--text-muted)",
|
||||
color: "var(--mui-palette-text-secondary)",
|
||||
path: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z",
|
||||
};
|
||||
|
||||
@@ -143,7 +156,16 @@ function getFileIcon(type: string, extension?: string) {
|
||||
|
||||
function SymlinkBadge({ target }: { target?: string }) {
|
||||
return (
|
||||
<span className="fm-symlink-badge" title={target || "Odkaz"}>
|
||||
<Box
|
||||
component="span"
|
||||
title={target || "Odkaz"}
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
ml: 0.5,
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
@@ -155,7 +177,7 @@ function SymlinkBadge({ target }: { target?: string }) {
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,26 +190,53 @@ function FileNameCell({
|
||||
}) {
|
||||
if (item.type === "folder") {
|
||||
return (
|
||||
<span className="fm-name-cell">
|
||||
<button
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
className="fm-folder-link"
|
||||
onClick={() => onFolderClick(item.name)}
|
||||
sx={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
p: 0,
|
||||
font: "inherit",
|
||||
color: "primary.main",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
"&:hover": { textDecoration: "underline" },
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
</Box>
|
||||
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
||||
{item.item_count !== undefined && (
|
||||
<span className="fm-item-count">{item.item_count}</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: ".7rem",
|
||||
color: "text.secondary",
|
||||
bgcolor: "action.hover",
|
||||
borderRadius: 1,
|
||||
px: 0.75,
|
||||
}}
|
||||
>
|
||||
{item.item_count}
|
||||
</Box>
|
||||
)}
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="fm-name-cell">
|
||||
<span className="fm-file-name">{item.name}</span>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
||||
>
|
||||
<Box component="span">{item.name}</Box>
|
||||
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
||||
</span>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -469,76 +518,271 @@ export default function ProjectFileManager({
|
||||
};
|
||||
|
||||
if (filesLoading && items.length === 0 && !errorMessage) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Soubory</h3>
|
||||
<div className="fm-empty">
|
||||
<Card>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
Soubory
|
||||
</Typography>
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="var(--text-tertiary)"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
title={errorMessage}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-body">
|
||||
<h3 className="admin-card-title">Soubory</h3>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="fm-toolbar">
|
||||
<div className="fm-breadcrumb">
|
||||
{breadcrumb.map((segment, i) => (
|
||||
<span key={i} className="fm-breadcrumb-segment">
|
||||
{i > 0 && <span className="fm-breadcrumb-sep">/</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={`fm-breadcrumb-btn ${i === breadcrumb.length - 1 ? "active" : ""}`}
|
||||
onClick={() => handleBreadcrumbClick(i)}
|
||||
>
|
||||
{i === 0 ? projectNumber : segment}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{fullPath && (
|
||||
<span className="fm-full-path" title={fullPath}>
|
||||
{fullPath}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<div className="fm-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
onClick={() => {
|
||||
setNewFolderMode(!newFolderMode);
|
||||
setNewFolderName("");
|
||||
const columns: DataColumn<FileItem>[] = [
|
||||
{
|
||||
key: "icon",
|
||||
header: "",
|
||||
width: 30,
|
||||
align: "left",
|
||||
render: (item) => (
|
||||
<Box sx={{ textAlign: "center" }}>
|
||||
{getFileIcon(item.type, item.extension)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
header: "Název",
|
||||
render: (item) =>
|
||||
renamingItem === item.name ? (
|
||||
<TextField
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
autoFocus
|
||||
sx={{ maxWidth: 300 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRename(item);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
isCancelling.current = true;
|
||||
setRenamingItem(null);
|
||||
setRenameValue(item.name);
|
||||
setTimeout(() => {
|
||||
isCancelling.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (isCancelling.current) {
|
||||
return;
|
||||
}
|
||||
handleRename(item);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FileNameCell item={item} onFolderClick={handleFolderClick} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
header: "Velikost",
|
||||
width: 90,
|
||||
render: (item) => (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.type === "file" ? item.size_formatted : "—"}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "modified",
|
||||
header: "Změněno",
|
||||
width: 120,
|
||||
render: (item) => (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{item.modified || "—"}
|
||||
</Typography>
|
||||
),
|
||||
},
|
||||
...(canManage
|
||||
? [
|
||||
{
|
||||
key: "actions",
|
||||
header: "Akce",
|
||||
width: 100,
|
||||
align: "right" as const,
|
||||
render: (item: FileItem) => (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
gap: 0.5,
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{item.type === "file" && (
|
||||
<IconButton
|
||||
size="small"
|
||||
title="Stáhnout"
|
||||
aria-label="Stáhnout"
|
||||
onClick={() => handleDownload(item)}
|
||||
>
|
||||
<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="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
title="Přejmenovat"
|
||||
aria-label="Přejmenovat"
|
||||
onClick={() => startRename(item)}
|
||||
>
|
||||
<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>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
title="Smazat"
|
||||
aria-label="Smazat"
|
||||
onClick={() => setDeleteTarget(item)}
|
||||
>
|
||||
<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 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
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"
|
||||
@@ -551,101 +795,148 @@ export default function ProjectFileManager({
|
||||
<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={() => {
|
||||
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"
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Drop zone + table */}
|
||||
<div
|
||||
className={`fm-content ${dragOver ? "fm-drag-over" : ""}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="fm-dropzone-overlay">
|
||||
{items.length === 0 && !filesLoading ? (
|
||||
<EmptyState
|
||||
icon={
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
@@ -653,180 +944,31 @@ export default function ProjectFileManager({
|
||||
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)}
|
||||
</td>
|
||||
<td>
|
||||
{renamingItem === item.name ? (
|
||||
<input
|
||||
type="text"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
padding: "3px 8px",
|
||||
maxWidth: "300px",
|
||||
}}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleRename(item);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
isCancelling.current = true;
|
||||
setRenamingItem(null);
|
||||
setRenameValue(item.name);
|
||||
setTimeout(() => {
|
||||
isCancelling.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (isCancelling.current) {
|
||||
return;
|
||||
}
|
||||
handleRename(item);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FileNameCell
|
||||
item={item}
|
||||
onFolderClick={handleFolderClick}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="fm-meta">
|
||||
{item.type === "file" ? item.size_formatted : "\u2014"}
|
||||
</td>
|
||||
<td className="fm-meta">{item.modified || "\u2014"}</td>
|
||||
{canManage && (
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<div className="fm-actions">
|
||||
{item.type === "file" && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
title="Stáhnout"
|
||||
onClick={() => handleDownload(item)}
|
||||
>
|
||||
<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="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
title="Přejmenovat"
|
||||
onClick={() => startRename(item)}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon danger"
|
||||
title="Smazat"
|
||||
onClick={() => setDeleteTarget(item)}
|
||||
>
|
||||
<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 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
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>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
@@ -836,9 +978,9 @@ export default function ProjectFileManager({
|
||||
message={`Opravdu chcete smazat "${deleteTarget?.name}"?${deleteTarget?.type === "folder" ? " Složka bude smazána včetně veškerého obsahu." : ""}`}
|
||||
confirmText="Smazat"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useRef, useCallback, useLayoutEffect } from "react";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
import { RichEditorRoot } from "../ui/RichText";
|
||||
|
||||
const COLORS = [
|
||||
"#000000",
|
||||
@@ -104,8 +105,7 @@ export default function RichEditor({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="admin-rich-editor"
|
||||
<RichEditorRoot
|
||||
style={{ "--re-min-height": minHeight } as React.CSSProperties}
|
||||
>
|
||||
<ReactQuill
|
||||
@@ -118,6 +118,6 @@ export default function RichEditor({
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</RichEditorRoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import AdminDatePicker from "./AdminDatePicker";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Select,
|
||||
TextField,
|
||||
DateField,
|
||||
TimeField,
|
||||
Field,
|
||||
Alert,
|
||||
} from "../ui";
|
||||
import {
|
||||
calcFormWorkMinutes,
|
||||
calcProjectMinutesTotal,
|
||||
@@ -81,6 +91,19 @@ export interface ShiftFormModalProps {
|
||||
editingRecord: EditingRecord | null;
|
||||
}
|
||||
|
||||
const RemoveIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ---------- ProjectTimeStatus ----------
|
||||
|
||||
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
||||
@@ -101,30 +124,14 @@ function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
||||
|
||||
const isMatch = remaining === 0;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem 0.75rem",
|
||||
marginBottom: "0.5rem",
|
||||
borderRadius: "6px",
|
||||
fontSize: "0.8rem",
|
||||
background: isMatch
|
||||
? "var(--success-bg, rgba(34,197,94,0.1))"
|
||||
: "var(--danger-bg, rgba(239,68,68,0.1))",
|
||||
color: isMatch
|
||||
? "var(--success-color, #16a34a)"
|
||||
: "var(--danger-color, #dc2626)",
|
||||
border: `1px solid ${
|
||||
isMatch
|
||||
? "var(--success-border, rgba(34,197,94,0.3))"
|
||||
: "var(--danger-border, rgba(239,68,68,0.3))"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m | Přiřazeno:{" "}
|
||||
{Math.floor(totalProject / 60)}h {totalProject % 60}m | Zbývá:{" "}
|
||||
{Math.floor(Math.abs(remaining) / 60)}h {Math.abs(remaining) % 60}m{" "}
|
||||
{remaining < 0 ? "(překročeno)" : ""}
|
||||
</div>
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Alert severity={isMatch ? "success" : "error"}>
|
||||
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m |
|
||||
Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m |
|
||||
Zbývá: {Math.floor(Math.abs(remaining) / 60)}h{" "}
|
||||
{Math.abs(remaining) % 60}m {remaining < 0 ? "(překročeno)" : ""}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,65 +145,65 @@ function ProjectLogRow({
|
||||
onRemove,
|
||||
}: ProjectLogRowProps) {
|
||||
return (
|
||||
<div className="flex-row gap-2 mb-2">
|
||||
<select
|
||||
value={log.project_id}
|
||||
onChange={(e) => onUpdate(index, "project_id", e.target.value)}
|
||||
className="admin-form-select"
|
||||
style={{ flex: 3, marginBottom: 0 }}
|
||||
>
|
||||
<option value="">— Projekt —</option>
|
||||
{projectList.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.project_number} – {p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="24"
|
||||
value={log.hours}
|
||||
onChange={(e) => onUpdate(index, "hours", e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
||||
placeholder="h"
|
||||
/>
|
||||
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 3, minWidth: 0 }}>
|
||||
<Select
|
||||
value={String(log.project_id)}
|
||||
onChange={(val) => onUpdate(index, "project_id", val)}
|
||||
options={[
|
||||
{ value: "", label: "— Projekt —" },
|
||||
...projectList.map((p) => ({
|
||||
value: String(p.id),
|
||||
label: `${p.project_number} – ${p.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ width: 70, flexShrink: 0 }}>
|
||||
<TextField
|
||||
type="number"
|
||||
value={log.hours}
|
||||
onChange={(e) => onUpdate(index, "hours", e.target.value)}
|
||||
placeholder="h"
|
||||
slotProps={{
|
||||
htmlInput: { min: 0, max: 24, style: { textAlign: "center" } },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
h
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={log.minutes}
|
||||
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
|
||||
className="admin-form-input"
|
||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
||||
placeholder="m"
|
||||
/>
|
||||
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
|
||||
</Typography>
|
||||
<Box sx={{ width: 70, flexShrink: 0 }}>
|
||||
<TextField
|
||||
type="number"
|
||||
value={log.minutes}
|
||||
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
|
||||
placeholder="m"
|
||||
slotProps={{
|
||||
htmlInput: { min: 0, max: 59, style: { textAlign: "center" } },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
m
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRemove(index)}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
style={{ padding: "0.375rem", flexShrink: 0 }}
|
||||
aria-label="Odebrat"
|
||||
title="Odebrat"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{RemoveIcon}
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,7 +223,6 @@ export default function ShiftFormModal({
|
||||
onShiftDateChange,
|
||||
editingRecord,
|
||||
}: ShiftFormModalProps) {
|
||||
useModalLock(isOpen);
|
||||
const isCreate = mode === "create";
|
||||
const isWorkType = form.leave_type === "work";
|
||||
|
||||
@@ -247,276 +253,211 @@ export default function ShiftFormModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
||||
subtitle={
|
||||
!isCreate && editingRecord
|
||||
? `${editingRecord.user_name} — ${formatDate(editingRecord.shift_date)}`
|
||||
: undefined
|
||||
}
|
||||
maxWidth="lg"
|
||||
onSubmit={onSubmit}
|
||||
submitText="Uložit"
|
||||
cancelText="Zrušit"
|
||||
>
|
||||
{isCreate ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<div className="admin-modal-backdrop" onClick={onClose} />
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="shift-form-modal-title"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 id="shift-form-modal-title" className="admin-modal-title">
|
||||
{isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
||||
</h2>
|
||||
{!isCreate && editingRecord && (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{editingRecord.user_name} —{" "}
|
||||
{formatDate(editingRecord.shift_date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
{isCreate ? (
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label required">
|
||||
Zaměstnanec
|
||||
</label>
|
||||
<select
|
||||
value={form.user_id}
|
||||
onChange={(e) => updateField("user_id", e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte zaměstnance</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label required">
|
||||
Datum směny
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.shift_date}
|
||||
onChange={(val) => onShiftDateChange(val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Datum směny</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.shift_date}
|
||||
onChange={(val) => updateField("shift_date", val)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Typ záznamu</label>
|
||||
<select
|
||||
value={form.leave_type}
|
||||
onChange={(e) => updateField("leave_type", e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="work">Práce</option>
|
||||
<option value="vacation">Dovolená</option>
|
||||
<option value="sick">Nemoc</option>
|
||||
<option value="unpaid">Neplacené volno</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!isWorkType && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Počet hodin</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
updateField("leave_hours", parseFloat(e.target.value))
|
||||
}
|
||||
min="0.5"
|
||||
max="24"
|
||||
step="0.5"
|
||||
className="admin-form-input"
|
||||
/>
|
||||
{isCreate && (
|
||||
<small className="admin-form-hint">
|
||||
8 hodin = celý den
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWorkType && (
|
||||
<>
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Příchod - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.arrival_date}
|
||||
onChange={(val) => updateField("arrival_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Příchod - čas
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => updateField("arrival_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Začátek pauzy - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_start_date}
|
||||
onChange={(val) =>
|
||||
updateField("break_start_date", val)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Začátek pauzy - čas
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val) =>
|
||||
updateField("break_start_time", val)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Konec pauzy - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_end_date}
|
||||
onChange={(val) => updateField("break_end_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Konec pauzy - čas
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => updateField("break_end_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">
|
||||
Odchod - datum
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.departure_date}
|
||||
onChange={(val) => updateField("departure_date", val)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Odchod - čas</label>
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val) => updateField("departure_time", val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWorkType && projectList.length > 0 && (
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Projekty</label>
|
||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||
{projectLogs.map((log, i) => (
|
||||
<ProjectLogRow
|
||||
key={log._key || i}
|
||||
log={log}
|
||||
index={i}
|
||||
projectList={projectList}
|
||||
onUpdate={updateProjectLog}
|
||||
onRemove={removeProjectLog}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addProjectLog}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
+ Přidat projekt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Poznámka</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
className="admin-form-textarea"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
Uložit
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<Field label="Zaměstnanec" required>
|
||||
<Select
|
||||
value={form.user_id}
|
||||
onChange={(val) => updateField("user_id", val)}
|
||||
options={[
|
||||
{ value: "", label: "Vyberte zaměstnance" },
|
||||
...users.map((user) => ({
|
||||
value: String(user.id),
|
||||
label: user.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Datum směny" required>
|
||||
<DateField
|
||||
value={form.shift_date}
|
||||
onChange={(val) => onShiftDateChange(val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
) : (
|
||||
<Field label="Datum směny">
|
||||
<DateField
|
||||
value={form.shift_date}
|
||||
onChange={(val) => updateField("shift_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Field label="Typ záznamu">
|
||||
<Select
|
||||
value={form.leave_type}
|
||||
onChange={(val) => updateField("leave_type", val)}
|
||||
options={[
|
||||
{ value: "work", label: "Práce" },
|
||||
{ value: "vacation", label: "Dovolená" },
|
||||
{ value: "sick", label: "Nemoc" },
|
||||
{ value: "unpaid", label: "Neplacené volno" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{!isWorkType && (
|
||||
<Field
|
||||
label="Počet hodin"
|
||||
hint={isCreate ? "8 hodin = celý den" : undefined}
|
||||
>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
updateField("leave_hours", parseFloat(e.target.value))
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isWorkType && (
|
||||
<>
|
||||
{/* Arrival */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Příchod - datum">
|
||||
<DateField
|
||||
value={form.arrival_date}
|
||||
onChange={(val) => updateField("arrival_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Příchod - čas">
|
||||
<TimeField
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => updateField("arrival_time", val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Break start */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Začátek pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_start_date}
|
||||
onChange={(val) => updateField("break_start_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Začátek pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_start_time}
|
||||
onChange={(val) => updateField("break_start_time", val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Break end */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Konec pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_end_date}
|
||||
onChange={(val) => updateField("break_end_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Konec pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_end_time}
|
||||
onChange={(val) => updateField("break_end_time", val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Departure */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Odchod - datum">
|
||||
<DateField
|
||||
value={form.departure_date}
|
||||
onChange={(val) => updateField("departure_date", val)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Odchod - čas">
|
||||
<TimeField
|
||||
value={form.departure_time}
|
||||
onChange={(val) => updateField("departure_time", val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isWorkType && projectList.length > 0 && (
|
||||
<Field label="Projekty">
|
||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||
{projectLogs.map((log, i) => (
|
||||
<ProjectLogRow
|
||||
key={log._key || i}
|
||||
log={log}
|
||||
index={i}
|
||||
projectList={projectList}
|
||||
onUpdate={updateProjectLog}
|
||||
onRemove={removeProjectLog}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={addProjectLog}
|
||||
>
|
||||
+ Přidat projekt
|
||||
</Button>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Poznámka">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
ENTITY_TYPE_LABELS,
|
||||
getActivityIconClass,
|
||||
@@ -18,6 +22,16 @@ interface DashActivityFeedProps {
|
||||
activities: Activity[] | null;
|
||||
}
|
||||
|
||||
// Maps the legacy activity-icon class to the MUI palette key used to tint the
|
||||
// circular icon badge (danger → error, accent → primary, muted → default).
|
||||
const ACTIVITY_BADGE_COLOR: Record<string, string> = {
|
||||
success: "success",
|
||||
info: "info",
|
||||
danger: "error",
|
||||
accent: "primary",
|
||||
muted: "default",
|
||||
};
|
||||
|
||||
function getActivityIcon(action: string) {
|
||||
switch (action) {
|
||||
case "create":
|
||||
@@ -103,37 +117,95 @@ export default function DashActivityFeed({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card dash-activity-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Audit log</h2>
|
||||
<Link
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Audit log</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/audit-log"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Detail →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{activities.map((act) => (
|
||||
<div key={act.id} className="dash-activity-row">
|
||||
<div
|
||||
className={`dash-activity-icon ${getActivityIconClass(act.action)}`}
|
||||
>
|
||||
{getActivityIcon(act.action)}
|
||||
</div>
|
||||
<div className="dash-activity-main">
|
||||
<div className="dash-activity-text">{act.description}</div>
|
||||
<div className="dash-activity-sub">
|
||||
{act.username || "Systém"} ·{" "}
|
||||
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dash-activity-time admin-mono">
|
||||
{formatActivityTime(act.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{activities.length === 0 ? (
|
||||
<EmptyState title="Žádná aktivita" />
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
{activities.map((act) => {
|
||||
const badgeColor =
|
||||
ACTIVITY_BADGE_COLOR[getActivityIconClass(act.action)] ??
|
||||
"default";
|
||||
const isDefault = badgeColor === "default";
|
||||
return (
|
||||
<Box
|
||||
key={act.id}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={[
|
||||
{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconBadgeSx(
|
||||
isDefault ? "default" : (badgeColor as IconBadgeColor),
|
||||
),
|
||||
]}
|
||||
>
|
||||
{getActivityIcon(act.action)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 500 }}
|
||||
noWrap
|
||||
title={act.description}
|
||||
>
|
||||
{act.description}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{act.username || "Systém"} ·{" "}
|
||||
{ENTITY_TYPE_LABELS[act.entity_type] || act.entity_type}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{formatActivityTime(act.created_at)}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
LEAVE_TYPE_LABELS,
|
||||
STATUS_DOT_CLASS,
|
||||
STATUS_LABELS,
|
||||
} from "../../utils/dashboardHelpers";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import { Card, Button, StatusChip, EmptyState } from "../../ui";
|
||||
import { LEAVE_TYPE_LABELS, STATUS_LABELS } from "../../utils/dashboardHelpers";
|
||||
|
||||
interface AttendanceUser {
|
||||
user_id: number | string;
|
||||
@@ -22,6 +22,19 @@ interface DashAttendanceTodayProps {
|
||||
attendance: AttendanceData | null;
|
||||
}
|
||||
|
||||
// Maps the attendance status to the StatusChip / avatar tint color.
|
||||
// Mirrors the legacy dash-status-* dot classes: in→success, away→warning,
|
||||
// out→default, leave→error.
|
||||
const STATUS_COLOR: Record<
|
||||
string,
|
||||
"default" | "success" | "warning" | "error"
|
||||
> = {
|
||||
in: "success",
|
||||
away: "warning",
|
||||
out: "default",
|
||||
leave: "error",
|
||||
};
|
||||
|
||||
export default function DashAttendanceToday({
|
||||
attendance,
|
||||
}: DashAttendanceTodayProps) {
|
||||
@@ -30,42 +43,96 @@ export default function DashAttendanceToday({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-card dash-attendance-card">
|
||||
<div className="admin-card-header flex-between">
|
||||
<h2 className="admin-card-title">Docházka dnes</h2>
|
||||
<Link
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Docházka dnes</Typography>
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Detail →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{attendance.users.map((u, i) => (
|
||||
<div key={`${u.user_id}-${i}`} className="dash-presence-row">
|
||||
<div
|
||||
className={`dash-presence-avatar ${STATUS_DOT_CLASS[u.status]}`}
|
||||
>
|
||||
{u.initials || "?"}
|
||||
</div>
|
||||
<div className="dash-presence-name">{u.name}</div>
|
||||
<div className="dash-presence-end">
|
||||
<span
|
||||
className={`dash-presence-label ${STATUS_DOT_CLASS[u.status]}`}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{attendance.users.length === 0 ? (
|
||||
<EmptyState title="Žádná docházka" />
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
{attendance.users.map((u, i) => {
|
||||
const color = STATUS_COLOR[u.status] ?? "default";
|
||||
const isDefault = color === "default";
|
||||
return (
|
||||
<Box
|
||||
key={`${u.user_id}-${i}`}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
"&:last-of-type": { borderBottom: 0 },
|
||||
}}
|
||||
>
|
||||
{u.status === "leave"
|
||||
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
|
||||
: STATUS_LABELS[u.status]}
|
||||
</span>
|
||||
{u.arrived_at && (
|
||||
<span className="admin-mono dash-presence-time">
|
||||
{u.arrived_at}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Avatar
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
fontSize: "0.8rem",
|
||||
fontWeight: 700,
|
||||
bgcolor: isDefault ? "grey.600" : `${color}.main`,
|
||||
color: "common.white",
|
||||
}}
|
||||
>
|
||||
{u.initials || "?"}
|
||||
</Avatar>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontWeight: 500, flex: 1, minWidth: 0 }}
|
||||
noWrap
|
||||
>
|
||||
{u.name}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<StatusChip
|
||||
color={color}
|
||||
label={
|
||||
u.status === "leave"
|
||||
? LEAVE_TYPE_LABELS[u.leave_type || ""] || "Nepřítomen"
|
||||
: STATUS_LABELS[u.status]
|
||||
}
|
||||
/>
|
||||
{u.arrived_at && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace" }}
|
||||
>
|
||||
{u.arrived_at}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { StatCard, type StatCardColor } from "../../ui";
|
||||
import { formatCurrency } from "../../utils/formatters";
|
||||
|
||||
interface KpiCard {
|
||||
@@ -108,11 +111,13 @@ function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
|
||||
};
|
||||
}
|
||||
|
||||
const KPI_CLASS_MAP: Record<number, string> = {
|
||||
4: "admin-kpi-4",
|
||||
3: "admin-kpi-3",
|
||||
2: "admin-kpi-2",
|
||||
1: "admin-kpi-1",
|
||||
// Maps the legacy KPI color tokens to the StatCard color palette
|
||||
// (danger → error; the rest are 1:1).
|
||||
const KPI_COLOR_MAP: Record<string, StatCardColor> = {
|
||||
success: "success",
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
danger: "error",
|
||||
};
|
||||
|
||||
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
@@ -121,36 +126,50 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const kpiClass = KPI_CLASS_MAP[Math.min(kpiCards.length, 4)] || "admin-kpi-4";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`admin-kpi-grid ${kpiClass}`}
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
lg: `repeat(${Math.min(kpiCards.length, 4)}, 1fr)`,
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{kpiCards.map((kpi) => (
|
||||
<div key={kpi.label} className={`admin-stat-card ${kpi.color}`}>
|
||||
<div className="admin-stat-label">{kpi.label}</div>
|
||||
<div className="admin-stat-value admin-mono">
|
||||
{kpi.value}
|
||||
{kpi.sub && (
|
||||
<small
|
||||
className="text-muted"
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 500,
|
||||
marginLeft: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{kpi.sub}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
{kpi.footer && <div className="admin-stat-footer">{kpi.footer}</div>}
|
||||
</div>
|
||||
<StatCard
|
||||
key={kpi.label}
|
||||
color={KPI_COLOR_MAP[kpi.color] ?? "default"}
|
||||
label={kpi.label}
|
||||
value={
|
||||
<>
|
||||
{kpi.value}
|
||||
{kpi.sub && (
|
||||
<Typography
|
||||
component="span"
|
||||
color="text.secondary"
|
||||
sx={{
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 500,
|
||||
ml: 0.5,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
{kpi.sub}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
footer={kpi.footer ?? undefined}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import { formatKm, todayLocalStr } from "../../utils/formatters";
|
||||
import AdminDatePicker from "../AdminDatePicker";
|
||||
import { Button, Modal, Field, Select, DateField, TextField } from "../../ui";
|
||||
import apiFetch from "../../utils/api";
|
||||
import useModalLock from "../../hooks/useModalLock";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -46,6 +46,14 @@ interface DashQuickActionsProps {
|
||||
onPunch: () => void;
|
||||
}
|
||||
|
||||
// Maps the legacy quick-action color tokens to the MUI Button palette color.
|
||||
const ACTION_COLOR: Record<string, "success" | "error" | "info" | "warning"> = {
|
||||
success: "success",
|
||||
danger: "error",
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
};
|
||||
|
||||
export default function DashQuickActions({
|
||||
dashData,
|
||||
punching,
|
||||
@@ -69,8 +77,6 @@ export default function DashQuickActions({
|
||||
});
|
||||
const [tripErrors, setTripErrors] = useState<TripErrors>({});
|
||||
|
||||
useModalLock(showTripModal);
|
||||
|
||||
const openTripModal = async () => {
|
||||
setTripForm({
|
||||
vehicle_id: "",
|
||||
@@ -286,303 +292,192 @@ export default function DashQuickActions({
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="dash-quick-actions"
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
md: "repeat(4, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{quickActions.map((action) =>
|
||||
action.onClick ? (
|
||||
<button
|
||||
{quickActions.map((action) => {
|
||||
const color = ACTION_COLOR[action.color] ?? "primary";
|
||||
const common = {
|
||||
variant: "contained" as const,
|
||||
color,
|
||||
startIcon: action.icon,
|
||||
sx: { py: 1.5, justifyContent: "flex-start" },
|
||||
};
|
||||
return action.onClick ? (
|
||||
<Button
|
||||
key={action.label}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
||||
{...common}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Link
|
||||
<Button
|
||||
key={action.label}
|
||||
component={RouterLink}
|
||||
to={action.path!}
|
||||
className={`dash-quick-btn dash-quick-btn-${action.color}`}
|
||||
{...common}
|
||||
>
|
||||
{action.icon}
|
||||
<span>{action.label}</span>
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</motion.div>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<AnimatePresence>
|
||||
{showTripModal && (
|
||||
<motion.div
|
||||
className="admin-modal-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
onClick={() => setShowTripModal(false)}
|
||||
<Modal
|
||||
isOpen={showTripModal}
|
||||
onClose={() => setShowTripModal(false)}
|
||||
title="Přidat jízdu"
|
||||
maxWidth="md"
|
||||
onSubmit={handleTripSubmit}
|
||||
submitText="Uložit"
|
||||
loading={tripSubmitting}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Vozidlo" required error={tripErrors.vehicle_id}>
|
||||
<Select
|
||||
value={tripForm.vehicle_id}
|
||||
onChange={(val) => {
|
||||
handleTripVehicleChange(val);
|
||||
setTripErrors((prev) => ({ ...prev, vehicle_id: undefined }));
|
||||
}}
|
||||
options={[
|
||||
{ value: "", label: "Vyberte vozidlo" },
|
||||
...tripVehicles.map((v) => ({
|
||||
value: String(v.id),
|
||||
label: `${v.spz} - ${v.name}`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<motion.div
|
||||
className="admin-modal admin-modal-lg"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="admin-modal-header">
|
||||
<h2 className="admin-modal-title">Přidat jízdu</h2>
|
||||
</div>
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
<div className="admin-form-row">
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.vehicle_id ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Vozidlo
|
||||
</label>
|
||||
<select
|
||||
value={tripForm.vehicle_id}
|
||||
onChange={(e) => {
|
||||
handleTripVehicleChange(e.target.value);
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
vehicle_id: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Vyberte vozidlo</option>
|
||||
{tripVehicles.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.spz} - {v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{tripErrors.vehicle_id && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.vehicle_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.trip_date ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Datum jízdy
|
||||
</label>
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={tripForm.trip_date}
|
||||
onChange={(val: string) => {
|
||||
setTripForm((prev) => ({ ...prev, trip_date: val }));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
trip_date: undefined,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{tripErrors.trip_date && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.trip_date}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Datum jízdy" required error={tripErrors.trip_date}>
|
||||
<DateField
|
||||
value={tripForm.trip_date}
|
||||
onChange={(val) => {
|
||||
setTripForm((prev) => ({ ...prev, trip_date: val }));
|
||||
setTripErrors((prev) => ({ ...prev, trip_date: undefined }));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row admin-form-row-3">
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.start_km ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Počáteční stav km
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.start_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
start_km: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
start_km: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
{tripErrors.start_km && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.start_km}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.end_km ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Konečný stav km
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.end_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
end_km: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
end_km: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
min="0"
|
||||
/>
|
||||
{tripErrors.end_km && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.end_km}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Vzdálenost</label>
|
||||
<input
|
||||
type="text"
|
||||
value={`${formatKm(tripDistance())} km`}
|
||||
className="admin-form-input"
|
||||
readOnly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Počáteční stav km" required error={tripErrors.start_km}>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.start_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({ ...prev, start_km: e.target.value }));
|
||||
setTripErrors((prev) => ({ ...prev, start_km: undefined }));
|
||||
}}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Konečný stav km" required error={tripErrors.end_km}>
|
||||
<TextField
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={tripForm.end_km}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({ ...prev, end_km: e.target.value }));
|
||||
setTripErrors((prev) => ({ ...prev, end_km: undefined }));
|
||||
}}
|
||||
slotProps={{ htmlInput: { min: 0 } }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Vzdálenost">
|
||||
<TextField
|
||||
value={`${formatKm(tripDistance())} km`}
|
||||
InputProps={{ readOnly: true }}
|
||||
disabled
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.route_from ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Místo odjezdu
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tripForm.route_from}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_from: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
route_from: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Praha"
|
||||
/>
|
||||
{tripErrors.route_from && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.route_from}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`admin-form-group${tripErrors.route_to ? " has-error" : ""}`}
|
||||
>
|
||||
<label className="admin-form-label required">
|
||||
Místo příjezdu
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tripForm.route_to}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_to: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({
|
||||
...prev,
|
||||
route_to: undefined,
|
||||
}));
|
||||
}}
|
||||
className="admin-form-input"
|
||||
placeholder="Např. Brno"
|
||||
/>
|
||||
{tripErrors.route_to && (
|
||||
<span className="admin-form-error">
|
||||
{tripErrors.route_to}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Místo odjezdu" required error={tripErrors.route_from}>
|
||||
<TextField
|
||||
value={tripForm.route_from}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
route_from: e.target.value,
|
||||
}));
|
||||
setTripErrors((prev) => ({ ...prev, route_from: undefined }));
|
||||
}}
|
||||
placeholder="Např. Praha"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Místo příjezdu" required error={tripErrors.route_to}>
|
||||
<TextField
|
||||
value={tripForm.route_to}
|
||||
onChange={(e) => {
|
||||
setTripForm((prev) => ({ ...prev, route_to: e.target.value }));
|
||||
setTripErrors((prev) => ({ ...prev, route_to: undefined }));
|
||||
}}
|
||||
placeholder="Např. Brno"
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Typ jízdy</label>
|
||||
<select
|
||||
value={tripForm.is_business}
|
||||
onChange={(e) =>
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
is_business: parseInt(e.target.value),
|
||||
}))
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value={1}>Služební</option>
|
||||
<option value={0}>Soukromá</option>
|
||||
</select>
|
||||
</div>
|
||||
<Field label="Typ jízdy">
|
||||
<Select
|
||||
value={String(tripForm.is_business)}
|
||||
onChange={(val) =>
|
||||
setTripForm((prev) => ({ ...prev, is_business: parseInt(val) }))
|
||||
}
|
||||
options={[
|
||||
{ value: "1", label: "Služební" },
|
||||
{ value: "0", label: "Soukromá" },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">Poznámky</label>
|
||||
<textarea
|
||||
value={tripForm.notes}
|
||||
onChange={(e) =>
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
notes: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="admin-form-textarea"
|
||||
rows={2}
|
||||
placeholder="Volitelné poznámky..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTripModal(false)}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={tripSubmitting}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTripSubmit}
|
||||
className="admin-btn admin-btn-primary"
|
||||
disabled={tripSubmitting}
|
||||
>
|
||||
{tripSubmitting ? "Ukládám..." : "Uložit"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Field label="Poznámky">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={2}
|
||||
value={tripForm.notes}
|
||||
onChange={(e) =>
|
||||
setTripForm((prev) => ({ ...prev, notes: e.target.value }))
|
||||
}
|
||||
placeholder="Volitelné poznámky..."
|
||||
/>
|
||||
</Field>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
import ConfirmModal from "../ConfirmModal";
|
||||
import useModalLock from "../../hooks/useModalLock";
|
||||
import { Card, Button, StatusChip, ConfirmDialog } from "../../ui";
|
||||
import apiFetch from "../../utils/api";
|
||||
import { formatSessionDate } from "../../utils/dashboardHelpers";
|
||||
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
|
||||
@@ -76,8 +79,6 @@ export default function DashSessions() {
|
||||
const [deleteAllModal, setDeleteAllModal] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useModalLock(deleteAllModal);
|
||||
|
||||
const handleDeleteSession = async () => {
|
||||
if (!deleteModal.session) {
|
||||
return;
|
||||
@@ -127,113 +128,140 @@ export default function DashSessions() {
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
>
|
||||
<div
|
||||
className="admin-card-header"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<h2 className="admin-card-title">Přihlášená zařízení</h2>
|
||||
{sessions.filter((s) => !s.is_current).length > 0 && (
|
||||
<button
|
||||
onClick={() => setDeleteAllModal(true)}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
Odhlásit ostatní
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 1.5,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">Přihlášená zařízení</Typography>
|
||||
{sessions.filter((s) => !s.is_current).length > 0 && (
|
||||
<Button
|
||||
onClick={() => setDeleteAllModal(true)}
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
>
|
||||
Odhlásit ostatní
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{sessionsLoading ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
|
||||
<CircularProgress size={28} />
|
||||
</Box>
|
||||
) : sessions.length === 0 ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ textAlign: "center", py: 3 }}
|
||||
>
|
||||
Žádné aktivní relace
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
{sessions.length === 0 && (
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
textAlign: "center",
|
||||
fontSize: "0.875rem",
|
||||
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||
{sessions.map((session) => (
|
||||
<Box
|
||||
key={session.id}
|
||||
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 }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
Žádné aktivní relace
|
||||
</div>
|
||||
)}
|
||||
{sessions.length > 0 && (
|
||||
<div className="dash-sessions-list">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`dash-session-item ${session.is_current ? "dash-session-item-current" : ""}`}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "text.secondary",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{getDeviceIcon(session.device_info?.icon)}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div className="dash-session-icon">
|
||||
{getDeviceIcon(session.device_info?.icon)}
|
||||
</div>
|
||||
<div className="dash-session-info">
|
||||
<div className="dash-session-device">
|
||||
{session.device_info?.browser} na{" "}
|
||||
{session.device_info?.os}
|
||||
{session.is_current && (
|
||||
<span
|
||||
className="admin-badge admin-badge-success"
|
||||
style={{ marginLeft: "0.5rem" }}
|
||||
>
|
||||
Aktuální
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="dash-session-meta">
|
||||
<span>{session.ip_address}</span>
|
||||
<span className="dash-session-meta-separator">|</span>
|
||||
<span>{formatSessionDate(session.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dash-session-actions">
|
||||
{!session.is_current && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteModal({ isOpen: true, session })
|
||||
}
|
||||
className="admin-btn-icon danger"
|
||||
title="Ukončit relaci"
|
||||
aria-label="Ukončit relaci"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{session.device_info?.browser} na{" "}
|
||||
{session.device_info?.os}
|
||||
</Typography>
|
||||
{session.is_current && (
|
||||
<StatusChip color="success" label="Aktuální" />
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 0.75,
|
||||
color: "text.secondary",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
<span>{session.ip_address}</span>
|
||||
<span>|</span>
|
||||
<span>{formatSessionDate(session.created_at)}</span>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ flexShrink: 0 }}>
|
||||
{!session.is_current && (
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setDeleteModal({ isOpen: true, session })
|
||||
}
|
||||
color="error"
|
||||
size="small"
|
||||
title="Ukončit relaci"
|
||||
aria-label="Ukončit relaci"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={() => setDeleteModal({ isOpen: false, session: null })}
|
||||
onConfirm={handleDeleteSession}
|
||||
@@ -241,10 +269,10 @@ export default function DashSessions() {
|
||||
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
|
||||
confirmText="Ukončit"
|
||||
cancelText="Zrušit"
|
||||
type="danger"
|
||||
confirmVariant="danger"
|
||||
loading={deleting}
|
||||
/>
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteAllModal}
|
||||
onClose={() => setDeleteAllModal(false)}
|
||||
onConfirm={handleDeleteAllSessions}
|
||||
@@ -252,7 +280,7 @@ export default function DashSessions() {
|
||||
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
|
||||
confirmText="Odhlásit vše"
|
||||
cancelText="Zrušit"
|
||||
type="warning"
|
||||
confirmVariant="primary"
|
||||
loading={deleting}
|
||||
/>
|
||||
</>
|
||||
|
||||
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 { createPortal } from "react-dom";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { warehouseItemListOptions } from "../../lib/queries/warehouse";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {
|
||||
warehouseItemListOptions,
|
||||
type WarehouseItem,
|
||||
} from "../../lib/queries/warehouse";
|
||||
|
||||
interface ItemPickerProps {
|
||||
value: number | null;
|
||||
@@ -9,220 +16,88 @@ interface ItemPickerProps {
|
||||
itemName?: string;
|
||||
}
|
||||
|
||||
/** Minimal option shape used to display a selected value that isn't in the
|
||||
* current search results (edit/prefill via `itemName`). */
|
||||
type ItemOption = Pick<WarehouseItem, "id" | "name"> & Partial<WarehouseItem>;
|
||||
|
||||
export default function ItemPicker({
|
||||
value,
|
||||
onChange,
|
||||
itemName,
|
||||
}: ItemPickerProps) {
|
||||
const [search, setSearch] = useState(itemName ?? "");
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));
|
||||
const items = data?.data ?? [];
|
||||
const [inputValue, setInputValue] = useState(itemName ?? "");
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const activeIndexRef = useRef<number>(-1);
|
||||
const listboxId = useId();
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const { data, isFetching } = useQuery(
|
||||
warehouseItemListOptions({ search: inputValue, perPage: 20 }),
|
||||
);
|
||||
const options: ItemOption[] = data?.data ?? [];
|
||||
|
||||
const [dropdownStyle, setDropdownStyle] = useState<{
|
||||
position: "fixed";
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
zIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setDropdownStyle({
|
||||
position: "fixed",
|
||||
top: rect.bottom + 2,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
zIndex: 100,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 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;
|
||||
// Show the selected value even when it isn't in the current search results
|
||||
// (e.g. prefilled via `itemName`) by synthesizing a fallback option.
|
||||
const selectedOption: ItemOption | null =
|
||||
value == null
|
||||
? null
|
||||
: (options.find((o) => o.id === value) ?? {
|
||||
id: value,
|
||||
name: itemName ?? "",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ position: "relative" }}
|
||||
role="combobox"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-owns={listboxId}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
placeholder="Hledat položku..."
|
||||
value={search}
|
||||
role="searchbox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeId}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
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>
|
||||
<Autocomplete<ItemOption>
|
||||
value={selectedOption}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(_, newInput) => setInputValue(newInput)}
|
||||
onChange={(_, opt) => {
|
||||
if (opt) onChange(opt.id);
|
||||
}}
|
||||
options={options}
|
||||
loading={isFetching}
|
||||
filterOptions={(x) => x}
|
||||
getOptionLabel={(o) => o.name}
|
||||
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||
noOptionsText="Žádné položky"
|
||||
renderOption={(props, option) => {
|
||||
const { key, ...rest } = props;
|
||||
return (
|
||||
<Box component="li" key={key} {...rest}>
|
||||
<Box>
|
||||
<Typography variant="body2">{option.name}</Typography>
|
||||
{(option.item_number ||
|
||||
option.available_quantity !== undefined) && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{option.item_number}
|
||||
{option.item_number && option.available_quantity !== undefined
|
||||
? " · "
|
||||
: ""}
|
||||
{option.available_quantity !== undefined
|
||||
? `${option.available_quantity} ${option.unit ?? ""}`
|
||||
: ""}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
size="small"
|
||||
placeholder="Hledat položku..."
|
||||
slotProps={{
|
||||
input: {
|
||||
...params.InputProps,
|
||||
endAdornment: (
|
||||
<>
|
||||
{isFetching ? (
|
||||
<CircularProgress color="inherit" size={18} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 MenuItem from "@mui/material/MenuItem";
|
||||
import { Select } from "../../ui";
|
||||
import { warehouseReservationListOptions } from "../../lib/queries/warehouse";
|
||||
|
||||
interface ReservationPickerProps {
|
||||
@@ -26,11 +28,9 @@ export default function ReservationPicker({
|
||||
const reservations = result?.data ?? [];
|
||||
|
||||
return (
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
<Select
|
||||
value={value == null ? "" : String(value)}
|
||||
onChange={(val) => {
|
||||
if (!val) {
|
||||
onChange(null, 0);
|
||||
return;
|
||||
@@ -42,13 +42,13 @@ export default function ReservationPicker({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">— žádná rezervace —</option>
|
||||
<MenuItem value="">— žádná rezervace —</MenuItem>
|
||||
{reservations.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
<MenuItem key={r.id} value={String(r.id)}>
|
||||
R{r.id} — {r.project?.name ?? `Projekt #${r.project_id}`} — zbývá:{" "}
|
||||
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
|
||||
</option>
|
||||
</MenuItem>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
| { kind: "closed" }
|
||||
| { kind: "create"; userId: number; date: string }
|
||||
| { kind: "create-override"; userId: number; date: string }
|
||||
| { kind: "edit-range"; entryId: number; userId: number; date: string }
|
||||
| { kind: "edit-override"; overrideId: number; userId: number; date: string }
|
||||
| { kind: "day-in-range"; userId: number; date: string; entryId: number }
|
||||
| { kind: "day"; userId: number; date: string }
|
||||
| { kind: "view"; userId: number; date: string };
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
@@ -150,16 +152,16 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
key: readonly unknown[],
|
||||
userId: number,
|
||||
dates: string[],
|
||||
mutator: (prev: ResolvedCell | null) => ResolvedCell | null,
|
||||
): Record<string, ResolvedCell | null> | null {
|
||||
mutator: (prev: ResolvedCell[]) => ResolvedCell[],
|
||||
): Record<string, ResolvedCell[]> | null {
|
||||
const prev = snapshotGrid(key);
|
||||
if (!prev) return null;
|
||||
const userPrev = prev.cells[userId] ?? {};
|
||||
const rolled: Record<string, ResolvedCell | null> = {};
|
||||
const userNext: Record<string, ResolvedCell | null> = { ...userPrev };
|
||||
const rolled: Record<string, ResolvedCell[]> = {};
|
||||
const userNext: Record<string, ResolvedCell[]> = { ...userPrev };
|
||||
for (const date of dates) {
|
||||
rolled[date] = userPrev[date] ?? null;
|
||||
userNext[date] = mutator(rolled[date]);
|
||||
rolled[date] = userPrev[date] ?? [];
|
||||
userNext[date] = mutator(rolled[date]).slice(0, 3);
|
||||
}
|
||||
qc.setQueryData(key, {
|
||||
...prev,
|
||||
@@ -244,7 +246,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
// the ResolvedCell mirrors the request body.
|
||||
const days = eachDay(body.date_from, body.date_to);
|
||||
const id = data && typeof data.id === "number" ? data.id : null;
|
||||
const rolled = patchCells(currentGridKey, body.user_id, days, () =>
|
||||
const rolled = patchCells(currentGridKey, body.user_id, days, (prev) => [
|
||||
makeEntryCell({
|
||||
userId: body.user_id,
|
||||
date: days[0],
|
||||
@@ -255,7 +257,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
rangeTo: body.date_to,
|
||||
entryId: id,
|
||||
}),
|
||||
);
|
||||
...prev,
|
||||
]);
|
||||
// Stash the rollback on the mutation object so PlanWork can call
|
||||
// it from onError.
|
||||
(createEntry as any)._rolled = rolled;
|
||||
@@ -294,8 +297,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
let ownerUserId: number | null = null;
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const cell of Object.values(byDate)) {
|
||||
if (cell && cell.entryId === id) {
|
||||
for (const cells of Object.values(byDate)) {
|
||||
if (cells.some((c) => c.entryId === id)) {
|
||||
ownerUserId = Number(uidStr);
|
||||
break;
|
||||
}
|
||||
@@ -304,20 +307,27 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
}
|
||||
}
|
||||
if (ownerUserId !== null) {
|
||||
const rolled = patchCells(currentGridKey, ownerUserId, days, (prev) =>
|
||||
makeEntryCell({
|
||||
userId: ownerUserId!,
|
||||
date: days[0],
|
||||
projectId:
|
||||
body.project_id === undefined
|
||||
? (prev?.project_id ?? null)
|
||||
: body.project_id,
|
||||
category: body.category ?? prev?.category ?? "work",
|
||||
note: body.note ?? prev?.note ?? "",
|
||||
rangeFrom: body.date_from,
|
||||
rangeTo: body.date_to,
|
||||
entryId: id,
|
||||
}),
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
ownerUserId,
|
||||
days,
|
||||
(prev) => {
|
||||
const existing = prev.find((c) => c.entryId === id) ?? null;
|
||||
const updated = makeEntryCell({
|
||||
userId: ownerUserId!,
|
||||
date: days[0],
|
||||
projectId:
|
||||
body.project_id === undefined
|
||||
? (existing?.project_id ?? null)
|
||||
: body.project_id,
|
||||
category: body.category ?? existing?.category ?? "work",
|
||||
note: body.note ?? existing?.note ?? "",
|
||||
rangeFrom: body.date_from,
|
||||
rangeTo: body.date_to,
|
||||
entryId: id,
|
||||
});
|
||||
return [updated, ...prev.filter((c) => c.entryId !== id)];
|
||||
},
|
||||
);
|
||||
(updateEntry as any)._rolled = rolled;
|
||||
}
|
||||
@@ -338,24 +348,27 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [, cell] of Object.entries(byDate)) {
|
||||
if (
|
||||
cell &&
|
||||
cell.entryId === vars.id &&
|
||||
cell.rangeFrom &&
|
||||
cell.rangeTo
|
||||
) {
|
||||
const days = eachDay(cell.rangeFrom, cell.rangeTo);
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
days,
|
||||
() => null,
|
||||
);
|
||||
(deleteEntry as any)._rolled = rolled;
|
||||
let range: { from: string; to: string } | null = null;
|
||||
for (const cells of Object.values(byDate)) {
|
||||
const hit = cells.find(
|
||||
(c) => c.entryId === vars.id && c.rangeFrom && c.rangeTo,
|
||||
);
|
||||
if (hit) {
|
||||
range = { from: hit.rangeFrom!, to: hit.rangeTo! };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (range) {
|
||||
const days = eachDay(range.from, range.to);
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
days,
|
||||
(prev) => prev.filter((c) => c.entryId !== vars.id),
|
||||
);
|
||||
(deleteEntry as any)._rolled = rolled;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
@@ -375,7 +388,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
currentGridKey,
|
||||
vars.user_id,
|
||||
[vars.shift_date],
|
||||
() =>
|
||||
(prev) => [
|
||||
makeOverrideCell({
|
||||
userId: vars.user_id,
|
||||
date: vars.shift_date,
|
||||
@@ -384,6 +397,8 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
note: vars.note,
|
||||
overrideId: id,
|
||||
}),
|
||||
...prev,
|
||||
],
|
||||
);
|
||||
(createOverride as any)._rolled = rolled;
|
||||
invalidate();
|
||||
@@ -411,21 +426,30 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cell] of Object.entries(byDate)) {
|
||||
if (cell && cell.overrideId === vars.id) {
|
||||
for (const [date, cells] of Object.entries(byDate)) {
|
||||
if (cells.some((c) => c.overrideId === vars.id)) {
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
[date],
|
||||
(prev) =>
|
||||
makeOverrideCell({
|
||||
(prev) => {
|
||||
const existing =
|
||||
prev.find((c) => c.overrideId === vars.id) ?? null;
|
||||
const updated = makeOverrideCell({
|
||||
userId: Number(uidStr),
|
||||
date,
|
||||
projectId: vars.body.project_id ?? prev?.project_id ?? null,
|
||||
category: vars.body.category ?? prev?.category ?? "work",
|
||||
note: vars.body.note ?? prev?.note ?? "",
|
||||
projectId:
|
||||
vars.body.project_id ?? existing?.project_id ?? null,
|
||||
category:
|
||||
vars.body.category ?? existing?.category ?? "work",
|
||||
note: vars.body.note ?? existing?.note ?? "",
|
||||
overrideId: vars.id,
|
||||
}),
|
||||
});
|
||||
return [
|
||||
updated,
|
||||
...prev.filter((c) => c.overrideId !== vars.id),
|
||||
];
|
||||
},
|
||||
);
|
||||
(updateOverride as any)._rolled = rolled;
|
||||
break;
|
||||
@@ -446,13 +470,13 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cell] of Object.entries(byDate)) {
|
||||
if (cell && cell.overrideId === vars.id) {
|
||||
for (const [date, cells] of Object.entries(byDate)) {
|
||||
if (cells.some((c) => c.overrideId === vars.id)) {
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
[date],
|
||||
() => null,
|
||||
(prev) => prev.filter((c) => c.overrideId !== vars.id),
|
||||
);
|
||||
(deleteOverride as any)._rolled = rolled;
|
||||
break;
|
||||
@@ -464,6 +488,20 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
},
|
||||
});
|
||||
|
||||
const bulkCreate = useMutation({
|
||||
mutationFn: (body: any) =>
|
||||
apiCall("/api/admin/plan/entries/bulk", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
// No optimistic patch — bulk can touch many cells; just invalidate and
|
||||
// let the grid refetch the authoritative state.
|
||||
invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Roll back an optimistic patch on mutation error. Called from
|
||||
// PlanWork's mutation wrappers via `rollbackMutation(mutation, key)`.
|
||||
// `mutation` is a TanStack mutation result with a `_rolled` snapshot stashed
|
||||
@@ -472,15 +510,15 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
// directly without the "weak type" mismatch a `{ _rolled? }` param causes.
|
||||
function rollbackMutation(mutation: unknown, userId: number) {
|
||||
const stash = mutation as {
|
||||
_rolled?: Record<string, ResolvedCell | null> | null;
|
||||
_rolled?: Record<string, ResolvedCell[]> | null;
|
||||
};
|
||||
if (!stash._rolled) return;
|
||||
const prev = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (!prev) return;
|
||||
const userPrev = prev.cells[userId] ?? {};
|
||||
const userNext = { ...userPrev };
|
||||
for (const [date, cell] of Object.entries(stash._rolled)) {
|
||||
userNext[date] = cell;
|
||||
for (const [date, cells] of Object.entries(stash._rolled)) {
|
||||
userNext[date] = cells;
|
||||
}
|
||||
qc.setQueryData(currentGridKey, {
|
||||
...prev,
|
||||
@@ -510,6 +548,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
createOverride,
|
||||
updateOverride,
|
||||
deleteOverride,
|
||||
bulkCreate,
|
||||
invalidate,
|
||||
// Exposed so PlanWork can roll back an optimistic patch when a
|
||||
// mutation throws. Pass the failed mutation and the userId it
|
||||
@@ -518,10 +557,18 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
};
|
||||
}
|
||||
|
||||
export function getCells(
|
||||
grid: GridData | undefined,
|
||||
userId: number,
|
||||
date: string,
|
||||
): ResolvedCell[] {
|
||||
return grid?.cells?.[userId]?.[date] ?? [];
|
||||
}
|
||||
|
||||
export function getCell(
|
||||
grid: GridData | undefined,
|
||||
userId: number,
|
||||
date: string,
|
||||
): ResolvedCell | null {
|
||||
return grid?.cells?.[userId]?.[date] ?? null;
|
||||
return getCells(grid, userId, date)[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -82,3 +82,12 @@ export const orderDetailOptions = (id: string | undefined) =>
|
||||
queryFn: () => jsonQuery<OrderData>(`/api/admin/orders/${id}`),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
export const orderNextNumberOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["orders", "next-number"],
|
||||
queryFn: () =>
|
||||
jsonQuery<{ next_number?: string; number?: string }>(
|
||||
"/api/admin/orders/next-number",
|
||||
),
|
||||
});
|
||||
|
||||
@@ -81,7 +81,7 @@ export interface GridData {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
users: PlanUser[];
|
||||
cells: Record<number, Record<string, ResolvedCell | null>>;
|
||||
cells: Record<number, Record<string, ResolvedCell[]>>;
|
||||
}
|
||||
|
||||
export const planKeys = {
|
||||
|
||||
@@ -109,3 +109,12 @@ export const projectFilesOptions = (projectId: number, path: string) =>
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const projectNextNumberOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["projects", "next-number"],
|
||||
queryFn: () =>
|
||||
jsonQuery<{ next_number?: string; number?: string }>(
|
||||
"/api/admin/projects/next-number",
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
/* ============================================================================
|
||||
Login Page
|
||||
============================================================================ */
|
||||
|
||||
.admin-login {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
max-height: 100vh;
|
||||
max-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.admin-login .bg-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-login .bg-orb-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: var(--orb-color-1);
|
||||
top: 10%;
|
||||
left: 20%;
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.admin-login .bg-orb-2 {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
background: var(--orb-color-2);
|
||||
bottom: 20%;
|
||||
right: 15%;
|
||||
animation: float 25s ease-in-out infinite reverse;
|
||||
}
|
||||
|
||||
.admin-login-theme-btn {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
transition: var(--transition);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.admin-login-theme-btn:hover {
|
||||
background: var(--glass-bg-solid);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color-hover);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.admin-login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: calc(100vh - 2rem);
|
||||
max-height: calc(100dvh - 2rem);
|
||||
padding: 2rem;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: var(--glass-shadow);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.admin-login-card {
|
||||
padding: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.admin-login-logo {
|
||||
height: 48px;
|
||||
width: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-login-2fa-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-login-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-heading);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-login-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.admin-back-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: var(--transition);
|
||||
margin-top: 1.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.admin-back-link:hover {
|
||||
color: var(--text-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -1,621 +0,0 @@
|
||||
/* ============================================
|
||||
Offers Module
|
||||
============================================ */
|
||||
|
||||
/* Items table */
|
||||
.offers-items-table {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.offers-items-table .admin-table {
|
||||
min-width: 700px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.offers-items-table .admin-table thead th {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-tertiary);
|
||||
padding: 8px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.offers-items-table .admin-table td {
|
||||
vertical-align: top;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.offers-items-table .admin-table tbody tr {
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.offers-items-table .admin-table tbody tr:hover {
|
||||
background: var(--table-row-hover);
|
||||
}
|
||||
|
||||
.offers-items-table .admin-table td .admin-form-input {
|
||||
display: block;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* Language badges */
|
||||
.offers-lang-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.5rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
background: color-mix(in srgb, var(--info) 15%, transparent);
|
||||
color: var(--info);
|
||||
margin-right: 0.375rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.offers-lang-badge-cz {
|
||||
background: color-mix(in srgb, var(--danger) 15%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* RichEditor (Quill) */
|
||||
.admin-rich-editor {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.admin-rich-editor .quill {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.admin-rich-editor .ql-toolbar.ql-snow {
|
||||
background: var(--bg-secondary);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
padding: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-rich-editor .ql-toolbar .ql-formats {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Toolbar buttons */
|
||||
.admin-rich-editor .ql-snow .ql-stroke {
|
||||
stroke: var(--text-secondary);
|
||||
}
|
||||
.admin-rich-editor .ql-snow .ql-fill {
|
||||
fill: var(--text-secondary);
|
||||
}
|
||||
.admin-rich-editor .ql-snow .ql-picker-label {
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.admin-rich-editor .ql-snow button:hover .ql-stroke,
|
||||
.admin-rich-editor .ql-snow .ql-picker-label:hover .ql-stroke {
|
||||
stroke: var(--text-primary);
|
||||
}
|
||||
.admin-rich-editor .ql-snow button:hover .ql-fill,
|
||||
.admin-rich-editor .ql-snow .ql-picker-label:hover .ql-fill {
|
||||
fill: var(--text-primary);
|
||||
}
|
||||
.admin-rich-editor .ql-snow button:hover,
|
||||
.admin-rich-editor .ql-snow .ql-picker-label:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Active state */
|
||||
.admin-rich-editor .ql-snow button.ql-active {
|
||||
color: var(--accent-color);
|
||||
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.admin-rich-editor .ql-snow button.ql-active .ql-stroke {
|
||||
stroke: var(--accent-color);
|
||||
}
|
||||
.admin-rich-editor .ql-snow button.ql-active .ql-fill,
|
||||
.admin-rich-editor .ql-snow button.ql-active .ql-stroke.ql-fill {
|
||||
fill: var(--accent-color);
|
||||
}
|
||||
.admin-rich-editor .ql-snow .ql-picker-item.ql-selected {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.admin-rich-editor .ql-snow .ql-picker-label.ql-active {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.admin-rich-editor .ql-snow .ql-picker-label.ql-active .ql-stroke {
|
||||
stroke: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Dropdowns (font, size, color, align) */
|
||||
.admin-rich-editor .ql-snow .ql-picker-options {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-rich-editor .ql-snow .ql-picker-item {
|
||||
color: var(--text-secondary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.admin-rich-editor .ql-snow .ql-picker-item:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Font picker */
|
||||
.admin-rich-editor .ql-snow .ql-font .ql-picker-options {
|
||||
min-width: 11rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.admin-rich-editor .ql-snow .ql-size .ql-picker-options {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Font labels - vysoka specificita kvuli quill.snow.css */
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="arial"]::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="arial"]::before {
|
||||
content: "Arial" !important;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="tahoma"]::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="tahoma"]::before {
|
||||
content: "Tahoma" !important;
|
||||
font-family: Tahoma, sans-serif;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="verdana"]::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="verdana"]::before {
|
||||
content: "Verdana" !important;
|
||||
font-family: Verdana, sans-serif;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="georgia"]::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="georgia"]::before {
|
||||
content: "Georgia" !important;
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
.ql-snow
|
||||
.ql-picker.ql-font
|
||||
.ql-picker-label[data-value="times-new-roman"]::before,
|
||||
.ql-snow
|
||||
.ql-picker.ql-font
|
||||
.ql-picker-item[data-value="times-new-roman"]::before {
|
||||
content: "Times New Roman" !important;
|
||||
font-family: "Times New Roman", serif;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="courier-new"]::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="courier-new"]::before {
|
||||
content: "Courier New" !important;
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="trebuchet-ms"]::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="trebuchet-ms"]::before {
|
||||
content: "Trebuchet MS" !important;
|
||||
font-family: "Trebuchet MS", sans-serif;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="impact"]::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="impact"]::before {
|
||||
content: "Impact" !important;
|
||||
font-family: Impact, sans-serif;
|
||||
}
|
||||
.ql-snow
|
||||
.ql-picker.ql-font
|
||||
.ql-picker-label[data-value="comic-sans-ms"]::before,
|
||||
.ql-snow
|
||||
.ql-picker.ql-font
|
||||
.ql-picker-item[data-value="comic-sans-ms"]::before {
|
||||
content: "Comic Sans MS" !important;
|
||||
font-family: "Comic Sans MS", cursive;
|
||||
}
|
||||
.ql-snow
|
||||
.ql-picker.ql-font
|
||||
.ql-picker-label[data-value="lucida-console"]::before,
|
||||
.ql-snow
|
||||
.ql-picker.ql-font
|
||||
.ql-picker-item[data-value="lucida-console"]::before {
|
||||
content: "Lucida Console" !important;
|
||||
font-family: "Lucida Console", monospace;
|
||||
}
|
||||
.ql-snow
|
||||
.ql-picker.ql-font
|
||||
.ql-picker-label[data-value="palatino-linotype"]::before,
|
||||
.ql-snow
|
||||
.ql-picker.ql-font
|
||||
.ql-picker-item[data-value="palatino-linotype"]::before {
|
||||
content: "Palatino Linotype" !important;
|
||||
font-family: "Palatino Linotype", serif;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value="garamond"]::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value="garamond"]::before {
|
||||
content: "Garamond" !important;
|
||||
font-family: Garamond, serif;
|
||||
}
|
||||
|
||||
/* Font classes */
|
||||
.ql-font-arial {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.ql-font-tahoma {
|
||||
font-family: Tahoma, sans-serif;
|
||||
}
|
||||
.ql-font-verdana {
|
||||
font-family: Verdana, sans-serif;
|
||||
}
|
||||
.ql-font-georgia {
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
.ql-font-times-new-roman {
|
||||
font-family: "Times New Roman", serif;
|
||||
}
|
||||
.ql-font-courier-new {
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
.ql-font-trebuchet-ms {
|
||||
font-family: "Trebuchet MS", sans-serif;
|
||||
}
|
||||
.ql-font-impact {
|
||||
font-family: Impact, sans-serif;
|
||||
}
|
||||
.ql-font-comic-sans-ms {
|
||||
font-family: "Comic Sans MS", cursive;
|
||||
}
|
||||
.ql-font-lucida-console {
|
||||
font-family: "Lucida Console", monospace;
|
||||
}
|
||||
.ql-font-palatino-linotype {
|
||||
font-family: "Palatino Linotype", serif;
|
||||
}
|
||||
.ql-font-garamond {
|
||||
font-family: Garamond, serif;
|
||||
}
|
||||
|
||||
/* Size picker */
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="8px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="8px"]::before {
|
||||
content: "8px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="9px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="9px"]::before {
|
||||
content: "9px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="10px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="10px"]::before {
|
||||
content: "10px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="11px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="11px"]::before {
|
||||
content: "11px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="12px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="12px"]::before {
|
||||
content: "12px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="14px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="14px"]::before {
|
||||
content: "14px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before {
|
||||
content: "16px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="18px"]::before {
|
||||
content: "18px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="20px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="20px"]::before {
|
||||
content: "20px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="24px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="24px"]::before {
|
||||
content: "24px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="28px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="28px"]::before {
|
||||
content: "28px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="32px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="32px"]::before {
|
||||
content: "32px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="36px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="36px"]::before {
|
||||
content: "36px" !important;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value="48px"]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value="48px"]::before {
|
||||
content: "48px" !important;
|
||||
}
|
||||
|
||||
/* Editor area */
|
||||
.admin-rich-editor .ql-container.ql-snow {
|
||||
border: none;
|
||||
border-radius: 0 0 0.5rem 0.5rem;
|
||||
font-family: Tahoma, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-rich-editor .ql-editor {
|
||||
min-height: var(--re-min-height, 120px);
|
||||
padding: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
font-family: Tahoma, sans-serif;
|
||||
font-size: 14px;
|
||||
background: var(--input-bg);
|
||||
}
|
||||
|
||||
.admin-rich-editor .ql-editor.ql-blank::before {
|
||||
color: var(--text-tertiary);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Lists inside editor */
|
||||
.admin-rich-editor .ql-editor ul,
|
||||
.admin-rich-editor .ql-editor ol {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
/* Color picker */
|
||||
.admin-rich-editor
|
||||
.ql-snow
|
||||
.ql-color-picker
|
||||
.ql-picker-options[aria-hidden="false"] {
|
||||
width: 176px;
|
||||
padding: 0.375rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-rich-editor .ql-snow .ql-color-picker .ql-picker-item {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tooltip (link editor) */
|
||||
.admin-rich-editor .ql-snow .ql-tooltip {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.admin-rich-editor .ql-snow .ql-tooltip input[type="text"] {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--text-primary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.admin-rich-editor .ql-snow .ql-tooltip a {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Read-only rendered rich text (Quill HTML output) */
|
||||
.admin-rich-text-view {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
font-size: 0.875rem;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-rich-text-view ul,
|
||||
.admin-rich-text-view ol {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.25rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.admin-rich-text-view li {
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.admin-rich-text-view a {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.admin-rich-text-view strong,
|
||||
.admin-rich-text-view b {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-rich-text-view br + b,
|
||||
.admin-rich-text-view br + strong {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-rich-text-view > br:first-child,
|
||||
.admin-rich-text-view ul + br,
|
||||
.admin-rich-text-view ol + br {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.offers-items-table .admin-table td .admin-form-input {
|
||||
font-size: 16px;
|
||||
min-height: 44px;
|
||||
padding: 9px 10px;
|
||||
}
|
||||
|
||||
/* Give the description column more room by shrinking numeric columns */
|
||||
.offers-col-qty {
|
||||
width: 4rem !important;
|
||||
}
|
||||
.offers-col-unit {
|
||||
width: 4rem !important;
|
||||
}
|
||||
.offers-col-price {
|
||||
width: 5.5rem !important;
|
||||
}
|
||||
.offers-col-included {
|
||||
width: 3.5rem !important;
|
||||
}
|
||||
.offers-col-total {
|
||||
width: 5.5rem !important;
|
||||
}
|
||||
.offers-col-del {
|
||||
width: 2.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.offers-items-table {
|
||||
margin: 0 -1rem;
|
||||
width: calc(100% + 2rem);
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.offers-items-table .admin-table {
|
||||
min-width: 700px;
|
||||
}
|
||||
|
||||
.offers-items-table .admin-table td {
|
||||
padding: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.offers-items-table .admin-table th {
|
||||
font-size: 10px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
/* Further reduce numeric columns on very small screens */
|
||||
.offers-col-qty {
|
||||
width: 3.5rem !important;
|
||||
}
|
||||
.offers-col-unit {
|
||||
width: 3.5rem !important;
|
||||
}
|
||||
.offers-col-price {
|
||||
width: 5rem !important;
|
||||
}
|
||||
.offers-col-total {
|
||||
width: 5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Offer draft row in table */
|
||||
.offers-draft-row {
|
||||
background: var(--row-draft);
|
||||
}
|
||||
|
||||
.offers-draft-row-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 14%, transparent);
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
/* Expired offer without order */
|
||||
.offers-expired-row {
|
||||
background: var(--row-expired);
|
||||
}
|
||||
|
||||
.offers-expired-row td {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.offers-expired-row a {
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
/* Invalidated offer */
|
||||
.offers-invalidated-row {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.offers-invalidated-row td {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.offers-invalidated-row a {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
/* Completed offer */
|
||||
.offers-completed-row {
|
||||
background: var(
|
||||
--row-completed,
|
||||
color-mix(in srgb, var(--success) 8%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
/* Read-only form (invalidated offer detail) */
|
||||
.offers-readonly input[readonly],
|
||||
.offers-readonly select:disabled {
|
||||
background-color: var(--bg-secondary);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Offer draft indicator */
|
||||
/* Filters */
|
||||
.admin-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.admin-filter-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.admin-filter-group .admin-tabs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-filter-group .admin-form-select {
|
||||
min-width: 10rem;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,29 @@
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import ConfirmModal from "../components/ConfirmModal";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import BulkAttendanceModal from "../components/BulkAttendanceModal";
|
||||
import ShiftFormModal from "../components/ShiftFormModal";
|
||||
import AttendanceShiftTable from "../components/AttendanceShiftTable";
|
||||
import useModalLock from "../hooks/useModalLock";
|
||||
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
|
||||
import FormField from "../components/FormField";
|
||||
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
FilterBar,
|
||||
LoadingState,
|
||||
MonthField,
|
||||
PageEnter,
|
||||
Select,
|
||||
StatCard,
|
||||
StatusChip,
|
||||
ProgressBar,
|
||||
type ProgressColor,
|
||||
type StatCardColor,
|
||||
} from "../ui";
|
||||
|
||||
interface UserTotalData {
|
||||
name: string;
|
||||
@@ -35,23 +48,57 @@ interface UserTotalData {
|
||||
worked_holiday_hours?: number;
|
||||
}
|
||||
|
||||
function getFundBarBackground(data: UserTotalData) {
|
||||
// fondUsed mirrors the Fond "used" line in the IIFE below:
|
||||
// real_worked + vacation + worked_holiday + free_holiday
|
||||
// (delta is fondUsed - fund; 0 means exactly at the fund).
|
||||
/** Fond "used" total mirrors the Fond footer line below:
|
||||
* real_worked + vacation + worked_holiday + free_holiday. */
|
||||
function getFondUsed(data: UserTotalData): number {
|
||||
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
||||
const fondUsed =
|
||||
return (
|
||||
realWorked +
|
||||
(data.vacation_hours ?? 0) +
|
||||
(data.worked_holiday_hours ?? 0) +
|
||||
(data.svatek ?? 0);
|
||||
const fundVal = data.fund ?? 0;
|
||||
const delta = fondUsed - fundVal;
|
||||
if (delta > 0) return "linear-gradient(135deg, var(--warning), #d97706)";
|
||||
if (delta >= 0) return "linear-gradient(135deg, var(--success), #059669)";
|
||||
return "var(--gradient)";
|
||||
(data.svatek ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Maps the Fond delta (used − fund) to a ProgressBar color token, mirroring
|
||||
* the legacy getFundBarBackground conditional: over fund → warning, exactly
|
||||
* at fund → success, under fund → primary (was the accent gradient). */
|
||||
function getFundBarColor(data: UserTotalData): ProgressColor {
|
||||
const delta = getFondUsed(data) - (data.fund ?? 0);
|
||||
if (delta > 0) return "warning";
|
||||
if (delta >= 0) return "success";
|
||||
return "primary";
|
||||
}
|
||||
|
||||
const PrintIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="6 9 6 2 18 2 18 9" />
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="6" y="14" width="12" height="8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AddIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AttendanceAdmin() {
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
@@ -111,279 +158,238 @@ export default function AttendanceAdmin() {
|
||||
Object.keys(data.user_totals).length === 0;
|
||||
|
||||
if (isInitialLoad) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
const hasTotals = Object.keys(data.user_totals).length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
<PageEnter>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Správa docházky</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<Typography variant="h4">Správa docházky</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||
{hasData && (
|
||||
<button
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={PrintIcon}
|
||||
onClick={handlePrint}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
title="Tisk docházky"
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
style={{ marginRight: "0.5rem" }}
|
||||
>
|
||||
<polyline points="6 9 6 2 18 2 18 9" />
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="6" y="14" width="12" height="8" />
|
||||
</svg>
|
||||
Tisk
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={openBulkModal}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
<Button variant="outlined" color="inherit" onClick={openBulkModal}>
|
||||
Vyplnit měsíc
|
||||
</button>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button startIcon={AddIcon} onClick={openCreateModal}>
|
||||
Přidat záznam
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Filters */}
|
||||
<motion.div
|
||||
className="admin-card mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Měsíc">
|
||||
<AdminDatePicker
|
||||
mode="month"
|
||||
value={month}
|
||||
onChange={(val: string) => setMonth(val)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Zaměstnanec">
|
||||
<select
|
||||
value={filterUserId}
|
||||
onChange={(e) => setFilterUserId(e.target.value)}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="">Všichni</option>
|
||||
{data.users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 180px" }}>
|
||||
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={filterUserId}
|
||||
onChange={setFilterUserId}
|
||||
options={[
|
||||
{ value: "", label: "Všichni zaměstnanci" },
|
||||
...data.users.map((user) => ({
|
||||
value: String(user.id),
|
||||
label: user.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{/* User Totals */}
|
||||
{Object.keys(data.user_totals).length > 0 && (
|
||||
<motion.div
|
||||
className="admin-grid admin-grid-3 mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.09 }}
|
||||
{/* User Totals (KPI cards) */}
|
||||
{hasTotals && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
lg: "repeat(3, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
||||
const ut = userData as UserTotalData;
|
||||
const balance = data.leave_balances[uid];
|
||||
const statColor: StatCardColor = ut.working ? "success" : "default";
|
||||
return (
|
||||
<div key={uid} className="admin-card" style={{ display: "flex" }}>
|
||||
<div
|
||||
className="admin-card-body"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex-row gap-2 mb-2">
|
||||
<span style={{ fontWeight: 600 }}>{ut.name}</span>
|
||||
<span
|
||||
className={`attendance-working-badge ${ut.working ? "working" : "finished"}`}
|
||||
>
|
||||
{ut.working ? "\u2713" : "\u2717"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-stat-value">
|
||||
{formatMinutes(ut.minutes)}
|
||||
</div>
|
||||
<div className="admin-stat-label">odpracováno</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
marginBottom: "0.75rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.4rem",
|
||||
minHeight: "2rem",
|
||||
<StatCard
|
||||
key={uid}
|
||||
label={ut.name}
|
||||
value={
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{ut.vacation_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-vacation">
|
||||
Dov: {ut.vacation_hours}h
|
||||
</span>
|
||||
)}
|
||||
{ut.sick_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-sick">
|
||||
Nem: {ut.sick_hours}h
|
||||
</span>
|
||||
)}
|
||||
{ut.svatek !== undefined && ut.svatek > 0 && (
|
||||
<span className="attendance-leave-badge badge-holiday">
|
||||
Sv: {Math.round(ut.svatek)}h
|
||||
</span>
|
||||
)}
|
||||
{ut.unpaid_hours > 0 && (
|
||||
<span className="attendance-leave-badge badge-unpaid">
|
||||
Nep: {ut.unpaid_hours}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{ut.fund !== null &&
|
||||
(() => {
|
||||
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday
|
||||
// (everything the user actually got paid for this month:
|
||||
// raw worked time with sub-day remainders, plus
|
||||
// vacation days, plus hours worked on holidays, plus
|
||||
// the 8h per unworked holiday from the fund).
|
||||
const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
|
||||
const fondUsed =
|
||||
realWorked +
|
||||
(ut.vacation_hours ?? 0) +
|
||||
(ut.worked_holiday_hours ?? 0) +
|
||||
(ut.svatek ?? 0);
|
||||
const fundVal = ut.fund ?? 0;
|
||||
const delta = fondUsed - fundVal;
|
||||
return (
|
||||
<div
|
||||
className="kpi-card-footer"
|
||||
style={{
|
||||
marginTop: "auto",
|
||||
paddingTop: "0.75rem",
|
||||
borderTop: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
||||
{formatHoursDecimal(fundVal * 60)}h
|
||||
</span>
|
||||
{delta > 0 && (
|
||||
<span className="text-warning fw-600">
|
||||
+{formatHoursDecimal(delta * 60)}h
|
||||
</span>
|
||||
)}
|
||||
{delta <= 0 && delta < 0 && (
|
||||
<span className="text-danger fw-600">
|
||||
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "0.375rem",
|
||||
height: "4px",
|
||||
background: "var(--bg-tertiary)",
|
||||
borderRadius: "2px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${Math.min(
|
||||
100,
|
||||
(fondUsed / (ut.fund || 1)) * 100,
|
||||
)}%`,
|
||||
background: getFundBarBackground(ut),
|
||||
borderRadius: "2px",
|
||||
transition: "width 0.3s ease",
|
||||
{formatMinutes(ut.minutes)}
|
||||
<StatusChip
|
||||
color={ut.working ? "success" : "default"}
|
||||
label={ut.working ? "✓" : "✗"}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
color={statColor}
|
||||
footer={
|
||||
<Box>
|
||||
{/* Leave-type badges */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 0.5,
|
||||
mb: 1,
|
||||
minHeight: "1.75rem",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{ut.vacation_hours > 0 && (
|
||||
<StatusChip
|
||||
color="info"
|
||||
label={`Dov: ${ut.vacation_hours}h`}
|
||||
/>
|
||||
)}
|
||||
{ut.sick_hours > 0 && (
|
||||
<StatusChip
|
||||
color="error"
|
||||
label={`Nem: ${ut.sick_hours}h`}
|
||||
/>
|
||||
)}
|
||||
{ut.svatek !== undefined && ut.svatek > 0 && (
|
||||
<StatusChip
|
||||
color="warning"
|
||||
label={`Sv: ${Math.round(ut.svatek)}h`}
|
||||
/>
|
||||
)}
|
||||
{ut.unpaid_hours > 0 && (
|
||||
<StatusChip
|
||||
color="default"
|
||||
label={`Nep: ${ut.unpaid_hours}h`}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Fond usage */}
|
||||
{ut.fund !== null &&
|
||||
(() => {
|
||||
// Fond "used" = real_worked + vacation +
|
||||
// worked_holiday + free_holiday (everything the user
|
||||
// actually got paid for this month).
|
||||
const fondUsed = getFondUsed(ut);
|
||||
const fundVal = ut.fund ?? 0;
|
||||
const delta = fondUsed - fundVal;
|
||||
const pct = Math.min(
|
||||
100,
|
||||
(fondUsed / (ut.fund || 1)) * 100,
|
||||
);
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
>
|
||||
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
||||
{formatHoursDecimal(fundVal * 60)}h
|
||||
</Typography>
|
||||
{delta > 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: "warning.main",
|
||||
}}
|
||||
>
|
||||
+{formatHoursDecimal(delta * 60)}h
|
||||
</Typography>
|
||||
)}
|
||||
{delta < 0 && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: "error.main",
|
||||
}}
|
||||
>
|
||||
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<ProgressBar
|
||||
value={pct}
|
||||
color={getFundBarColor(ut)}
|
||||
height={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div
|
||||
className="text-secondary"
|
||||
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
|
||||
>
|
||||
{data.leave_balances[uid] ? (
|
||||
<>
|
||||
Zbývá dovolené:{" "}
|
||||
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
|
||||
h / {data.leave_balances[uid].vacation_total}h
|
||||
</>
|
||||
) : (
|
||||
<span style={{ visibility: "hidden" }}>—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Remaining vacation */}
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
{balance ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Zbývá dovolené:{" "}
|
||||
{balance.vacation_remaining.toFixed(1)}h /{" "}
|
||||
{balance.vacation_total}h
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ visibility: "hidden" }}
|
||||
>
|
||||
—
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Records Table */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<AttendanceShiftTable
|
||||
records={data.records}
|
||||
onEdit={openEditModal}
|
||||
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
<Card>
|
||||
<AttendanceShiftTable
|
||||
records={data.records}
|
||||
onEdit={openEditModal}
|
||||
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Modals */}
|
||||
<BulkAttendanceModal
|
||||
@@ -435,7 +441,7 @@ export default function AttendanceAdmin() {
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirm.show}
|
||||
onClose={() => setDeleteConfirm({ show: false, record: null })}
|
||||
onConfirm={handleDelete}
|
||||
@@ -444,6 +450,6 @@ export default function AttendanceAdmin() {
|
||||
confirmText="Smazat"
|
||||
confirmVariant="danger"
|
||||
/>
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
|
||||
import { userListOptions } from "../lib/queries/users";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import FormField from "../components/FormField";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { todayLocalStr } from "../utils/formatters";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DateField,
|
||||
Field,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
PageHeader,
|
||||
Select,
|
||||
TextField,
|
||||
TimeField,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
interface CreateForm {
|
||||
@@ -102,223 +114,218 @@ export default function AttendanceCreate() {
|
||||
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Přidat záznam docházky</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<Link
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Přidat záznam docházky"
|
||||
actions={
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
component={RouterLink}
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
>
|
||||
← Zpět na správu
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
← Zpět na správu
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<select
|
||||
value={form.leave_type}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, leave_type: e.target.value })
|
||||
}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="work">Práce</option>
|
||||
<option value="vacation">Dovolená</option>
|
||||
<option value="sick">Nemoc</option>
|
||||
<option value="unpaid">Neplacené volno</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
{!isWorkType && (
|
||||
<FormField label="Počet hodin">
|
||||
<input
|
||||
type="number"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
leave_hours: parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
min="0.5"
|
||||
max="24"
|
||||
step="0.5"
|
||||
className="admin-form-input"
|
||||
/>
|
||||
<small className="admin-form-hint">
|
||||
Výchozí 8 hodin pro celý den
|
||||
</small>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{isWorkType && (
|
||||
<>
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Příchod - datum">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.arrival_date}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, arrival_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Příchod - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.arrival_time}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, arrival_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Začátek pauzy - datum">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_start_date}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, break_start_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Začátek pauzy - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_start_time}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, break_start_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Konec pauzy - datum">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.break_end_date}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, break_end_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Konec pauzy - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.break_end_time}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, break_end_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Odchod - datum">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={form.departure_date}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, departure_date: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Odchod - čas">
|
||||
<AdminDatePicker
|
||||
mode="time"
|
||||
value={form.departure_time}
|
||||
onChange={(val: string) =>
|
||||
setForm({ ...form, departure_time: val })
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField label="Poznámka">
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
className="admin-form-textarea"
|
||||
rows={3}
|
||||
<Card sx={{ maxWidth: 640 }}>
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
{/* Employee + Shift date */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Zaměstnanec" required>
|
||||
<Select
|
||||
value={form.user_id}
|
||||
onChange={(val) => setForm({ ...form, user_id: val })}
|
||||
options={[
|
||||
{ value: "", label: "Vyberte zaměstnance" },
|
||||
...users.map((u) => ({
|
||||
value: String(u.id),
|
||||
label: u.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
</Field>
|
||||
<Field label="Datum směny" required>
|
||||
<DateField
|
||||
value={form.shift_date}
|
||||
onChange={(val) => handleShiftDateChange(val)}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<Link
|
||||
to="/attendance/admin"
|
||||
className="admin-btn admin-btn-secondary"
|
||||
{/* 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 && (
|
||||
<Field label="Počet hodin" hint="Výchozí 8 hodin pro celý den">
|
||||
<TextField
|
||||
type="number"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
leave_hours: parseFloat(e.target.value),
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Work-type time fields */}
|
||||
{isWorkType && (
|
||||
<>
|
||||
{/* Arrival */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
Zrušit
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="admin-btn admin-btn-primary"
|
||||
<Field label="Příchod - datum">
|
||||
<DateField
|
||||
value={form.arrival_date}
|
||||
onChange={(val) => setForm({ ...form, arrival_date: val })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Příchod - čas">
|
||||
<TimeField
|
||||
value={form.arrival_time}
|
||||
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Break start */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{submitting ? "Ukládám..." : "Uložit"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<Field label="Začátek pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_start_date}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_start_date: val })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Začátek pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_start_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_start_time: val })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Break end */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Konec pauzy - datum">
|
||||
<DateField
|
||||
value={form.break_end_date}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_end_date: val })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Konec pauzy - čas">
|
||||
<TimeField
|
||||
value={form.break_end_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, break_end_time: val })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
|
||||
{/* Departure */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Field label="Odchod - datum">
|
||||
<DateField
|
||||
value={form.departure_date}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, departure_date: val })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Odchod - čas">
|
||||
<TimeField
|
||||
value={form.departure_time}
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, departure_time: val })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<Field label="Poznámka">
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: "flex", gap: 1.5, justifyContent: "flex-end" }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
component={RouterLink}
|
||||
to="/attendance/admin"
|
||||
>
|
||||
Zrušit
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? "Ukládám..." : "Uložit"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { motion } from "framer-motion";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import { companySettingsOptions } from "../lib/queries/settings";
|
||||
import { iconBadgeSx } from "../theme";
|
||||
import {
|
||||
attendanceHistoryOptions,
|
||||
type AttendanceRecord,
|
||||
@@ -25,7 +26,20 @@ import {
|
||||
normalizeDateStr,
|
||||
formatHoursDecimal,
|
||||
} from "../utils/attendanceHelpers";
|
||||
import FormField from "../components/FormField";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
FilterBar,
|
||||
MonthField,
|
||||
ProgressBar,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"Leden",
|
||||
@@ -42,6 +56,21 @@ const MONTH_NAMES = [
|
||||
"Prosinec",
|
||||
];
|
||||
|
||||
const PrintIcon = (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="6 9 6 2 18 2 18 9" />
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||
<rect x="6" y="14" width="12" height="8" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const formatBreakRange = (record: AttendanceRecord): string => {
|
||||
if (record.break_start && record.break_end) {
|
||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
||||
@@ -122,9 +151,7 @@ function getBusinessDaysInMonth(year: number, month: number): number {
|
||||
const renderProjectCell = (record: AttendanceRecord) => {
|
||||
if (record.project_logs && record.project_logs.length > 0) {
|
||||
return (
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
|
||||
{record.project_logs.map((log, i) => {
|
||||
let h: number,
|
||||
m: number,
|
||||
@@ -145,31 +172,28 @@ const renderProjectCell = (record: AttendanceRecord) => {
|
||||
m = mins % 60;
|
||||
}
|
||||
return (
|
||||
<span
|
||||
<StatusChip
|
||||
key={log.id || i}
|
||||
className="admin-badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
display: "inline-block",
|
||||
background: isActive ? "var(--accent-light)" : undefined,
|
||||
}}
|
||||
>
|
||||
{log.project_name || `#${log.project_id}`} ({h}:
|
||||
{String(m).padStart(2, "0")}h{isActive ? " ▸" : ""})
|
||||
</span>
|
||||
color={isActive ? "info" : "default"}
|
||||
label={`${log.project_name || `#${log.project_id}`} (${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`}
|
||||
sx={{ fontSize: "0.7rem" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (record.project_name) {
|
||||
return (
|
||||
<span
|
||||
className="admin-badge admin-badge-wrap"
|
||||
style={{ fontSize: "0.75rem" }}
|
||||
>
|
||||
{record.project_name}
|
||||
</span>
|
||||
<StatusChip
|
||||
color="default"
|
||||
label={record.project_name}
|
||||
sx={{
|
||||
fontSize: "0.75rem",
|
||||
height: "auto",
|
||||
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return "—";
|
||||
@@ -373,325 +397,265 @@ 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 (
|
||||
<StatusChip
|
||||
color={leaveTypeChipColor(leaveType)}
|
||||
label={getLeaveTypeName(leaveType)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "arrival",
|
||||
header: "Příchod",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatDatetime(record.arrival_time);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "break",
|
||||
header: "Pauza",
|
||||
width: "11%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatBreakRange(record);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "departure",
|
||||
header: "Odchod",
|
||||
width: "10%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const isLeave = (record.leave_type || "work") !== "work";
|
||||
return isLeave ? "—" : formatDatetime(record.departure_time);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "hours",
|
||||
header: "Hodiny",
|
||||
width: "9%",
|
||||
mono: true,
|
||||
render: (record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (Number(record.leave_hours) || 8) * 60
|
||||
: calculateWorkMinutes(record);
|
||||
return workMinutes > 0 ? formatMinutes(workMinutes, true) : "—";
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "projects",
|
||||
header: "Projekty",
|
||||
width: "18%",
|
||||
render: (record) => renderProjectCell(record),
|
||||
},
|
||||
{
|
||||
key: "notes",
|
||||
header: "Poznámka",
|
||||
width: "11%",
|
||||
render: (record) => record.notes || "",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Historie docházky</h1>
|
||||
<p className="admin-page-subtitle">{computed.monthName}</p>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
{records.length > 0 && (
|
||||
<button
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Historie docházky"
|
||||
subtitle={computed.monthName}
|
||||
actions={
|
||||
records.length > 0 ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={PrintIcon}
|
||||
onClick={handlePrint}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
title="Tisk docházky"
|
||||
>
|
||||
Tisk
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "0 0 180px" }}>
|
||||
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
{/* Monthly Fund Card */}
|
||||
<Card sx={{ mt: 3 }}>
|
||||
{isPending ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={[
|
||||
{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 2,
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconBadgeSx("info"),
|
||||
]}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
width="24"
|
||||
height="24"
|
||||
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" />
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
Tisk
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Filters */}
|
||||
<motion.div
|
||||
className="admin-card mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form-row">
|
||||
<FormField label="Měsíc">
|
||||
<AdminDatePicker
|
||||
mode="month"
|
||||
value={month}
|
||||
onChange={(val: string) => setMonth(val)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Monthly Fund Card */}
|
||||
<motion.div
|
||||
className="admin-card mb-6"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{computed.monthlyFund && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "1rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: "200px" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
mb: 0.75,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 600, fontSize: "1rem" }}>
|
||||
Fond: {fund.worked}h / {fund.fund}h
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: "0.8125rem" }}
|
||||
>
|
||||
<div className="admin-stat-icon info">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: "200px" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "baseline",
|
||||
marginBottom: "0.375rem",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "1rem",
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Fond: {computed.monthlyFund.worked}h /{" "}
|
||||
{computed.monthlyFund.fund}h
|
||||
</span>
|
||||
<span
|
||||
className="text-secondary"
|
||||
style={{ fontSize: "0.8125rem" }}
|
||||
>
|
||||
{computed.monthlyFund.business_days} prac. dnů
|
||||
{computed.monthlyFund.holiday_count > 0 &&
|
||||
` + ${computed.monthlyFund.holiday_count} svátky`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="attendance-balance-bar">
|
||||
<div
|
||||
className="attendance-balance-progress"
|
||||
style={{
|
||||
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.worked / computed.monthlyFund.fund) * 100 : 0)}%`,
|
||||
background:
|
||||
computed.delta > 0
|
||||
? "linear-gradient(135deg, var(--warning), #d97706)"
|
||||
: computed.delta >= 0
|
||||
? "linear-gradient(135deg, var(--success), #059669)"
|
||||
: "var(--gradient)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.4rem",
|
||||
marginTop: "0.5rem",
|
||||
minHeight: "1.5rem",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.4rem",
|
||||
}}
|
||||
>
|
||||
{computed.totalMinutes > 0 && (
|
||||
<span
|
||||
className="attendance-leave-badge"
|
||||
title="Odpracováno (reálně)"
|
||||
>
|
||||
Práce: {formatHoursDecimal(computed.totalMinutes)}h
|
||||
</span>
|
||||
)}
|
||||
{computed.vacationHours > 0 && (
|
||||
<span className="attendance-leave-badge badge-vacation">
|
||||
Dov: {computed.vacationHours}h
|
||||
</span>
|
||||
)}
|
||||
{computed.sickHours > 0 && (
|
||||
<span className="attendance-leave-badge badge-sick">
|
||||
Nem: {computed.sickHours}h
|
||||
</span>
|
||||
)}
|
||||
{computed.freeHolidayHours > 0 && (
|
||||
<span className="attendance-leave-badge badge-holiday">
|
||||
Sv: {Math.round(computed.freeHolidayHours)}h
|
||||
</span>
|
||||
)}
|
||||
{computed.unpaidHours > 0 && (
|
||||
<span className="attendance-leave-badge badge-unpaid">
|
||||
Nep: {computed.unpaidHours}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
className={
|
||||
computed.delta > 0
|
||||
? "text-warning fw-600"
|
||||
: computed.delta < 0
|
||||
? "text-danger fw-600"
|
||||
: "text-success fw-600"
|
||||
}
|
||||
>
|
||||
{computed.delta > 0
|
||||
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
|
||||
: computed.delta < 0
|
||||
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
|
||||
: "Fond splněn"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!computed.monthlyFund && (
|
||||
<div
|
||||
className="text-muted"
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
textAlign: "center",
|
||||
padding: "0.5rem 0",
|
||||
}}
|
||||
{fund.business_days} prac. dnů
|
||||
{fund.holiday_count > 0 && ` + ${fund.holiday_count} svátky`}
|
||||
</Typography>
|
||||
</Box>
|
||||
<ProgressBar value={progressValue} color={progressColor} />
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 0.5,
|
||||
mt: 1,
|
||||
minHeight: "1.5rem",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
||||
{computed.totalMinutes > 0 && (
|
||||
<StatusChip
|
||||
color="info"
|
||||
label={`Práce: ${formatHoursDecimal(computed.totalMinutes)}h`}
|
||||
title="Odpracováno (reálně)"
|
||||
/>
|
||||
)}
|
||||
{computed.vacationHours > 0 && (
|
||||
<StatusChip
|
||||
color="success"
|
||||
label={`Dov: ${computed.vacationHours}h`}
|
||||
/>
|
||||
)}
|
||||
{computed.sickHours > 0 && (
|
||||
<StatusChip
|
||||
color="error"
|
||||
label={`Nem: ${computed.sickHours}h`}
|
||||
/>
|
||||
)}
|
||||
{computed.freeHolidayHours > 0 && (
|
||||
<StatusChip
|
||||
color="warning"
|
||||
label={`Sv: ${Math.round(computed.freeHolidayHours)}h`}
|
||||
/>
|
||||
)}
|
||||
{computed.unpaidHours > 0 && (
|
||||
<StatusChip
|
||||
color="default"
|
||||
label={`Nep: ${computed.unpaidHours}h`}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{ fontWeight: 600, color: deltaColor }}
|
||||
>
|
||||
Fond měsíce není k dispozici
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
{computed.delta > 0
|
||||
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
|
||||
: computed.delta < 0
|
||||
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
|
||||
: "Fond splněn"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Records Table */}
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.12 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{records.length === 0 && (
|
||||
<div className="admin-empty-state">
|
||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
||||
</div>
|
||||
)}
|
||||
{records.length > 0 && (
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Typ</th>
|
||||
<th>Příchod</th>
|
||||
<th>Pauza</th>
|
||||
<th>Odchod</th>
|
||||
<th>Hodiny</th>
|
||||
<th>Projekty</th>
|
||||
<th>Poznámka</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const leaveType = record.leave_type || "work";
|
||||
const isLeave = leaveType !== "work";
|
||||
const workMinutes = isLeave
|
||||
? (Number(record.leave_hours) || 8) * 60
|
||||
: calculateWorkMinutes(record);
|
||||
|
||||
return (
|
||||
<tr key={record.id}>
|
||||
<td className="admin-mono">
|
||||
{formatDate(record.shift_date)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
||||
>
|
||||
{getLeaveTypeName(leaveType)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave
|
||||
? "—"
|
||||
: formatDatetime(record.arrival_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave ? "—" : formatBreakRange(record)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{isLeave
|
||||
? "—"
|
||||
: formatDatetime(record.departure_time)}
|
||||
</td>
|
||||
<td className="admin-mono">
|
||||
{workMinutes > 0
|
||||
? formatMinutes(workMinutes, true)
|
||||
: "—"}
|
||||
</td>
|
||||
<td>{renderProjectCell(record)}</td>
|
||||
<td
|
||||
style={{
|
||||
maxWidth: "150px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{record.notes || ""}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<Card sx={{ mt: 3 }}>
|
||||
{isPending ? (
|
||||
<LoadingState />
|
||||
) : (
|
||||
<DataTable<AttendanceRecord>
|
||||
columns={columns}
|
||||
rows={records}
|
||||
rowKey={(record) => record.id}
|
||||
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Hidden Print Content */}
|
||||
{records.length > 0 && (
|
||||
@@ -896,6 +860,24 @@ export default function AttendanceHistory() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
/** Map an attendance leave_type to a StatusChip semantic color (mirrors the
|
||||
* legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue),
|
||||
* sick→error(red), unpaid→default(gray). */
|
||||
function leaveTypeChipColor(
|
||||
type: string,
|
||||
): "default" | "success" | "error" | "warning" | "info" {
|
||||
switch (type) {
|
||||
case "vacation":
|
||||
return "info";
|
||||
case "sick":
|
||||
return "error";
|
||||
case "unpaid":
|
||||
return "default";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,47 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { useNavigate, useParams, Link } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { useNavigate, useParams, Link as RouterLink } from "react-router-dom";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { styled } from "@mui/material/styles";
|
||||
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet map container (was the .attendance-location-map rule). zIndex:0
|
||||
// creates a stacking context so Leaflet's internal z-indexes stay contained.
|
||||
const LocationMap = styled("div")(({ theme }) => ({
|
||||
height: "400px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "1.5rem",
|
||||
background: theme.vars!.palette.background.paper,
|
||||
position: "relative",
|
||||
zIndex: 0,
|
||||
"@media (max-width:640px)": {
|
||||
height: "clamp(220px, 45vh, 400px)",
|
||||
},
|
||||
}));
|
||||
|
||||
import { formatDate, formatTime } from "../utils/attendanceHelpers";
|
||||
import {
|
||||
attendanceLocationOptions,
|
||||
type LocationRecord,
|
||||
} from "../lib/queries/attendance";
|
||||
import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
|
||||
|
||||
const BackIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AttendanceLocation() {
|
||||
const alert = useAlert();
|
||||
@@ -167,127 +197,164 @@ export default function AttendanceLocation() {
|
||||
const month = shiftDateStr.substring(0, 7);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Poloha záznamu</h1>
|
||||
</div>
|
||||
<div className="admin-page-actions">
|
||||
<Link
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Poloha záznamu"
|
||||
actions={
|
||||
<Button
|
||||
component={RouterLink}
|
||||
to={`/attendance/admin?month=${month}`}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={BackIcon}
|
||||
>
|
||||
← Zpět na správu
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
Zpět na správu
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-header">
|
||||
<h2 className="admin-card-title">
|
||||
{record.user_name} — {formatDate(record.shift_date)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
{hasAnyLocation && (
|
||||
<div ref={mapRef} className="attendance-location-map" />
|
||||
)}
|
||||
{/* Info + map card */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
|
||||
{record.user_name} — {formatDate(record.shift_date)}
|
||||
</Typography>
|
||||
|
||||
<div className="attendance-location-grid">
|
||||
{/* Arrival */}
|
||||
<div
|
||||
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
|
||||
{/* Leaflet map */}
|
||||
{hasAnyLocation && <LocationMap ref={mapRef} />}
|
||||
|
||||
{/* Location detail grid */}
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||
gap: 2,
|
||||
mt: hasAnyLocation ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
{/* Arrival */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
opacity: hasArrivalLocation ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Příchod
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
|
||||
>
|
||||
<h3 className="attendance-location-title">Příchod</h3>
|
||||
<div className="attendance-location-time">
|
||||
{record.arrival_time
|
||||
? formatDatetimeLocal(record.arrival_time)
|
||||
{record.arrival_time
|
||||
? formatDatetimeLocal(record.arrival_time)
|
||||
: "—"}
|
||||
</Typography>
|
||||
{hasArrivalLocation ? (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
{record.arrival_address || <em>Adresa nezjištěna</em>}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontSize: "0.75rem",
|
||||
color: "text.secondary",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
GPS: {record.arrival_lat}, {record.arrival_lng}
|
||||
{record.arrival_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
||||
</Typography>
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
Otevřít v Google Maps
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<em>Poloha nebyla zaznamenána</em>
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Departure */}
|
||||
{(hasDepartureLocation || record.departure_time) && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
opacity: hasDepartureLocation ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
Odchod
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
|
||||
>
|
||||
{record.departure_time
|
||||
? formatDatetimeLocal(record.departure_time)
|
||||
: "—"}
|
||||
</div>
|
||||
{hasArrivalLocation ? (
|
||||
</Typography>
|
||||
{hasDepartureLocation ? (
|
||||
<>
|
||||
<div className="attendance-location-address">
|
||||
{record.arrival_address || <em>Adresa nezjištěna</em>}
|
||||
</div>
|
||||
<div className="attendance-location-coords">
|
||||
GPS: {record.arrival_lat}, {record.arrival_lng}
|
||||
{record.arrival_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
||||
</div>
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
|
||||
<Typography variant="body2" sx={{ mb: 0.5 }}>
|
||||
{record.departure_address || <em>Adresa nezjištěna</em>}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontSize: "0.75rem",
|
||||
color: "text.secondary",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
GPS: {record.departure_lat}, {record.departure_lng}
|
||||
{record.departure_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
|
||||
</Typography>
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
size="small"
|
||||
>
|
||||
Otevřít v Google Maps
|
||||
</a>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<div className="attendance-location-address">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<em>Poloha nebyla zaznamenána</em>
|
||||
</div>
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Departure */}
|
||||
{(hasDepartureLocation || record.departure_time) && (
|
||||
<div
|
||||
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
|
||||
>
|
||||
<h3 className="attendance-location-title">Odchod</h3>
|
||||
<div className="attendance-location-time">
|
||||
{record.departure_time
|
||||
? formatDatetimeLocal(record.departure_time)
|
||||
: "—"}
|
||||
</div>
|
||||
{hasDepartureLocation ? (
|
||||
<>
|
||||
<div className="attendance-location-address">
|
||||
{record.departure_address || <em>Adresa nezjištěna</em>}
|
||||
</div>
|
||||
<div className="attendance-location-coords">
|
||||
GPS: {record.departure_lat}, {record.departure_lng}
|
||||
{record.departure_accuracy &&
|
||||
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
|
||||
</div>
|
||||
<a
|
||||
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
|
||||
>
|
||||
Otevřít v Google Maps
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<div className="attendance-location-address">
|
||||
<em>Poloha nebyla zaznamenána</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import Pagination from "../components/Pagination";
|
||||
import FormField from "../components/FormField";
|
||||
import FormModal from "../components/FormModal";
|
||||
import AdminDatePicker from "../components/AdminDatePicker";
|
||||
import { czechPlural } from "../utils/formatters";
|
||||
import apiFetch from "../utils/api";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { ENTITY_TYPE_LABELS } from "../lib/entityTypeLabels";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
Pagination,
|
||||
Modal,
|
||||
Select,
|
||||
DateField,
|
||||
TextField,
|
||||
StatusChip,
|
||||
PageHeader,
|
||||
PageEnter,
|
||||
FilterBar,
|
||||
EmptyState,
|
||||
LoadingState,
|
||||
type DataColumn,
|
||||
} from "../ui";
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
@@ -30,19 +44,23 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
access_denied: "Přístup odepřen",
|
||||
};
|
||||
|
||||
const ACTION_BADGE_CLASS: Record<string, string> = {
|
||||
create: "admin-badge-success",
|
||||
update: "admin-badge-info",
|
||||
delete: "admin-badge-danger",
|
||||
login: "admin-badge-secondary",
|
||||
login_failed: "admin-badge-danger",
|
||||
logout: "admin-badge-secondary",
|
||||
view: "admin-badge-info",
|
||||
activate: "admin-badge-success",
|
||||
deactivate: "admin-badge-warning",
|
||||
password_change: "admin-badge-info",
|
||||
permission_change: "admin-badge-warning",
|
||||
access_denied: "admin-badge-danger",
|
||||
// Maps legacy admin-badge-* CSS class suffixes → StatusChip color
|
||||
const ACTION_CHIP_COLOR: Record<
|
||||
string,
|
||||
"success" | "info" | "warning" | "error" | "default"
|
||||
> = {
|
||||
create: "success",
|
||||
update: "info",
|
||||
delete: "error",
|
||||
login: "default",
|
||||
login_failed: "error",
|
||||
logout: "default",
|
||||
view: "info",
|
||||
activate: "success",
|
||||
deactivate: "warning",
|
||||
password_change: "info",
|
||||
permission_change: "warning",
|
||||
access_denied: "error",
|
||||
};
|
||||
|
||||
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
|
||||
@@ -53,6 +71,29 @@ const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(
|
||||
([value, label]) => ({ value, label }),
|
||||
);
|
||||
|
||||
const CLEANUP_DAYS_OPTIONS = [
|
||||
{ value: "30", label: "30 dní" },
|
||||
{ value: "60", label: "60 dní" },
|
||||
{ value: "90", label: "90 dní" },
|
||||
{ value: "180", label: "180 dní" },
|
||||
{ value: "365", label: "1 rok" },
|
||||
{ value: "0", label: "Vše" },
|
||||
];
|
||||
|
||||
const TrashIcon = (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: number;
|
||||
created_at: string;
|
||||
@@ -82,11 +123,16 @@ export default function AuditLog() {
|
||||
date_to: "",
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(50);
|
||||
const [perPage] = useState(50);
|
||||
const [showCleanup, setShowCleanup] = useState(false);
|
||||
const [cleanupDays, setCleanupDays] = useState(90);
|
||||
// cleanupDays stored as string for the kit Select; converted to number at the boundary
|
||||
const [cleanupDaysStr, setCleanupDaysStr] = useState("90");
|
||||
|
||||
const { data: logsData, isPending } = useQuery({
|
||||
const {
|
||||
data: logsData,
|
||||
isPending,
|
||||
isFetching,
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
"audit-log",
|
||||
{
|
||||
@@ -127,6 +173,10 @@ export default function AuditLog() {
|
||||
},
|
||||
};
|
||||
},
|
||||
// Keep the current rows on screen while a filter refetches, so the page
|
||||
// doesn't drop to <LoadingState/> (which unmounts PageEnter and replays the
|
||||
// whole entrance animation on every filter change).
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
||||
@@ -154,16 +204,8 @@ export default function AuditLog() {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handlePerPageChange = (newPerPage: number) => {
|
||||
setPage(1);
|
||||
setPerPage(newPerPage);
|
||||
};
|
||||
|
||||
const handleCleanup = async () => {
|
||||
const cleanupDays = Number(cleanupDaysStr);
|
||||
try {
|
||||
// Backend requires this literal confirmation token when wiping everything.
|
||||
await cleanupMutation.mutateAsync({
|
||||
@@ -181,229 +223,156 @@ export default function AuditLog() {
|
||||
};
|
||||
|
||||
if (isPending && logs.length === 0) {
|
||||
return (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<motion.div
|
||||
className="admin-page-header"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<div>
|
||||
<h1 className="admin-page-title">Audit log</h1>
|
||||
{pagination && (
|
||||
<p className="admin-page-subtitle">
|
||||
{pagination.total}{" "}
|
||||
{czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
onClick={() => setShowCleanup(true)}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
Vyčistit
|
||||
</button>
|
||||
</motion.div>
|
||||
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 || "-",
|
||||
},
|
||||
];
|
||||
|
||||
<FormModal
|
||||
return (
|
||||
<PageEnter>
|
||||
<PageHeader
|
||||
title="Audit log"
|
||||
subtitle={
|
||||
pagination
|
||||
? `${pagination.total} ${czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}`
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={TrashIcon}
|
||||
onClick={() => setShowCleanup(true)}
|
||||
>
|
||||
Vyčistit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Cleanup Modal */}
|
||||
<Modal
|
||||
isOpen={showCleanup}
|
||||
onClose={() => !cleanupMutation.isPending && setShowCleanup(false)}
|
||||
onSubmit={handleCleanup}
|
||||
title="Vyčistit audit log"
|
||||
submitLabel="Smazat"
|
||||
submitText="Smazat"
|
||||
loading={cleanupMutation.isPending}
|
||||
>
|
||||
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
|
||||
<div style={{ margin: "0.75rem 0", maxWidth: "200px" }}>
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={cleanupDays}
|
||||
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
|
||||
>
|
||||
<option value={30}>30 dní</option>
|
||||
<option value={60}>60 dní</option>
|
||||
<option value={90}>90 dní</option>
|
||||
<option value={180}>180 dní</option>
|
||||
<option value={365}>1 rok</option>
|
||||
<option value={0}>Vše</option>
|
||||
</select>
|
||||
</div>
|
||||
<p
|
||||
className="admin-confirm-message"
|
||||
style={{ fontSize: "12px", opacity: 0.6 }}
|
||||
>
|
||||
Tato akce je nevratná.
|
||||
</p>
|
||||
</FormModal>
|
||||
|
||||
<motion.div
|
||||
className="admin-card mb-4"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-form-row admin-form-row-5">
|
||||
<FormField label="Hledat">
|
||||
<input
|
||||
type="text"
|
||||
className="admin-form-input"
|
||||
placeholder="Popis, uživatel..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Akce">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={filters.action}
|
||||
onChange={(e) => handleFilterChange("action", e.target.value)}
|
||||
>
|
||||
<option value="">Všechny</option>
|
||||
{ACTION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Typ entity">
|
||||
<select
|
||||
className="admin-form-select"
|
||||
value={filters.entity_type}
|
||||
onChange={(e) =>
|
||||
handleFilterChange("entity_type", e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Všechny</option>
|
||||
{ENTITY_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Od">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={filters.date_from}
|
||||
onChange={(val: string) => handleFilterChange("date_from", val)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Do">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={filters.date_to}
|
||||
onChange={(val: string) => handleFilterChange("date_to", val)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="admin-card"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-table-responsive">
|
||||
{isPending ? (
|
||||
<div className="admin-loading">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
) : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Čas</th>
|
||||
<th>Uživatel</th>
|
||||
<th>Akce</th>
|
||||
<th>Typ entity</th>
|
||||
<th>Popis</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6}>
|
||||
<div className="admin-empty-state">
|
||||
<div className="admin-empty-icon">
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
</div>
|
||||
<p>Žádné záznamy k zobrazení</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{logs.length > 0 &&
|
||||
logs.map((log) => (
|
||||
<tr key={log.id}>
|
||||
<td className="admin-mono">
|
||||
{formatDatetime(log.created_at)}
|
||||
</td>
|
||||
<td className="fw-500">{log.username || "-"}</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`}
|
||||
>
|
||||
{ACTION_LABELS[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{ENTITY_TYPE_LABELS[log.entity_type || ""] ||
|
||||
log.entity_type ||
|
||||
"-"}
|
||||
</td>
|
||||
<td>{log.description || "-"}</td>
|
||||
<td className="admin-mono">{log.user_ip || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Smazat záznamy starší než:
|
||||
</Typography>
|
||||
<Box sx={{ maxWidth: 200, mb: 1 }}>
|
||||
<Select
|
||||
value={cleanupDaysStr}
|
||||
onChange={setCleanupDaysStr}
|
||||
options={CLEANUP_DAYS_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ opacity: 0.6, fontSize: "0.75rem" }}>
|
||||
Tato akce je nevratná.
|
||||
</Typography>
|
||||
</Modal>
|
||||
|
||||
<FilterBar>
|
||||
<Box sx={{ flex: "1 1 320px" }}>
|
||||
<TextField
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||
placeholder="Popis, uživatel..."
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 160px" }}>
|
||||
<Select
|
||||
value={filters.action}
|
||||
onChange={(val) => handleFilterChange("action", val)}
|
||||
options={[{ value: "", label: "Všechny akce" }, ...ACTION_OPTIONS]}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 220px" }}>
|
||||
<Select
|
||||
value={filters.entity_type}
|
||||
onChange={(val) => handleFilterChange("entity_type", val)}
|
||||
options={[
|
||||
{ value: "", label: "Všechny entity" },
|
||||
...ENTITY_OPTIONS,
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 150px" }}>
|
||||
<DateField
|
||||
value={filters.date_from}
|
||||
onChange={(val) => handleFilterChange("date_from", val)}
|
||||
label="Od"
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: "0 0 150px" }}>
|
||||
<DateField
|
||||
value={filters.date_to}
|
||||
onChange={(val) => handleFilterChange("date_to", val)}
|
||||
label="Do"
|
||||
/>
|
||||
</Box>
|
||||
</FilterBar>
|
||||
|
||||
<Card sx={{ opacity: isFetching ? 0.6 : 1, transition: "opacity .2s" }}>
|
||||
<DataTable<AuditLogEntry>
|
||||
columns={columns}
|
||||
rows={logs}
|
||||
rowKey={(log) => log.id}
|
||||
empty={<EmptyState title="Žádné záznamy k zobrazení" />}
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={pagination?.total_pages ?? 1}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Card>
|
||||
</PageEnter>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user